commit 78b18797d500df2ea94421b90f45fe67651a7ef6 Author: Hanzo AI Date: Sat Apr 11 01:59:19 2026 -0700 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). diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..5da118e8 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/apps/extension/.depcheckrc b/apps/extension/.depcheckrc new file mode 100644 index 00000000..a520e271 --- /dev/null +++ b/apps/extension/.depcheckrc @@ -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', + ] diff --git a/apps/extension/.eslintignore b/apps/extension/.eslintignore new file mode 100644 index 00000000..8e9904e7 --- /dev/null +++ b/apps/extension/.eslintignore @@ -0,0 +1 @@ +jest-setup.js diff --git a/apps/extension/.eslintrc.js b/apps/extension/.eslintrc.js new file mode 100644 index 00000000..5be202e7 --- /dev/null +++ b/apps/extension/.eslintrc.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), + }, + }, + ], +} diff --git a/apps/extension/.gitignore b/apps/extension/.gitignore new file mode 100644 index 00000000..b0fedeff --- /dev/null +++ b/apps/extension/.gitignore @@ -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/ diff --git a/apps/extension/.oxlintrc.json b/apps/extension/.oxlintrc.json new file mode 100644 index 00000000..40e2022f --- /dev/null +++ b/apps/extension/.oxlintrc.json @@ -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" + } + } + ] +} diff --git a/apps/extension/RABBY_FEATURES.md b/apps/extension/RABBY_FEATURES.md new file mode 100644 index 00000000..537e08df --- /dev/null +++ b/apps/extension/RABBY_FEATURES.md @@ -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 diff --git a/apps/extension/README.md b/apps/extension/README.md new file mode 100644 index 00000000..4628a27e --- /dev/null +++ b/apps/extension/README.md @@ -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//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. diff --git a/apps/extension/__mocks__/expo-blur.jsx b/apps/extension/__mocks__/expo-blur.jsx new file mode 100644 index 00000000..953adee7 --- /dev/null +++ b/apps/extension/__mocks__/expo-blur.jsx @@ -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) =>
+ +export default BlurView diff --git a/apps/extension/config/getTsconfigAliases.test.ts b/apps/extension/config/getTsconfigAliases.test.ts new file mode 100644 index 00000000..521d3464 --- /dev/null +++ b/apps/extension/config/getTsconfigAliases.test.ts @@ -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) + }) +}) diff --git a/apps/extension/config/getTsconfigAliases.ts b/apps/extension/config/getTsconfigAliases.ts new file mode 100644 index 00000000..d13ab296 --- /dev/null +++ b/apps/extension/config/getTsconfigAliases.ts @@ -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 { + const aliases: Record = {} + + // 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 } } + 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 | 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 +} diff --git a/apps/extension/e2e/README.md b/apps/extension/e2e/README.md new file mode 100644 index 00000000..89ea11e6 --- /dev/null +++ b/apps/extension/e2e/README.md @@ -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. diff --git a/apps/extension/e2e/config/playwright.config.ts b/apps/extension/e2e/config/playwright.config.ts new file mode 100644 index 00000000..5bb61c72 --- /dev/null +++ b/apps/extension/e2e/config/playwright.config.ts @@ -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', +}) diff --git a/apps/extension/e2e/fixtures/extension-context.ts b/apps/extension/e2e/fixtures/extension-context.ts new file mode 100644 index 00000000..379ce107 --- /dev/null +++ b/apps/extension/e2e/fixtures/extension-context.ts @@ -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 { + 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 +} diff --git a/apps/extension/e2e/fixtures/extension.fixture.ts b/apps/extension/e2e/fixtures/extension.fixture.ts new file mode 100644 index 00000000..5a702dab --- /dev/null +++ b/apps/extension/e2e/fixtures/extension.fixture.ts @@ -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' diff --git a/apps/extension/e2e/fixtures/onboarded-extension.fixture.ts b/apps/extension/e2e/fixtures/onboarded-extension.fixture.ts new file mode 100644 index 00000000..26f34c81 --- /dev/null +++ b/apps/extension/e2e/fixtures/onboarded-extension.fixture.ts @@ -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({ + // 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) + }, +}) diff --git a/apps/extension/e2e/sandbox-test/child.html b/apps/extension/e2e/sandbox-test/child.html new file mode 100644 index 00000000..ddee2725 --- /dev/null +++ b/apps/extension/e2e/sandbox-test/child.html @@ -0,0 +1,64 @@ + + + + + Sandbox Child Frame + + + +
Detecting providers...
+ + + diff --git a/apps/extension/e2e/sandbox-test/index.html b/apps/extension/e2e/sandbox-test/index.html new file mode 100644 index 00000000..1ec9b415 --- /dev/null +++ b/apps/extension/e2e/sandbox-test/index.html @@ -0,0 +1,135 @@ + + + + + Extension Sandbox Origin Validation Test + + + +

Sandbox Origin Validation Test

+

Bug bounty #621 — Verify sandboxed frames cannot receive the Uniswap provider

+ +
+
+

1. Normal Frame (no sandbox)

+
Expected: origin = http://localhost:*, Uniswap provider = PRESENT
+ +
Waiting for report...
+
+ +
+

2. Sandboxed (allow-scripts only)

+
Expected: origin = "null", Uniswap provider = ABSENT
+ +
Waiting for report...
+
+ +
+

3. Sandboxed (allow-scripts + allow-same-origin)

+
Expected: origin = http://localhost:*, Uniswap provider = PRESENT
+ +
Waiting for report...
+
+
+ +
+ How to use:
+ 1. Serve this directory: python3 -m http.server 8080
+ 2. Load the extension in Chrome (webpack dev build via bun start:webpack)
+ 3. Open http://localhost:8080 in Chrome
+ 4. All three cards should show their expected results (green = pass, red = fail)
+
+ Note: This test detects the Uniswap provider specifically via EIP-6963 (rdns: org.uniswap.app), + so other wallet extensions (MetaMask, etc.) won't cause false positives. +
+ + + + diff --git a/apps/extension/e2e/tests/smoke/basic-setup.test.ts b/apps/extension/e2e/tests/smoke/basic-setup.test.ts new file mode 100644 index 00000000..515bd0e0 --- /dev/null +++ b/apps/extension/e2e/tests/smoke/basic-setup.test.ts @@ -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) + }) +}) diff --git a/apps/extension/e2e/tests/smoke/onboarding-flow.test.ts b/apps/extension/e2e/tests/smoke/onboarding-flow.test.ts new file mode 100644 index 00000000..91af8cdf --- /dev/null +++ b/apps/extension/e2e/tests/smoke/onboarding-flow.test.ts @@ -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) + }) +}) diff --git a/apps/extension/e2e/tests/smoke/sidebar-loads.test.ts b/apps/extension/e2e/tests/smoke/sidebar-loads.test.ts new file mode 100644 index 00000000..f43976fa --- /dev/null +++ b/apps/extension/e2e/tests/smoke/sidebar-loads.test.ts @@ -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) + }) +}) diff --git a/apps/extension/e2e/tests/smoke/wallet-connection.test.ts b/apps/extension/e2e/tests/smoke/wallet-connection.test.ts new file mode 100644 index 00000000..0c8e9ea4 --- /dev/null +++ b/apps/extension/e2e/tests/smoke/wallet-connection.test.ts @@ -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) + }) +}) diff --git a/apps/extension/e2e/utils/extension-helpers.ts b/apps/extension/e2e/utils/extension-helpers.ts new file mode 100644 index 00000000..73b9b51c --- /dev/null +++ b/apps/extension/e2e/utils/extension-helpers.ts @@ -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 { + 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 { + 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') +} diff --git a/apps/extension/e2e/utils/onboarding-helpers.ts b/apps/extension/e2e/utils/onboarding-helpers.ts new file mode 100644 index 00000000..373cfb31 --- /dev/null +++ b/apps/extension/e2e/utils/onboarding-helpers.ts @@ -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 { + 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) +} diff --git a/apps/extension/e2e/utils/wait-for-extension.ts b/apps/extension/e2e/utils/wait-for-extension.ts new file mode 100644 index 00000000..032eb493 --- /dev/null +++ b/apps/extension/e2e/utils/wait-for-extension.ts @@ -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 } +} diff --git a/apps/extension/entrypoints/background.ts b/apps/extension/entrypoints/background.ts new file mode 100644 index 00000000..17c144fd --- /dev/null +++ b/apps/extension/entrypoints/background.ts @@ -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 + }) +}) diff --git a/apps/extension/entrypoints/popup/App.css b/apps/extension/entrypoints/popup/App.css new file mode 100644 index 00000000..a28beed2 --- /dev/null +++ b/apps/extension/entrypoints/popup/App.css @@ -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; +} diff --git a/apps/extension/entrypoints/popup/App.tsx b/apps/extension/entrypoints/popup/App.tsx new file mode 100644 index 00000000..0252fd3e --- /dev/null +++ b/apps/extension/entrypoints/popup/App.tsx @@ -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 ( +
+
+
+ + Lux Exchange +
+ +
+ +
+
+
+ You pay + Balance: 0.00 +
+
+ setInputAmount(e.target.value)} + /> + +
+
+ + + +
+
+ You receive + Balance: 0.00 +
+
+ + +
+
+ + +
+ + +
+ ) +} diff --git a/apps/extension/entrypoints/popup/index.html b/apps/extension/entrypoints/popup/index.html new file mode 100644 index 00000000..28569342 --- /dev/null +++ b/apps/extension/entrypoints/popup/index.html @@ -0,0 +1,12 @@ + + + + + + Lux Exchange + + +
+ + + diff --git a/apps/extension/entrypoints/popup/index.tsx b/apps/extension/entrypoints/popup/index.tsx new file mode 100644 index 00000000..02f73ebc --- /dev/null +++ b/apps/extension/entrypoints/popup/index.tsx @@ -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() diff --git a/apps/extension/env.d.ts b/apps/extension/env.d.ts new file mode 100644 index 00000000..660ba371 --- /dev/null +++ b/apps/extension/env.d.ts @@ -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 {} diff --git a/apps/extension/eslint.config.mjs b/apps/extension/eslint.config.mjs new file mode 100644 index 00000000..ebc0dcef --- /dev/null +++ b/apps/extension/eslint.config.mjs @@ -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, + }, + }, + }, +] diff --git a/apps/extension/jest-setup.js b/apps/extension/jest-setup.js new file mode 100644 index 00000000..7ac54c79 --- /dev/null +++ b/apps/extension/jest-setup.js @@ -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 diff --git a/apps/extension/jest.config.js b/apps/extension/jest.config.js new file mode 100644 index 00000000..3093d961 --- /dev/null +++ b/apps/extension/jest.config.js @@ -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: '/src/test/jest-resolver.js', + displayName: 'Extension Wallet', + testMatch: ['/src/**/*.(spec|test).[jt]s?(x)', '/config/**/*.(spec|test).[jt]s?(x)'], + testPathIgnorePatterns: [...preset.testPathIgnorePatterns, '/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'], +} diff --git a/apps/extension/package.json b/apps/extension/package.json new file mode 100644 index 00000000..143fee0d --- /dev/null +++ b/apps/extension/package.json @@ -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": [] + } +} diff --git a/apps/extension/project.json b/apps/extension/project.json new file mode 100644 index 00000000..69744bd9 --- /dev/null +++ b/apps/extension/project.json @@ -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": {} + } +} diff --git a/apps/extension/scripts/validateBuildOutput.ts b/apps/extension/scripts/validateBuildOutput.ts new file mode 100644 index 00000000..ec4c6fcd --- /dev/null +++ b/apps/extension/scripts/validateBuildOutput.ts @@ -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() diff --git a/apps/extension/src/app/Global.css b/apps/extension/src/app/Global.css new file mode 100644 index 00000000..0e392a9e --- /dev/null +++ b/apps/extension/src/app/Global.css @@ -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); + } +} diff --git a/apps/extension/src/app/apollo.tsx b/apps/extension/src/app/apollo.tsx new file mode 100644 index 00000000..3955e078 --- /dev/null +++ b/apps/extension/src/app/apollo.tsx @@ -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): JSX.Element { + const apolloClient = usePersistedApolloClient({ + storageWrapper: localStorage, + maxCacheSizeInBytes: MAX_CACHE_SIZE_IN_BYTES, + reduxStore: getReduxStore(), + }) + + if (!apolloClient) { + return <> + } + + return {children} +} diff --git a/apps/extension/src/app/components/AutoLockProvider.test.tsx b/apps/extension/src/app/components/AutoLockProvider.test.tsx new file mode 100644 index 00000000..5e3f6979 --- /dev/null +++ b/apps/extension/src/app/components/AutoLockProvider.test.tsx @@ -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( + +
Test
+
, + { + preloadedState: { + userSettings: { + currentLanguage: Language.English, + currentCurrency: FiatCurrency.UnitedStatesDollar, + hideSmallBalances: true, + hideSpamTokens: true, + hapticsEnabled: true, + deviceAccessTimeout, + }, + }, + }, + ) +} + +const simulateFocusChange = (component: ReturnType) => (fromFocused: boolean, toFocused: boolean) => { + mockUseIsChromeWindowFocused.mockReturnValue(fromFocused) + const { rerender } = component + + mockUseIsChromeWindowFocused.mockReturnValue(toFocused) + rerender() +} + +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() + + 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() + + 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() + + // 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() + + // 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() + + // Should only clear alarm (wallet state change priority), not schedule + expect(mockChromeAlarms.clear).toHaveBeenCalledTimes(1) + expect(mockChromeAlarms.create).not.toHaveBeenCalled() + }) + }) +}) diff --git a/apps/extension/src/app/components/AutoLockProvider.tsx b/apps/extension/src/app/components/AutoLockProvider.tsx new file mode 100644 index 00000000..68c18d81 --- /dev/null +++ b/apps/extension/src/app/components/AutoLockProvider.tsx @@ -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(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} +} diff --git a/apps/extension/src/app/components/ErrorElement.tsx b/apps/extension/src/app/components/ErrorElement.tsx new file mode 100644 index 00000000..1ca005a4 --- /dev/null +++ b/apps/extension/src/app/components/ErrorElement.tsx @@ -0,0 +1,13 @@ +import { PropsWithChildren } from 'react' +import { useRouteError } from 'react-router' + +export function ErrorElement({ children }: PropsWithChildren): JSX.Element { + const error = useRouteError() + + if (!error) { + return <>{children} + } + + // Need to throw here to propagate to the ErrorBoundary + throw error +} diff --git a/apps/extension/src/app/components/Input.tsx b/apps/extension/src/app/components/Input.tsx new file mode 100644 index 00000000..41ff4a1d --- /dev/null +++ b/apps/extension/src/app/components/Input.tsx @@ -0,0 +1,38 @@ +import { forwardRef } from 'react' +import { Input as TamaguiInput, InputProps as TamaguiInputProps } from 'ui/src' +import { inputStyles } from 'ui/src/components/input/utils' +import { fonts } from 'ui/src/theme/fonts' + +export type InputProps = { + large?: boolean + hideInput?: boolean + centered?: boolean +} & TamaguiInputProps + +export type Input = TamaguiInput + +export const Input = forwardRef(function InputInner( + { large = false, hideInput = false, centered = false, ...rest }: InputProps, + ref, +): JSX.Element { + return ( + + ) +}) diff --git a/apps/extension/src/app/components/PasswordInput.tsx b/apps/extension/src/app/components/PasswordInput.tsx new file mode 100644 index 00000000..7cfed81a --- /dev/null +++ b/apps/extension/src/app/components/PasswordInput.tsx @@ -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(function PasswordInput( + { passwordStrength, hideInput, onToggleHideInput, value, ...inputProps }, + ref, +): JSX.Element { + return ( + + + + {passwordStrength !== undefined ? ( + + ) : ( + onToggleHideInput && ( + onToggleHideInput(!hideInput)} + > + {hideInput ? : } + + ) + )} + + ) +}) + +export const PasswordInputWithBiometrics = forwardRef< + TextInput, + PasswordInputProps & { onPressBiometricUnlock: () => void } +>(function PasswordInputWithBiometrics( + { onPressBiometricUnlock, hideBiometrics = false, ...passwordInputProps }, + ref, +): JSX.Element { + const shouldShowBiometricUnlock = useShouldShowBiometricUnlock() && !hideBiometrics + + return ( + + + + + + {shouldShowBiometricUnlock && ( + + + + )} + + ) +}) + +function StrengthIndicator({ strength }: { strength: PasswordStrength }): JSX.Element | null { + const { t } = useTranslation() + if (strength === PasswordStrength.NONE) { + return null + } + + const { text, color } = getPasswordStrengthTextAndColor(t, strength) + + return ( + + + {text} + + + ) +} diff --git a/apps/extension/src/app/components/Trace/TraceUserProperties.tsx b/apps/extension/src/app/components/Trace/TraceUserProperties.tsx new file mode 100644 index 00000000..fda87455 --- /dev/null +++ b/apps/extension/src/app/components/Trace/TraceUserProperties.tsx @@ -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 +} diff --git a/apps/extension/src/app/components/Trace/useTraceSidebarDappUrl.tsx b/apps/extension/src/app/components/Trace/useTraceSidebarDappUrl.tsx new file mode 100644 index 00000000..0ce516f3 --- /dev/null +++ b/apps/extension/src/app/components/Trace/useTraceSidebarDappUrl.tsx @@ -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]) +} diff --git a/apps/extension/src/app/components/buttons/CopyButton.tsx b/apps/extension/src/app/components/buttons/CopyButton.tsx new file mode 100644 index 00000000..d77800a4 --- /dev/null +++ b/apps/extension/src/app/components/buttons/CopyButton.tsx @@ -0,0 +1,75 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { AnimatePresence, Flex, Text, TouchableArea } from 'ui/src' +import { Check, CopySheets } from 'ui/src/components/icons' +import { iconSizes, zIndexes } from 'ui/src/theme' + +export function CopyButton({ onCopyPress }: { onCopyPress: () => Promise }): JSX.Element { + const { t } = useTranslation() + + const [valueCopied, setValueCopied] = useState(false) + + const onPress = async (): Promise => { + await onCopyPress() + setValueCopied(true) + } + + return ( + + + + + {/* note there's various x/y adjustments here due to visual imbalance of icons/text */} + + {valueCopied ? ( + // check icon is a bit smaller and to the right + + ) : ( + + )} + + {valueCopied ? t('common.button.copied') : t('common.button.copy')} + + + + + + + ) +} diff --git a/apps/extension/src/app/components/buttons/OpenSidebarButton.tsx b/apps/extension/src/app/components/buttons/OpenSidebarButton.tsx new file mode 100644 index 00000000..a0573044 --- /dev/null +++ b/apps/extension/src/app/components/buttons/OpenSidebarButton.tsx @@ -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 + handleOpenWebApp: () => Promise +}) { + const { t } = useTranslation() + return ( + + + + ) +} diff --git a/apps/extension/src/app/components/buttons/OptionCard.tsx b/apps/extension/src/app/components/buttons/OptionCard.tsx new file mode 100644 index 00000000..a535ee98 --- /dev/null +++ b/apps/extension/src/app/components/buttons/OptionCard.tsx @@ -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 ( + + + + + + + + {title} + + + {subtitle} + + + + + ) +} diff --git a/apps/extension/src/app/components/layout/ScreenHeader.tsx b/apps/extension/src/app/components/layout/ScreenHeader.tsx new file mode 100644 index 00000000..980a3274 --- /dev/null +++ b/apps/extension/src/app/components/layout/ScreenHeader.tsx @@ -0,0 +1,34 @@ +import { useExtensionNavigation } from 'src/app/navigation/utils' +import { Flex, GeneratedIcon, IconProps, Text, TouchableArea } from 'ui/src' +import { BackArrow } from 'ui/src/components/icons' +import { iconSizes } from 'ui/src/theme' + +export function ScreenHeader({ + onBackClick, + title, + rightColumn, + Icon = BackArrow, +}: { + title?: JSX.Element | string + onBackClick?: () => void + rightColumn?: JSX.Element + Icon?: GeneratedIcon | ((props: IconProps) => JSX.Element) +}): JSX.Element { + const { navigateBack } = useExtensionNavigation() + + return ( + + + + + + {/* When there's no right column, we adjust the margin to match the icon width. This is so that the title is centered on the screen. */} + + {/* // Render empty string if no title to account for Text element added padding for consistent size*/} + {title ?? ' '} + + + {rightColumn && {rightColumn}} + + ) +} diff --git a/apps/extension/src/app/components/loading/SelectWalletSkeleton.tsx b/apps/extension/src/app/components/loading/SelectWalletSkeleton.tsx new file mode 100644 index 00000000..71a58854 --- /dev/null +++ b/apps/extension/src/app/components/loading/SelectWalletSkeleton.tsx @@ -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 ( + + {/* oxlint-disable-next-line max-params */} + {new Array(repeat).fill(null).map((_, i, { length }) => ( + + ))} + + ) +} + +function WalletSkeleton({ opacity }: { opacity: number }): JSX.Element { + return ( + + + + + + + + + + + ) +} diff --git a/apps/extension/src/app/components/loading/SkeletonBox.css b/apps/extension/src/app/components/loading/SkeletonBox.css new file mode 100644 index 00000000..aebf9b3e --- /dev/null +++ b/apps/extension/src/app/components/loading/SkeletonBox.css @@ -0,0 +1,40 @@ +.skeleton-box { + display: inline-block; + height: 1em; + position: relative; + overflow: hidden; +} + +.skeleton-box::after { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + transform: translateX(-100%); + background-image: linear-gradient( + -75deg, + rgba(240, 240, 240, 0) 0, + rgba(240, 240, 240, 0.2) 20%, + rgba(240, 240, 240, 0.5) 60%, + rgba(240, 240, 240, 0) + ); + animation: skeleton-box-shimmer 1s linear infinite; + content: ''; +} + +.t_dark .skeleton-box::after { + background-image: linear-gradient( + -75deg, + rgba(30, 30, 30, 0) 0, + rgba(30, 30, 30, 0.2) 20%, + rgba(30, 30, 30, 0.5) 60%, + rgba(30, 30, 30, 0) + ); +} + +@keyframes skeleton-box-shimmer { + 100% { + transform: translateX(100%); + } +} diff --git a/apps/extension/src/app/components/loading/SkeletonBox.tsx b/apps/extension/src/app/components/loading/SkeletonBox.tsx new file mode 100644 index 00000000..eabca0b1 --- /dev/null +++ b/apps/extension/src/app/components/loading/SkeletonBox.tsx @@ -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
+} diff --git a/apps/extension/src/app/components/modal/InfoModal.tsx b/apps/extension/src/app/components/modal/InfoModal.tsx new file mode 100644 index 00000000..0cf0b00b --- /dev/null +++ b/apps/extension/src/app/components/modal/InfoModal.tsx @@ -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): JSX.Element { + const colors = useSporeColors() + + return ( + + {showCloseButton && ( + + + + )} + + {icon} + + + {title} + + + {description} + + + + + + {linkText && linkUrl && ( + + + {linkText} + + + )} + + + ) +} diff --git a/apps/extension/src/app/components/modals/SmartWalletNudgeModals.tsx b/apps/extension/src/app/components/modals/SmartWalletNudgeModals.tsx new file mode 100644 index 00000000..f7a817a2 --- /dev/null +++ b/apps/extension/src/app/components/modals/SmartWalletNudgeModals.tsx @@ -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 + case ModalName.SmartWalletNudge: + return ( + { + if (!address) { + return + } + + dispatch(setSmartWalletConsent({ address, smartWalletConsent: true })) + openModal(ModalName.SmartWalletEnabledModal) + }} + /> + ) + case ModalName.SmartWalletEnabledModal: + return + default: + return null + } +} diff --git a/apps/extension/src/app/components/tabs/ActivityTab.tsx b/apps/extension/src/app/components/tabs/ActivityTab.tsx new file mode 100644 index 00000000..bb2d0e20 --- /dev/null +++ b/apps/extension/src/app/components/tabs/ActivityTab.tsx @@ -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 ( + + {/* `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 && ( + + + + )} + {/* Intersection observer sentinel for infinite scroll */} + + + ) +}) diff --git a/apps/extension/src/app/components/tabs/NftsTab.tsx b/apps/extension/src/app/components/tabs/NftsTab.tsx new file mode 100644 index 00000000..1e34b3ad --- /dev/null +++ b/apps/extension/src/app/components/tabs/NftsTab.tsx @@ -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 ( + + + + ) + }, + [accounts, owner], + ) + + return ( + + ) +}) + +const defaultEmptyStyle = { + minHeight: 100, + paddingVertical: '$spacing12', + width: '100%', +} diff --git a/apps/extension/src/app/constants.ts b/apps/extension/src/app/constants.ts new file mode 100644 index 00000000..a27c1202 --- /dev/null +++ b/apps/extension/src/app/constants.ts @@ -0,0 +1,3 @@ +import { SpaceTokens } from 'ui/src' + +export const SCREEN_ITEM_HORIZONTAL_PAD = '$spacing12' satisfies SpaceTokens diff --git a/apps/extension/src/app/context/SmartWalletNudgesContext.tsx b/apps/extension/src/app/context/SmartWalletNudgesContext.tsx new file mode 100644 index 00000000..1591c19b --- /dev/null +++ b/apps/extension/src/app/context/SmartWalletNudgesContext.tsx @@ -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(undefined) + +export function SmartWalletNudgesProvider({ children }: { children: ReactNode }): JSX.Element { + const dispatch = useDispatch() + + const [activeModal, setActiveModal] = useState(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 ( + + {children} + + ) +} + +export function useSmartWalletNudges(): SmartWalletNudgesContextState { + const context = useContext(SmartWalletNudgesContext) + if (!context) { + throw new Error('useSmartWalletNudges must be used within a SmartWalletNudgesProvider') + } + return context +} diff --git a/apps/extension/src/app/core/BaseAppContainer.tsx b/apps/extension/src/app/core/BaseAppContainer.tsx new file mode 100644 index 00000000..f2ff6aa9 --- /dev/null +++ b/apps/extension/src/app/core/BaseAppContainer.tsx @@ -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() + + 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 {children} +} + +function BaseAppContainerInner({ children }: PropsWithChildren): JSX.Element { + const isSessionServiceEnabled = useIsSessionServiceEnabled() + + return ( + + + + + + + + + + + {children} + + + + + + + ) +} + +export function BaseAppContainer({ + children, + appName, +}: PropsWithChildren<{ appName: DatadogAppNameTag }>): JSX.Element { + return ( + + + {children} + + + ) +} + +function LanguageSync(): null { + const currentLanguage = useCurrentLanguage() + + useEffect(() => { + changeLanguage(getLocale(currentLanguage)).catch(() => undefined) + }, [currentLanguage]) + + return null +} diff --git a/apps/extension/src/app/core/DevMenuModal.tsx b/apps/extension/src/app/core/DevMenuModal.tsx new file mode 100644 index 00000000..20881f4c --- /dev/null +++ b/apps/extension/src/app/core/DevMenuModal.tsx @@ -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 ( + <> + + + + + {isOpen && ( + + + + + + )} + + ) +} diff --git a/apps/extension/src/app/core/OnboardingApp.test.tsx b/apps/extension/src/app/core/OnboardingApp.test.tsx new file mode 100644 index 00000000..b5506947 --- /dev/null +++ b/apps/extension/src/app/core/OnboardingApp.test.tsx @@ -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() + }) +}) diff --git a/apps/extension/src/app/core/OnboardingApp.tsx b/apps/extension/src/app/core/OnboardingApp.tsx new file mode 100644 index 00000000..5dafe03d --- /dev/null +++ b/apps/extension/src/app/core/OnboardingApp.tsx @@ -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: , +} + +const allRoutes = [ + { + path: '', + element: , + }, + { + path: OnboardingRoutes.UnsupportedBrowser, + element: , + }, + { + path: OnboardingRoutes.Create, + element: ( + , + [CreateOnboardingSteps.Password]: , + [CreateOnboardingSteps.Complete]: , + }} + /> + ), + }, + { + path: OnboardingRoutes.SelectImportMethod, + element: ( + , + }} + /> + ), + }, + { + path: OnboardingRoutes.ImportPasskey, + element: ( + , + [ImportPasskeySteps.PasskeyImport]: , + [ImportOnboardingSteps.Password]: , + [ImportOnboardingSteps.Select]: , + [ImportOnboardingSteps.Complete]: , + }} + /> + ), + }, + { + path: OnboardingRoutes.Import, + element: ( + , + [ImportOnboardingSteps.Password]: , + [ImportOnboardingSteps.Select]: , + [ImportOnboardingSteps.Complete]: , + }} + /> + ), + }, + { + path: OnboardingRoutes.Scan, + element: , + }, + { + path: OnboardingRoutes.ResetScan, + element: , + }, + { + path: OnboardingRoutes.Reset, + element: ( + , + [ResetSteps.Password]: , + [ResetSteps.Select]: , + [ResetSteps.Complete]: , + }} + /> + ), + }, +] + +const router = createHashRouter([ + { + path: `/${TopLevelRoutes.Onboarding}`, + element: , + errorElement: , + children: !supportsSidePanel ? [unsupportedRoute] : allRoutes, + }, +]) + +function ScantasticFlow({ isResetting = false }: { isResetting?: boolean }): JSX.Element { + return ( + , + [ScanOnboardingSteps.OTP]: , + [ScanOnboardingSteps.Password]: , + [ScanOnboardingSteps.Select]: , + [ScanOnboardingSteps.Complete]: isResetting ? ( + + ) : ( + + ), + }} + /> + ) +} + +/** + * Note: we are using a pattern here to avoid circular dependencies, because + * this is the root of the app and it imports all sub-pages, we need to push the + * router/router state to a different file so it can be imported by those pages + */ +router.subscribe((state) => { + setRouterState(state) +}) + +setRouter(router) + +export default function OnboardingApp(): JSX.Element { + // initialize analytics on load + useEffect(() => { + async function initAndLogLoad(): Promise { + await initExtensionAnalytics() + sendAnalyticsEvent(ExtensionEventName.OnboardingLoad) + } + initAndLogLoad().catch(() => undefined) + }, []) + + return ( + + + + + + + + + + + + + ) +} diff --git a/apps/extension/src/app/core/PopupApp.tsx b/apps/extension/src/app/core/PopupApp.tsx new file mode 100644 index 00000000..79fc8880 --- /dev/null +++ b/apps/extension/src/app/core/PopupApp.tsx @@ -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: , + 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 ( + + + + + + + + + + + + + + {t('extension.popup.chrome.title')} + + + {t('extension.popup.chrome.description')} + + + + + + + + + + + + + ) +} + +// 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 ( + + + + ) +} diff --git a/apps/extension/src/app/core/SidebarApp.tsx b/apps/extension/src/app/core/SidebarApp.tsx new file mode 100644 index 00000000..0f5f366c --- /dev/null +++ b/apps/extension/src/app/core/SidebarApp.tsx @@ -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: , + errorElement: , + children: [ + { + path: '', + element: , + }, + { + path: AppRoutes.AccountSwitcher, + element: , + }, + { + path: AppRoutes.Settings, + element: , + children: [ + { + path: '', + element: , + }, + { + path: SettingsRoutes.DeviceAccess, + element: , + }, + ...(isDevEnv() + ? [ + { + path: SettingsRoutes.DevMenu, + element: , + }, + { + path: SettingsRoutes.SessionsDebug, + element: , + }, + { + path: SettingsRoutes.HashcashBenchmark, + element: , + }, + ] + : []), + { + path: SettingsRoutes.ViewRecoveryPhrase, + element: , + }, + { + path: SettingsRoutes.BackupRecoveryPhrase, + element: , + }, + { + path: SettingsRoutes.RemoveRecoveryPhrase, + children: [ + { + path: RemoveRecoveryPhraseRoutes.Wallets, + element: , + }, + { + path: RemoveRecoveryPhraseRoutes.Verify, + element: , + }, + ], + }, + { + path: SettingsRoutes.ManageConnections, + element: , + }, + { + path: SettingsRoutes.SmartWallet, + element: , + }, + { + path: SettingsRoutes.Storage, + element: , + }, + ], + }, + { + path: AppRoutes.Send, + element: , + }, + { + path: AppRoutes.Swap, + element: , + }, + { + path: AppRoutes.Receive, + element: , + }, + ], + }, +]) + +const PORT_PING_INTERVAL = 5 * ONE_SECOND_MS +function useDappRequestPortListener(): void { + const dispatch = useDispatch() + const [currentPortChannel, setCurrentPortChannel] = useState() + const [windowId, setWindowId] = useState() + + // 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 ( + <> + + + ) +} + +/** + * 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 ( + <> + + + + ) +} + +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 ( + + + + + + + + ) +} diff --git a/apps/extension/src/app/core/StatsigProvider.tsx b/apps/extension/src/app/core/StatsigProvider.tsx new file mode 100644 index 00000000..7c6aeb01 --- /dev/null +++ b/apps/extension/src/app/core/StatsigProvider.tsx @@ -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({ + 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 ( + + {children} + + ) +} diff --git a/apps/extension/src/app/core/UnitagClaimApp.tsx b/apps/extension/src/app/core/UnitagClaimApp.tsx new file mode 100644 index 00000000..24c20b5f --- /dev/null +++ b/apps/extension/src/app/core/UnitagClaimApp.tsx @@ -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: , + children: [ + { + path: UnitagClaimRoutes.ClaimIntro, + element: , + errorElement: , + }, + { + path: UnitagClaimRoutes.EditProfile, + element: , + 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 +} + +function UnitagClaimFlow(): JSX.Element { + return ( + + , + [ClaimUnitagSteps.CreateUsername]: , + [ClaimUnitagSteps.ChooseProfilePic]: , + [ClaimUnitagSteps.Confirmation]: , + [ClaimUnitagSteps.EditProfile]: , + }} + ContainerComponent={UnitagClaimAppWrapper} + /> + + + ) +} + +function UnitagClaimAppWrapper({ children }: PropsWithChildren): JSX.Element { + const { step } = useOnboardingSteps() + const blurAllBackground = step !== ClaimUnitagSteps.Intro + + return ( + + {children} + + ) +} + +function UnitagEditProfileFlow(): JSX.Element { + return ( + + , + }} + ContainerComponent={UnitagClaimAppWrapper} + /> + + + ) +} + +// TODO WALL-4876 combine this with `PopupApp` +export default function UnitagClaimApp(): JSX.Element { + // initialize analytics on load + useEffect(() => { + initExtensionAnalytics().catch(() => undefined) + }, []) + + return ( + + + + ) +} diff --git a/apps/extension/src/app/core/initStatSigForBrowserScripts.tsx b/apps/extension/src/app/core/initStatSigForBrowserScripts.tsx new file mode 100644 index 00000000..7a495477 --- /dev/null +++ b/apps/extension/src/app/core/initStatSigForBrowserScripts.tsx @@ -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 { + 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' }, + }) + }) +} diff --git a/apps/extension/src/app/datadog.ts b/apps/extension/src/app/datadog.ts new file mode 100644 index 00000000..b99fba64 --- /dev/null +++ b/apps/extension/src/app/datadog.ts @@ -0,0 +1,8 @@ +export const enum DatadogAppNameTag { + Sidebar = 'sidebar', + Onboarding = 'onboarding', + ContentScript = 'content-script', + Background = 'background', + Popup = 'popup', + UnitagClaim = 'unitag-claim', +} diff --git a/apps/extension/src/app/events/constants.ts b/apps/extension/src/app/events/constants.ts new file mode 100644 index 00000000..7a81e314 --- /dev/null +++ b/apps/extension/src/app/events/constants.ts @@ -0,0 +1,3 @@ +export enum GlobalErrorEvent { + ReduxStorageExceeded = 'ReduxStorageExceeded', +} diff --git a/apps/extension/src/app/events/global.ts b/apps/extension/src/app/events/global.ts new file mode 100644 index 00000000..d631a731 --- /dev/null +++ b/apps/extension/src/app/events/global.ts @@ -0,0 +1,5 @@ +import EventEmitter from 'eventemitter3' +import { GlobalErrorEvent } from 'src/app/events/constants' + +class GlobalEventEmitter extends EventEmitter {} +export const globalEventEmitter = new GlobalEventEmitter() diff --git a/apps/extension/src/app/features/accounts/AccountItem.tsx b/apps/extension/src/app/features/accounts/AccountItem.tsx new file mode 100644 index 00000000..9889dbdd --- /dev/null +++ b/apps/extension/src/app/features/accounts/AccountItem.tsx @@ -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 => { + 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 => { + if (accountHasUnitag) { + await focusOrCreateUnitagTab(address, UnitagClaimRoutes.EditProfile) + } else { + setShowEditLabelModal(true) + } + }, + Icon: Edit, + }, + { + label: t('account.wallet.menu.manageConnections'), + onPress: async (): Promise => { + 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 ( + <> + } + isOpen={showRemoveWalletModal} + modalName={ModalName.RemoveWallet} + severity={WarningSeverity.High} + title={t('account.wallet.remove.title', { name: displayName?.name ?? '' })} + onClose={() => setShowRemoveWalletModal(false)} + onAcknowledge={onRemoveWallet} + /> + setShowEditLabelModal(false)} /> + + + + + + + {formattedBalance} + + + + + + + + + + ) +} diff --git a/apps/extension/src/app/features/accounts/AccountSwitcherScreen.test.tsx b/apps/extension/src/app/features/accounts/AccountSwitcherScreen.test.tsx new file mode 100644 index 00000000..c9b48473 --- /dev/null +++ b/apps/extension/src/app/features/accounts/AccountSwitcherScreen.test.tsx @@ -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(, { preloadedState }) + + expect(tree).toMatchSnapshot() + cleanup() + }) +}) diff --git a/apps/extension/src/app/features/accounts/AccountSwitcherScreen.tsx b/apps/extension/src/app/features/accounts/AccountSwitcherScreen.tsx new file mode 100644 index 00000000..c2022aad --- /dev/null +++ b/apps/extension/src/app/features/accounts/AccountSwitcherScreen.tsx @@ -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() + + const sortedMnemonicAccounts = useSelector(selectSortedSignerMnemonicAccounts) + + const { canClaimUnitag } = useCanActiveAddressClaimUnitag(activeAddress) + + useEffect(() => { + const createOnboardingAccountAfterTransitionAnimation = async (): Promise => { + // TODO: EXT-1164 - Move Keyring methods to workers to not block main thread during onboarding + // Delays computation heavy function invocation to avoid disrupting transition animation + await sleep(400) + setPendingWallet(await createOnboardingAccount(sortedMnemonicAccounts)) + } + createOnboardingAccountAfterTransitionAnimation().catch((e) => { + logger.error(e, { + tags: { file: 'AccountSwitcherScreen', function: 'createOnboardingAccount' }, + }) + }) + }, [sortedMnemonicAccounts]) + + const onNavigateToRemoveWallet = (): void => { + setShowRemoveWalletModal(false) + setShowImportWalletModal(false) + navigate(`/${AppRoutes.Settings}/${SettingsRoutes.RemoveRecoveryPhrase}/${RemoveRecoveryPhraseRoutes.Wallets}`) + } + + const onCancelCreateWallet = (): void => { + setShowCreateWalletModal(false) + } + + const onConfirmCreateWallet = useCallback( + async (walletLabel: string): Promise => { + setShowCreateWalletModal(false) + if (!pendingWallet) { + return + } + + if (walletLabel) { + pendingWallet.name = walletLabel + } + + dispatch( + createAccountsActions.trigger({ + accounts: [pendingWallet], + }), + ) + + sendAnalyticsEvent(WalletEventName.WalletAdded, { + wallet_type: ImportType.CreateAdditional, + accounts_imported_count: 1, + wallets_imported: [pendingWallet.address], + cloud_backup_used: 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 => { + 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 ( + + setShowEditLabelModal(false)} + /> + } + 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} + /> + + + + + + + + } + /> + + + + + + + + {isWrappedBannerEnabled && ( + + + + )} + + + {activeAccountHasUnitag ? ( + + ) : ( + !activeAccountHasENS && ( + + + + ) + )} + + + + + {sortedAddressesByBalance.map(({ address, balance }) => { + return ( + => { + dispatch(setAccountAsActive(address)) + await updateDappConnectedAddressFromExtension(address) + if (connectedAccounts.length > 0 && !isConnectedAccount(connectedAccounts, address)) { + dispatch(openPopup(PopupName.Connect)) + } + navigate(-1) + }} + /> + ) + })} + + + + + + + {t('account.wallet.button.add')} + + + + + + + + + + + ) +} + +const UnitagActionButton = (): JSX.Element => { + const { t } = useTranslation() + const address = useActiveAccountAddressWithThrow() + + const onPressEditProfile = useCallback(async () => { + await focusOrCreateUnitagTab(address, UnitagClaimRoutes.EditProfile) + }, [address]) + + return ( + + + + ) +} diff --git a/apps/extension/src/app/features/accounts/CreateWalletModal.tsx b/apps/extension/src/app/features/accounts/CreateWalletModal.tsx new file mode 100644 index 00000000..22282103 --- /dev/null +++ b/apps/extension/src/app/features/accounts/CreateWalletModal.tsx @@ -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('') + + 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 ( + + + + {onboardingAccountAddress && } + + + + {onboardingAccountAddress && ( + + {shortenAddress({ address: onboardingAccountAddress })} + + )} + + + + + + + + + ) +} diff --git a/apps/extension/src/app/features/accounts/EditLabelModal.tsx b/apps/extension/src/app/features/accounts/EditLabelModal.tsx new file mode 100644 index 00000000..d5ce5ed9 --- /dev/null +++ b/apps/extension/src/app/features/accounts/EditLabelModal.tsx @@ -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(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 = ( + + ) + + return ( + + + + + + setIsFocused(false)} + onChangeText={setInputText} + onFocus={() => setIsFocused(true)} + /> + + + {shortenAddress({ address })} + + + + + + + + + ) +} diff --git a/apps/extension/src/app/features/accounts/__snapshots__/AccountSwitcherScreen.test.tsx.snap b/apps/extension/src/app/features/accounts/__snapshots__/AccountSwitcherScreen.test.tsx.snap new file mode 100644 index 00000000..032019f4 --- /dev/null +++ b/apps/extension/src/app/features/accounts/__snapshots__/AccountSwitcherScreen.test.tsx.snap @@ -0,0 +1,734 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`AccountSwitcherScreen renders correctly 1`] = ` +{ + "asFragment": [Function], + "baseElement": +
+ + +

+ + +

+ + +

+ +
+
+
+ + + +
+
+ + + +
+
+ +
+
+
+
+
+
+
+
+
+ + + + + + +
+
+
+
+
+
+ +
+
+
+
+
+ + 0x​9EB67f...D9A2Ca + +
+
+
+ + + +
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+ +
+
+
+ +
+
+ +
+
+ +
+ + + + + + + + + + + + + + + + , + "container":
+ + +

+ + +

+ + +

+ +
+
+
+ + + +
+
+ + + +
+
+ +
+
+
+
+
+
+
+
+
+ + + + + + +
+
+
+
+
+
+ +
+
+
+
+
+ + 0x​9EB67f...D9A2Ca + +
+
+
+ + + +
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+ +
+
+
+ +
+
+ +
+
+ +
, + "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], +} +`; diff --git a/apps/extension/src/app/features/accounts/useSortedAccountList.test.ts b/apps/extension/src/app/features/accounts/useSortedAccountList.test.ts new file mode 100644 index 00000000..87e03646 --- /dev/null +++ b/apps/extension/src/app/features/accounts/useSortedAccountList.test.ts @@ -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 + +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[] | 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(), + }) +} diff --git a/apps/extension/src/app/features/accounts/useSortedAccountList.ts b/apps/extension/src/app/features/accounts/useSortedAccountList.ts new file mode 100644 index 00000000..db605147 --- /dev/null +++ b/apps/extension/src/app/features/accounts/useSortedAccountList.ts @@ -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 = useMemo(() => { + const data = accountBalanceData || previousAccountBalanceData + if (!data?.portfolios) { + return {} + } + return Object.fromEntries( + data.portfolios + .filter((portfolio): portfolio is NonNullable => 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]) +} diff --git a/apps/extension/src/app/features/appRating/AppRatingModal.tsx b/apps/extension/src/app/features/appRating/AppRatingModal.tsx new file mode 100644 index 00000000..aeb53d9d --- /dev/null +++ b/apps/extension/src/app/features/appRating/AppRatingModal.tsx @@ -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 ( + + + + + + + + + + + {title} + + + {description} + + + + + + + + + ) +} diff --git a/apps/extension/src/app/features/biometricUnlock/BiometricUnlockStorage.ts b/apps/extension/src/app/features/biometricUnlock/BiometricUnlockStorage.ts new file mode 100644 index 00000000..599a36b1 --- /dev/null +++ b/apps/extension/src/app/features/biometricUnlock/BiometricUnlockStorage.ts @@ -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 & { ciphertext: string } +} + +const BIOMETRIC_UNLOCK_STORAGE_KEY = 'biometricUnlock' + +const storage = new PersistedStorage('local') + +interface IBiometricUnlockStorage { + set(data: BiometricUnlockStorageData): Promise + get(): Promise + remove(): Promise +} + +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 + } + }, +} diff --git a/apps/extension/src/app/features/biometricUnlock/biometricAuthUtils.ts b/apps/extension/src/app/features/biometricUnlock/biometricAuthUtils.ts new file mode 100644 index 00000000..21aa3d19 --- /dev/null +++ b/apps/extension/src/app/features/biometricUnlock/biometricAuthUtils.ts @@ -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 { + 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 { + // 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 { + // 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 +} diff --git a/apps/extension/src/app/features/biometricUnlock/biometricUnlockCredentialQuery.ts b/apps/extension/src/app/features/biometricUnlock/biometricUnlockCredentialQuery.ts new file mode 100644 index 00000000..48ff52be --- /dev/null +++ b/apps/extension/src/app/features/biometricUnlock/biometricUnlockCredentialQuery.ts @@ -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, + Error, + Awaited, + [ReactQueryCacheKey.ExtensionBiometricUnlockCredential] +> + +export function biometricUnlockCredentialQuery(): BiometricUnlockCredentialQueryOptions { + return queryWithoutCache({ + queryKey: [ReactQueryCacheKey.ExtensionBiometricUnlockCredential], + queryFn: async () => await BiometricUnlockStorage.get(), + }) +} diff --git a/apps/extension/src/app/features/biometricUnlock/useBiometricUnlockDisableMutation.ts b/apps/extension/src/app/features/biometricUnlock/useBiometricUnlockDisableMutation.ts new file mode 100644 index 00000000..dc233bfc --- /dev/null +++ b/apps/extension/src/app/features/biometricUnlock/useBiometricUnlockDisableMutation.ts @@ -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 { + 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', + }, + }) + }, + }) +} diff --git a/apps/extension/src/app/features/biometricUnlock/useBiometricUnlockSetupMutation.test.ts b/apps/extension/src/app/features/biometricUnlock/useBiometricUnlockSetupMutation.test.ts new file mode 100644 index 00000000..1eb0c37c --- /dev/null +++ b/apps/extension/src/app/features/biometricUnlock/useBiometricUnlockSetupMutation.test.ts @@ -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 +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) + }) + }) +}) diff --git a/apps/extension/src/app/features/biometricUnlock/useBiometricUnlockSetupMutation.ts b/apps/extension/src/app/features/biometricUnlock/useBiometricUnlockSetupMutation.ts new file mode 100644 index 00000000..14f0c89e --- /dev/null +++ b/apps/extension/src/app/features/biometricUnlock/useBiometricUnlockSetupMutation.ts @@ -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 { + 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 { + // 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 { + 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 } +} diff --git a/apps/extension/src/app/features/biometricUnlock/useChangePasswordWithBiometricMutation.test.ts b/apps/extension/src/app/features/biometricUnlock/useChangePasswordWithBiometricMutation.test.ts new file mode 100644 index 00000000..b97ee646 --- /dev/null +++ b/apps/extension/src/app/features/biometricUnlock/useChangePasswordWithBiometricMutation.test.ts @@ -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 +const mockKeyring = Keyring as jest.Mocked +const mockLogger = logger as jest.Mocked + +// 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) + }) + }) +}) diff --git a/apps/extension/src/app/features/biometricUnlock/useChangePasswordWithBiometricMutation.ts b/apps/extension/src/app/features/biometricUnlock/useChangePasswordWithBiometricMutation.ts new file mode 100644 index 00000000..c454f813 --- /dev/null +++ b/apps/extension/src/app/features/biometricUnlock/useChangePasswordWithBiometricMutation.ts @@ -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 { + const changePasswordWithBiometric = useEvent(async (newPassword: string): Promise => { + 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) + }, + }) +} diff --git a/apps/extension/src/app/features/biometricUnlock/useNavigatorCredentialAbortSignal.ts b/apps/extension/src/app/features/biometricUnlock/useNavigatorCredentialAbortSignal.ts new file mode 100644 index 00000000..3c86d3f5 --- /dev/null +++ b/apps/extension/src/app/features/biometricUnlock/useNavigatorCredentialAbortSignal.ts @@ -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 } +} diff --git a/apps/extension/src/app/features/biometricUnlock/useShouldShowBiometricUnlock.ts b/apps/extension/src/app/features/biometricUnlock/useShouldShowBiometricUnlock.ts new file mode 100644 index 00000000..54582813 --- /dev/null +++ b/apps/extension/src/app/features/biometricUnlock/useShouldShowBiometricUnlock.ts @@ -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 +} diff --git a/apps/extension/src/app/features/biometricUnlock/useShouldShowBiometricUnlockEnrollment.ts b/apps/extension/src/app/features/biometricUnlock/useShouldShowBiometricUnlockEnrollment.ts new file mode 100644 index 00000000..f642af2d --- /dev/null +++ b/apps/extension/src/app/features/biometricUnlock/useShouldShowBiometricUnlockEnrollment.ts @@ -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 +} diff --git a/apps/extension/src/app/features/biometricUnlock/useUnlockWithBiometricCredentialMutation.test.ts b/apps/extension/src/app/features/biometricUnlock/useUnlockWithBiometricCredentialMutation.test.ts new file mode 100644 index 00000000..2f170223 --- /dev/null +++ b/apps/extension/src/app/features/biometricUnlock/useUnlockWithBiometricCredentialMutation.test.ts @@ -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 + +// 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) + }) + }) +}) diff --git a/apps/extension/src/app/features/biometricUnlock/useUnlockWithBiometricCredentialMutation.ts b/apps/extension/src/app/features/biometricUnlock/useUnlockWithBiometricCredentialMutation.ts new file mode 100644 index 00000000..159c0ba0 --- /dev/null +++ b/apps/extension/src/app/features/biometricUnlock/useUnlockWithBiometricCredentialMutation.ts @@ -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 { + const unlockWithPassword = useUnlockWithPassword() + + const unlockWithBiometric = useEvent(async (): Promise => { + 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 { + 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 +} diff --git a/apps/extension/src/app/features/biometricUnlock/utils/assertAuthenticatorAssertionResponse.ts b/apps/extension/src/app/features/biometricUnlock/utils/assertAuthenticatorAssertionResponse.ts new file mode 100644 index 00000000..835934db --- /dev/null +++ b/apps/extension/src/app/features/biometricUnlock/utils/assertAuthenticatorAssertionResponse.ts @@ -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') + } +} diff --git a/apps/extension/src/app/features/biometricUnlock/utils/assertPublicKeyCredential.ts b/apps/extension/src/app/features/biometricUnlock/utils/assertPublicKeyCredential.ts new file mode 100644 index 00000000..2df298f7 --- /dev/null +++ b/apps/extension/src/app/features/biometricUnlock/utils/assertPublicKeyCredential.ts @@ -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 +} diff --git a/apps/extension/src/app/features/dapp/DappContext.tsx b/apps/extension/src/app/features/dapp/DappContext.tsx new file mode 100644 index 00000000..af21556e --- /dev/null +++ b/apps/extension/src/app/features/dapp/DappContext.tsx @@ -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(undefined) + +export function DappContextProvider({ children }: { children: ReactNode }): JSX.Element { + const [dappUrl, setDappUrl] = useState('') + const [dappIconUrl, setDappIconUrl] = useState(undefined) + + const activeAddress = useActiveAccountAddress() + const connectedAccounts = useDappConnectedAccounts(dappUrl) + const lastChainId = useDappLastChainId(dappUrl) + const dispatch = useDispatch() + + const isConnected = !!activeAddress && isConnectedAccount(connectedAccounts, activeAddress) + + useEffect(() => { + const updateDappInfo = async (): Promise => { + 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 {children} +} + +export function useDappContext(): DappContextState { + const context = useContext(DappContext) + + if (context === undefined) { + throw new Error('useDappContext must be used within a DappContextProvider') + } + + return context +} diff --git a/apps/extension/src/app/features/dapp/actions.ts b/apps/extension/src/app/features/dapp/actions.ts new file mode 100644 index 00000000..9ff00f30 --- /dev/null +++ b/apps/extension/src/app/features/dapp/actions.ts @@ -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 { + 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 { + const displayName = await getCapitalizedDisplayNameFromTab(dappUrl) + + const initialProperties: Partial = {} + + 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 { + dappStore.removeDappConnection(dappUrl, account) + await updateConnectionFromExtension(dappUrl) +} + +async function updateConnectionFromExtension(dappUrl: string): Promise { + const connectedWallets = dappStore.getDappOrderedConnectedAddresses(dappUrl) ?? [] + const response: UpdateConnectionRequest = { + type: ExtensionToDappRequestType.UpdateConnections, + addresses: connectedWallets, + } + + await externalDappMessageChannel.sendMessageToTabUrl(dappUrl, response) +} + +/** + * Set the display name of a dapp from the tab title + * @param dappUrl - extracted url for dapp + */ +export async function updateDisplayNameFromTab(dappUrl: string): Promise { + // 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 { + dappStore.updateDappConnectedAddress(address) + const connectedDapps = dappStore.getConnectedDapps(address) + for (const dappUrl of connectedDapps) { + await updateConnectionFromExtension(dappUrl) + } +} + +export async function removeAllDappConnectionsForAccount(account: Account): Promise { + const connectedDapps = dappStore.getConnectedDapps(account.address) + dappStore.removeAccountDappConnections(account) + for (const dappUrl of connectedDapps) { + await updateConnectionFromExtension(dappUrl) + } +} + +export async function removeAllDappConnectionsFromExtension(): Promise { + const dappUrls = dappStore.getDappUrls() + for (const dappUrl of dappUrls) { + const response: UpdateConnectionRequest = { + type: ExtensionToDappRequestType.UpdateConnections, + addresses: [], + } + await externalDappMessageChannel.sendMessageToTabUrl(dappUrl, response) + } + dappStore.removeAllDappConnections() +} diff --git a/apps/extension/src/app/features/dapp/changeChain.test.ts b/apps/extension/src/app/features/dapp/changeChain.test.ts new file mode 100644 index 00000000..b6c8ca18 --- /dev/null +++ b/apps/extension/src/app/features/dapp/changeChain.test.ts @@ -0,0 +1,115 @@ +import { JsonRpcProvider } from '@ethersproject/providers' +import { providerErrors, serializeError } from '@metamask/rpc-errors' +import { changeChain } from 'src/app/features/dapp/changeChain' +import { dappStore } from 'src/app/features/dapp/store' +import { 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, + }) + }) +}) diff --git a/apps/extension/src/app/features/dapp/changeChain.ts b/apps/extension/src/app/features/dapp/changeChain.ts new file mode 100644 index 00000000..dbe69fe0 --- /dev/null +++ b/apps/extension/src/app/features/dapp/changeChain.ts @@ -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, + } +} diff --git a/apps/extension/src/app/features/dapp/hooks.test.ts b/apps/extension/src/app/features/dapp/hooks.test.ts new file mode 100644 index 00000000..579db375 --- /dev/null +++ b/apps/extension/src/app/features/dapp/hooks.test.ts @@ -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([])) + }) + }) +}) diff --git a/apps/extension/src/app/features/dapp/hooks.ts b/apps/extension/src/app/features/dapp/hooks.ts new file mode 100644 index 00000000..54a3b358 --- /dev/null +++ b/apps/extension/src/app/features/dapp/hooks.ts @@ -0,0 +1,57 @@ +import { useEffect, useReducer, useState } from 'react' +import { DappInfo, DappStoreEvent, dappStore } from 'src/app/features/dapp/store' +import { UniverseChainId } from 'uniswap/src/features/chains/types' +import { Account } from 'wallet/src/features/wallet/accounts/types' +import { useActiveAccountAddress } from 'wallet/src/features/wallet/hooks' + +// exported to be used in tests +export function useDappStateUpdated(): boolean { + const [state, dispatch] = useReducer((v) => !v, false) + useEffect(() => { + const onUpdate = (): void => dispatch() + dappStore.addListener(DappStoreEvent.DappStateUpdated, onUpdate) + return () => { + dappStore.removeListener(DappStoreEvent.DappStateUpdated, onUpdate) + } + }, []) + return state +} + +export function useDappInfo(dappUrl: string | undefined): DappInfo | undefined { + const [info, setInfo] = useState() + const dappStateUpdated = useDappStateUpdated() + // oxlint-disable-next-line react/exhaustive-deps -- dappStateUpdated is used to trigger re-render when dapp store changes + useEffect(() => { + setInfo(dappStore.getDappInfo(dappUrl)) + }, [dappUrl, dappStateUpdated]) + return info +} + +export function useDappLastChainId(dappUrl: string | undefined): UniverseChainId | undefined { + return useDappInfo(dappUrl)?.lastChainId +} + +export function useDappConnectedAccounts(dappUrl: string | undefined): Account[] { + return useDappInfo(dappUrl)?.connectedAccounts || [] +} + +/** + * Hook to retrieve all dapp connection URLs for a specific account. + * + * @param address - Optional account address. If not provided, uses the active account. + * @returns all dapp connection URLs (ie state keys) for the specified account + */ +export function useAllDappConnectionsForAccount(address?: Address): string[] { + const [dappUrls, setDappUrls] = useState([]) + const dappStateUpdated = useDappStateUpdated() + const activeAccount = useActiveAccountAddress() + + const accountAddress = address ?? activeAccount + + // oxlint-disable-next-line react/exhaustive-deps -- dappStateUpdated is used to trigger re-render when dapp store changes + useEffect(() => { + setDappUrls(accountAddress ? dappStore.getConnectedDapps(accountAddress) : []) + }, [accountAddress, dappStateUpdated]) + + return dappUrls +} diff --git a/apps/extension/src/app/features/dapp/saga.ts b/apps/extension/src/app/features/dapp/saga.ts new file mode 100644 index 00000000..b2494f85 --- /dev/null +++ b/apps/extension/src/app/features/dapp/saga.ts @@ -0,0 +1,9 @@ +import { dappStore } from 'src/app/features/dapp/store' +import { call } from 'typed-redux-saga' +import { logger } from 'utilities/src/logger/logger' + +// Initialize Dapp Store +export function* initDappStore() { + logger.debug('dappStoreSaga', 'initDappStore', 'Initializing Dapp Store') + yield* call(dappStore.init) +} diff --git a/apps/extension/src/app/features/dapp/store.ts b/apps/extension/src/app/features/dapp/store.ts new file mode 100644 index 00000000..ea857feb --- /dev/null +++ b/apps/extension/src/app/features/dapp/store.ts @@ -0,0 +1,299 @@ +import { cloneDeep } from '@apollo/client/utilities' +import EventEmitter from 'eventemitter3' +import { getOrderedConnectedAddresses, isConnectedAccount } from 'src/app/features/dapp/utils' +import { UniverseChainId } from 'uniswap/src/features/chains/types' +import { Account } from 'wallet/src/features/wallet/accounts/types' + +const STATE_STORAGE_KEY = 'dappState' + +export interface DappInfo { + lastChainId: UniverseChainId + connectedAccounts: Account[] + activeConnectedAddress: Address + iconUrl?: string + displayName?: string +} + +export interface DappState { + [dappUrl: string]: DappInfo +} + +const initialDappState: DappState = {} +let state: DappState + +// Event Emitter +export enum DappStoreEvent { + DappStateUpdated = 'DappStateUpdated', +} + +class DappStoreEventEmitter extends EventEmitter {} +const dappStoreEventEmitter = new DappStoreEventEmitter() + +// Init +let initPromise: Promise | undefined + +async function init(): Promise { + if (!initPromise) { + initPromise = initInternal() + } + + return initPromise +} + +async function initInternal(): Promise { + // oxlint-disable-next-line typescript/no-unnecessary-condition + state = (await chrome.storage.local.get([STATE_STORAGE_KEY]))?.[STATE_STORAGE_KEY] || initialDappState + + chrome.storage.local.onChanged.addListener((changes) => { + if (changes['dappState']) { + state = changes['dappState'].newValue + dappStoreEventEmitter.emit(DappStoreEvent.DappStateUpdated, state) + } + }) +} + +// Sequential syncing of state to local storage +let dappStateSyncPromise = Promise.resolve() +let dappStateChangesNeedSync = false +function queueDappStateSync(): void { + if (!dappStateChangesNeedSync) { + dappStateChangesNeedSync = true + dappStateSyncPromise = dappStateSyncPromise.then((): Promise => { + dappStateChangesNeedSync = false + return chrome.storage.local.set({ [STATE_STORAGE_KEY]: state }) + }) + } +} + +/** Returns all dapp URLs that are connected to a particular address. */ +function getConnectedDapps(address: Address): string[] { + return Object.entries(state) + .filter(([_, dappInfo]) => isConnectedAccount(dappInfo.connectedAccounts, address)) + .map(([dappUrl]) => dappUrl) +} + +// TODO(WALL-4643): explore usage of immer here +/** Returns connected addresses with the currently connected address listed first. */ +function getDappOrderedConnectedAddresses(dappUrl: string): string[] | undefined { + const dappInfo = state[dappUrl] + if (!dappInfo) { + return undefined + } + const { connectedAccounts, activeConnectedAddress } = dappInfo + return getOrderedConnectedAddresses(connectedAccounts, activeConnectedAddress) +} + +function getDappInfo(dappUrl: string | undefined): DappInfo | undefined { + return dappUrl ? state[dappUrl] : undefined +} + +function getDappInfoIfConnected(dappUrl: string | undefined): DappInfo | undefined { + const dappInfo = getDappInfo(dappUrl) + return dappInfo && dappInfo.connectedAccounts.length > 0 ? dappInfo : undefined +} + +function getDappUrls(): string[] { + return Object.keys(state) +} + +// Update the connected address for all dapps +function updateDappConnectedAddress(address: Address): void { + // Never directly mutate state, as some of its fields could have `writable: false` + state = Object.fromEntries( + Object.entries(state).map(([key, dappUrlState]) => { + if (isConnectedAccount(dappUrlState.connectedAccounts, address)) { + return [key, { ...dappUrlState, activeConnectedAddress: address }] + } + return [key, dappUrlState] + }), + ) + queueDappStateSync() +} + +/** + * Helper function to update a specific property of a dapp in the state + * @param dappUrl - extracted url for dapp + * @param property - key of dapp property to update + * @param value - new value for the property + */ +function updateDappProperty({ + dappUrl, + property, + value, +}: { + dappUrl: string + property: T + value?: DappInfo[T] +}): void { + const info = state[dappUrl] + + if (!info) { + return + } + + state = { + ...state, + [dappUrl]: { + ...info, + [property]: value, + }, + } + + queueDappStateSync() +} + +/** + * Update the display name for a dapp; the dapp must be in state already for this to work + * (ie can't run immediately after a dapp is connected) + * @param dappUrl - extracted url for dapp + * @param newDisplayName - new display name for dapp + */ +function updateDappDisplayName(dappUrl: string, newDisplayName?: string): void { + updateDappProperty({ dappUrl, property: 'displayName', value: newDisplayName }) +} + +/** + * Update the icon URL for a dapp + * @param dappUrl - extracted url for dapp + * @param newIconUrl - new icon URL for dapp + */ +function updateDappIconUrl(dappUrl: string, newIconUrl?: string): void { + updateDappProperty({ dappUrl, property: 'iconUrl', value: newIconUrl }) +} + +// TODO(WALL-4643): if we migrate to immer, let's avoid iterating over the the object here +function updateDappLatestChainId(dappUrl: string, chainId: UniverseChainId): void { + // Never directly mutate state, as some of its fields could have `writable: false` + state = Object.fromEntries( + Object.entries(state).map(([key, dappUrlState]) => { + if (key === dappUrl) { + return [key, { ...dappUrlState, lastChainId: chainId }] + } + return [key, dappUrlState] + }), + ) + queueDappStateSync() +} + +function saveDappActiveAccount({ + dappUrl, + account, + initialProperties, +}: { + dappUrl: string + account: Account + initialProperties?: Partial +}): void { + // Never directly mutate state, as some of its fields could have `writable: false` + state = { + ...state, + [dappUrl]: { + ...state[dappUrl], + // TODO: WALL-4919: Remove hardcoded Mainnet + lastChainId: state[dappUrl]?.lastChainId ?? UniverseChainId.Mainnet, + activeConnectedAddress: account.address, + connectedAccounts: ((): Account[] => { + const currConnectedAccounts = state[dappUrl]?.connectedAccounts || [] + const isConnectionNew = !isConnectedAccount(currConnectedAccounts, account.address) + + if (isConnectionNew) { + return [...currConnectedAccounts, account] + } + return currConnectedAccounts + })(), + ...initialProperties, + }, + } + queueDappStateSync() +} + +/** + * Remove a dapp connection + * @param dappUrl extracted url for dapp + * @param account target account to remove connection. If undefined, will remove all accounts + * @returns + */ +function removeDappConnection(dappUrl: string, account?: Account): void { + // Never directly mutate state, as some of its fields could have `writable: false` + state = removeDappConnectionHelper({ initialState: state, dappUrl, account }) + queueDappStateSync() +} + +/** + * Remove all dapp connections for a specific account + * @param account - the account to remove all connections for + */ +function removeAccountDappConnections(account: Account): void { + let updatedState = { ...state } + + for (const dappUrl of getDappUrls()) { + updatedState = removeDappConnectionHelper({ initialState: updatedState, dappUrl, account }) + } + + state = updatedState + queueDappStateSync() +} + +/** + * Helper function to remove a dapp connection + * @param initialState - the initial mapping of dapp URLs to DappInfo + * @param dappUrl - the URL of the dapp (key) to target + * @param account - the account to remove from the target dapp; if undefined, all accounts will be removed + * @returns the updated state + */ +function removeDappConnectionHelper({ + initialState, + dappUrl, + account, +}: { + initialState: DappState + dappUrl: string + account?: Account +}): DappState { + const newState = cloneDeep(initialState) + const dappInfo = newState[dappUrl] + + if (!dappInfo) { + return initialState + } + + dappInfo.connectedAccounts = dappInfo.connectedAccounts.filter( + (existingAccount) => existingAccount.address !== account?.address, + ) + + const nextConnectedAccount = dappInfo.connectedAccounts[0] + + if (!nextConnectedAccount || !account) { + delete newState[dappUrl] + return newState + } + + if (dappInfo.activeConnectedAddress === account.address) { + dappInfo.activeConnectedAddress = nextConnectedAccount.address + } + return newState +} + +function removeAllDappConnections(): void { + state = {} + queueDappStateSync() +} + +export const dappStore = { + getConnectedDapps, + getDappInfo, + getDappInfoIfConnected, + getDappOrderedConnectedAddresses, + getDappUrls, + init, + removeAllDappConnections, + removeAccountDappConnections, + removeDappConnection, + saveDappActiveAccount, + addListener: dappStoreEventEmitter.addListener.bind(dappStoreEventEmitter), + removeListener: dappStoreEventEmitter.removeListener.bind(dappStoreEventEmitter), + updateDappConnectedAddress, + updateDappLatestChainId, + updateDappIconUrl, + updateDappDisplayName, +} diff --git a/apps/extension/src/app/features/dapp/utils.test.ts b/apps/extension/src/app/features/dapp/utils.test.ts new file mode 100644 index 00000000..4cb4bc5a --- /dev/null +++ b/apps/extension/src/app/features/dapp/utils.test.ts @@ -0,0 +1,150 @@ +import { + getActiveSignerConnectedAccount, + getCapitalizedDisplayNameFromTab, + getOrderedConnectedAddresses, + isConnectedAccount, +} from 'src/app/features/dapp/utils' +import { SAMPLE_SEED_ADDRESS_1, SAMPLE_SEED_ADDRESS_2, SAMPLE_SEED_ADDRESS_3 } from 'uniswap/src/test/fixtures' +import { extractNameFromUrl } from 'utilities/src/format/extractNameFromUrl' +import { promiseTimeout } from 'utilities/src/time/timing' +import { Account } from 'wallet/src/features/wallet/accounts/types' +import { ACCOUNT, ACCOUNT2, ACCOUNT3, readOnlyAccount } from 'wallet/src/test/fixtures' + +jest.mock('utilities/src/format/extractNameFromUrl', () => ({ + extractNameFromUrl: jest.fn(), +})) + +jest.mock('utilities/src/time/timing', () => ({ + promiseTimeout: jest.fn(), +})) + +const mockChromeTabsQuery = jest.fn() + +global.chrome = { + tabs: { + ...global.chrome.tabs, + query: mockChromeTabsQuery, + }, +} as unknown as typeof global.chrome + +const mockFunctions = { + extractNameFromUrl: extractNameFromUrl as jest.Mock, + promiseTimeout: promiseTimeout as jest.Mock, +} + +describe('isConnectedAccount', () => { + it('returns true if the account is connected', () => { + const accounts: Account[] = [ACCOUNT, ACCOUNT2] + expect(isConnectedAccount(accounts, SAMPLE_SEED_ADDRESS_1)).toBe(true) + }) + + it('returns false if the account is not connected', () => { + const accounts: Account[] = [ACCOUNT] + expect(isConnectedAccount(accounts, SAMPLE_SEED_ADDRESS_2)).toBe(false) + }) +}) + +describe('getActiveConnectedAccount', () => { + const accounts: Account[] = [ACCOUNT, ACCOUNT2] + + it('returns the account for the given address', () => { + const result = getActiveSignerConnectedAccount(accounts, SAMPLE_SEED_ADDRESS_2) + expect(result).toEqual(ACCOUNT2) + }) + + it('throws an error if the address is not in the list', () => { + expect(() => { + getActiveSignerConnectedAccount(accounts, SAMPLE_SEED_ADDRESS_3) + }).toThrow('The active connected address must be in the list of connected accounts.') + }) + + it('throws an error if the account is not a signer mnemonic account', () => { + const readOnlyAccount1 = readOnlyAccount() + const accounts: Account[] = [ACCOUNT, ACCOUNT2, readOnlyAccount1] + expect(() => { + getActiveSignerConnectedAccount(accounts, readOnlyAccount1.address!) + }).toThrow('The active connected address must be a signer mnemonic account.') + }) +}) + +describe('getOrderedConnectedAddresses', () => { + const accounts: Account[] = [ACCOUNT, ACCOUNT2, ACCOUNT3] + + it('places the active address first', () => { + const activeAddress = SAMPLE_SEED_ADDRESS_2 + const expectedOrder = [SAMPLE_SEED_ADDRESS_2, SAMPLE_SEED_ADDRESS_1, SAMPLE_SEED_ADDRESS_3] + const result = getOrderedConnectedAddresses(accounts, activeAddress) + expect(result).toEqual(expectedOrder) + }) + + it('returns the same order if the active address is already first', () => { + const activeAddress = SAMPLE_SEED_ADDRESS_1 + const expectedOrder = [SAMPLE_SEED_ADDRESS_1, SAMPLE_SEED_ADDRESS_2, SAMPLE_SEED_ADDRESS_3] + const result = getOrderedConnectedAddresses(accounts, activeAddress) + expect(result).toEqual(expectedOrder) + }) + + it('handles cases where the active address is not in the list', () => { + const activeAddress = '0xabc' // Not in the accounts list + const expectedOrder = [SAMPLE_SEED_ADDRESS_1, SAMPLE_SEED_ADDRESS_2, SAMPLE_SEED_ADDRESS_3] // Original order since active address is not found + const result = getOrderedConnectedAddresses(accounts, activeAddress) + expect(result).toEqual(expectedOrder) + }) +}) + +describe('getCapitalizedDisplayNameFromTab', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('should return the capitalized display name when the title contains the dapp name', async () => { + const dappUrl = 'https://example.com' + const dappName = 'example' + const tabTitle = 'Example - Dapp' + + mockFunctions.extractNameFromUrl.mockReturnValue(dappName) + mockChromeTabsQuery.mockResolvedValue([{ title: tabTitle }]) + mockFunctions.promiseTimeout.mockResolvedValue([{ title: tabTitle }]) + + const result = await getCapitalizedDisplayNameFromTab(dappUrl) + + expect(result).toBe('Example') + }) + + it('should return undefined when the title does not contain the dapp name', async () => { + const dappUrl = 'https://example.com' + const dappName = 'example' + const tabTitle = 'Another Dapp' + + mockFunctions.extractNameFromUrl.mockReturnValue(dappName) + mockChromeTabsQuery.mockResolvedValue([{ title: tabTitle }]) + mockFunctions.promiseTimeout.mockResolvedValue([{ title: tabTitle }]) + + const result = await getCapitalizedDisplayNameFromTab(dappUrl) + + expect(result).toBeUndefined() + }) + + it('should return undefined when there is no active tab', async () => { + const dappUrl = 'https://example.com' + + mockFunctions.extractNameFromUrl.mockReturnValue('example') + mockChromeTabsQuery.mockResolvedValue([]) + mockFunctions.promiseTimeout.mockResolvedValue([]) + + const result = await getCapitalizedDisplayNameFromTab(dappUrl) + + expect(result).toBeUndefined() + }) + + it('should return undefined when promiseTimeout times out', async () => { + const dappUrl = 'https://example.com' + + mockFunctions.extractNameFromUrl.mockReturnValue('example') + mockFunctions.promiseTimeout.mockResolvedValue(undefined) + + const result = await getCapitalizedDisplayNameFromTab(dappUrl) + + expect(result).toBeUndefined() + }) +}) diff --git a/apps/extension/src/app/features/dapp/utils.ts b/apps/extension/src/app/features/dapp/utils.ts new file mode 100644 index 00000000..a98e4272 --- /dev/null +++ b/apps/extension/src/app/features/dapp/utils.ts @@ -0,0 +1,63 @@ +import { AccountType } from 'uniswap/src/features/accounts/types' +import { extractNameFromUrl } from 'utilities/src/format/extractNameFromUrl' +import { bubbleToTop } from 'utilities/src/primitives/array' +import { ONE_SECOND_MS } from 'utilities/src/time/time' +import { promiseTimeout } from 'utilities/src/time/timing' +import { Account, SignerMnemonicAccount } from 'wallet/src/features/wallet/accounts/types' + +const MAX_TAB_QUERY_TIME = ONE_SECOND_MS + +export function isConnectedAccount(connectedAccounts: Account[], address: Address): boolean { + return connectedAccounts.some((account) => account.address === address) +} + +/** Gets the Account for a specific address. The address param must be in the list of connectedAccounts. */ +function getActiveConnectedAccount(connectedAccounts: Account[], activeConnectedAddress: Address): Account { + const activeConnectedAccount = connectedAccounts.find((account) => account.address === activeConnectedAddress) + if (!activeConnectedAccount) { + throw new Error('The active connected address must be in the list of connected accounts.') + } + return activeConnectedAccount +} + +/** Gets the SignerMnemonicAccount for a specific address. The address param must be in the list of connectedAccounts. */ +export function getActiveSignerConnectedAccount( + connectedAccounts: Account[], + activeConnectedAddress: Address, +): SignerMnemonicAccount { + const activeConnectedAccount = getActiveConnectedAccount(connectedAccounts, activeConnectedAddress) + if (activeConnectedAccount.type !== AccountType.SignerMnemonic) { + throw new Error('The active connected address must be a signer mnemonic account.') + } + return activeConnectedAccount +} + +/** Returns all connected addresses with the currently connected address listed first. */ +export function getOrderedConnectedAddresses(connectedAccounts: Account[], activeConnectedAddress: Address): Address[] { + const connectedAddresses = connectedAccounts.map((account) => account.address) + return bubbleToTop(connectedAddresses, (address) => address === activeConnectedAddress) +} + +/** + * Get the capitalized display name of a dapp from the tab title; the uncapitalized name is extracted from the dapp URL + * @param dappUrl - extracted url for dapp + * @returns a promise that resolves to the display name of the dapp, or an empty string if not found + */ +export async function getCapitalizedDisplayNameFromTab(dappUrl: string): Promise { + const getActiveTab = chrome.tabs.query({ active: true, currentWindow: true }) + const [activeTab] = (await promiseTimeout(getActiveTab, MAX_TAB_QUERY_TIME)) || [] + + if (!activeTab?.title) { + return undefined + } + + const dappNameFromUrl = extractNameFromUrl(dappUrl) + const nameIndex = activeTab.title.toLowerCase().indexOf(dappNameFromUrl) + + if (nameIndex === -1) { + return undefined + } + + const capitalizedDisplayName = activeTab.title.substring(nameIndex, nameIndex + dappNameFromUrl.length) + return capitalizedDisplayName +} diff --git a/apps/extension/src/app/features/dappRequests/DappRequestContent.test.tsx b/apps/extension/src/app/features/dappRequests/DappRequestContent.test.tsx new file mode 100644 index 00000000..7a805558 --- /dev/null +++ b/apps/extension/src/app/features/dappRequests/DappRequestContent.test.tsx @@ -0,0 +1,225 @@ +import { DappRequestContent } from 'src/app/features/dappRequests/DappRequestContent' +import { REQUEST_EXPIRY_TIME_MS } from 'src/app/features/dappRequests/hooks/useIsRequestStale' +import type { DappRequestStoreItem } from 'src/app/features/dappRequests/shared' +import { DappRequestStatus } from 'src/app/features/dappRequests/shared' +import type { WithMetadata } from 'src/app/features/dappRequests/slice' +import { render, screen } from 'src/test/test-utils' +import { AccountType } from 'uniswap/src/features/accounts/types' +import { DappRequestType } from 'uniswap/src/features/dappRequests/types' + +// Mock wagmi to avoid ESM import issues +jest.mock('wagmi', () => ({ + useAccountEffect: jest.fn(), +})) + +// Mock the useIsRequestStale hook to control output +const mockUseIsRequestStale = jest.fn() +jest.mock('src/app/features/dappRequests/hooks/useIsRequestStale', () => ({ + ...jest.requireActual('src/app/features/dappRequests/hooks/useIsRequestStale'), + useIsRequestStale: (createdAt: number) => mockUseIsRequestStale(createdAt), +})) + +// Mock the context hook to return our mock value +let mockContextValue: any = null +jest.mock('src/app/features/dappRequests/DappRequestQueueContext', () => ({ + useDappRequestQueueContext: () => mockContextValue, +})) + +// Mock hooks used by DappRequestFooter +jest.mock('src/app/features/dapp/hooks', () => ({ + useDappLastChainId: jest.fn(() => 1), +})) + +jest.mock('uniswap/src/features/gas/hooks/useChainGasToken', () => ({ + useChainGasToken: jest.fn(() => ({ + gasToken: { symbol: 'ETH' }, + gasBalance: { value: '1000000000000000000', currency: { symbol: 'ETH' }, equalTo: () => false }, + isLoading: false, + })), +})) + +jest.mock('uniswap/src/features/gas/utils', () => ({ + ...jest.requireActual('uniswap/src/features/gas/utils'), + hasSufficientGasBalance: jest.fn(() => true), + hasGasEstimationFailed: jest.fn(() => false), +})) + +jest.mock('wallet/src/features/wallet/hooks', () => ({ + useActiveAccountWithThrow: jest.fn(() => ({ + address: '0x123', + type: 'readonly', + timeImportedMs: Date.now(), + pushNotificationsEnabled: false, + })), +})) + +jest.mock('uniswap/src/features/chains/hooks/useEnabledChains', () => ({ + useEnabledChains: jest.fn(() => ({ + defaultChainId: 1, + })), +})) + +jest.mock('src/app/features/dappRequests/hooks', () => ({ + useIsDappRequestConfirming: jest.fn(() => false), +})) + +// Mock the NetworkFeeFooter to avoid complex currency parsing +jest.mock('wallet/src/features/transactions/TransactionRequest/NetworkFeeFooter', () => ({ + NetworkFeeFooter: () => null, +})) + +jest.mock('wallet/src/features/transactions/TransactionRequest/AddressFooter', () => ({ + AddressFooter: () => null, +})) + +// Mock currency hooks that parse transaction data +jest.mock('uniswap/src/data/apiClients/tradingApi/useTradingApiSwapQuery', () => ({ + useTradingApiSwapQuery: jest.fn(() => ({ + data: undefined, + isLoading: false, + })), +})) + +function setupMockRequestAndContext(createdAt: number, options?: { frameUrl?: string }): void { + const request: WithMetadata = { + dappRequest: { + type: DappRequestType.SendTransaction, + requestId: 'test-request-id', + transaction: { + from: '0x123', + to: '0x456', + value: '0', + chainId: 1, + }, + }, + senderTabInfo: { + id: 1, + url: 'https://example.com', + frameUrl: options?.frameUrl, + }, + dappInfo: { + activeConnectedAddress: '0x123', + lastChainId: 1, + connectedAccounts: [ + { + address: '0x123', + type: AccountType.Readonly, + timeImportedMs: Date.now(), + pushNotificationsEnabled: false, + }, + ], + }, + createdAt, + status: DappRequestStatus.Pending, + isSidebarClosed: false, + } + + mockContextValue = { + forwards: true, + increasing: true, + request, + currentAccount: { + address: '0x123', + type: AccountType.Readonly, + timeImportedMs: Date.now(), + pushNotificationsEnabled: false, + }, + dappUrl: 'https://example.com', + frameUrl: options?.frameUrl, + dappIconUrl: '', + currentIndex: 0, + totalRequestCount: 1, + onPressNext: jest.fn(), + onPressPrevious: jest.fn(), + onConfirm: jest.fn(), + onCancel: jest.fn(), + } +} + +function renderDappRequestContent(options: { createdAt: number; isRequestStale: boolean; frameUrl?: string }) { + mockUseIsRequestStale.mockReturnValue(options.isRequestStale) + setupMockRequestAndContext(options.createdAt, { frameUrl: options.frameUrl }) + return render() +} + +describe('DappRequestContent - Stale Request Rendering', () => { + beforeEach(() => { + jest.useFakeTimers() + jest.setSystemTime(new Date('2024-01-01T12:00:00.000Z')) + mockUseIsRequestStale.mockClear() + }) + + afterEach(() => { + jest.runOnlyPendingTimers() + jest.useRealTimers() + }) + + it('should render Cancel and Confirm buttons for fresh requests', async () => { + const freshCreatedAt = Date.now() - 1000 + + renderDappRequestContent({ createdAt: freshCreatedAt, isRequestStale: false }) + + // Verify hook was called + expect(mockUseIsRequestStale).toHaveBeenCalledWith(freshCreatedAt) + + // Verify normal buttons are shown + await screen.findByText('Cancel') + await screen.findByText('Confirm') + // Verify close button is NOT shown + expect(screen.queryByText('Close')).toBeNull() + }) + + it('should render warning and Close button for stale requests', async () => { + const staleCreatedAt = Date.now() - (REQUEST_EXPIRY_TIME_MS + 60000) + + renderDappRequestContent({ createdAt: staleCreatedAt, isRequestStale: true }) + + // Verify hook was called + expect(mockUseIsRequestStale).toHaveBeenCalledWith(staleCreatedAt) + // Verify Close button is shown + await screen.findByText('Close') + // Verify Confirm button is NOT shown + expect(screen.queryByText('Confirm')).toBeNull() + }) + + it('should match snapshot for fresh request', async () => { + const freshCreatedAt = Date.now() - 1000 + + const { container } = renderDappRequestContent({ createdAt: freshCreatedAt, isRequestStale: false }) + + expect(container).toMatchSnapshot() + }) + + it('should match snapshot for stale request', async () => { + const staleCreatedAt = Date.now() - (REQUEST_EXPIRY_TIME_MS + 60000) + + const { container } = renderDappRequestContent({ createdAt: staleCreatedAt, isRequestStale: true }) + + expect(container).toMatchSnapshot() + }) + + it('should display iframe URL with "via" when frameUrl differs from url', async () => { + const freshCreatedAt = Date.now() - 1000 + + renderDappRequestContent({ + createdAt: freshCreatedAt, + isRequestStale: false, + frameUrl: 'https://app.uniswap.org', + }) + + // Should show "app.uniswap.org via example.com" in the URL label + expect(screen.queryByText(/app\.uniswap\.org via example\.com/i)).not.toBeNull() + }) + + it('should display only top-level URL when frameUrl is not present', async () => { + const freshCreatedAt = Date.now() - 1000 + + renderDappRequestContent({ createdAt: freshCreatedAt, isRequestStale: false }) + + // Should show just "example.com" (no "via") + expect(screen.queryByText(/example\.com/i)).not.toBeNull() + + // Should NOT show "via" + expect(screen.queryByText(/via/i)).toBeNull() + }) +}) diff --git a/apps/extension/src/app/features/dappRequests/DappRequestContent.tsx b/apps/extension/src/app/features/dappRequests/DappRequestContent.tsx new file mode 100644 index 00000000..53a483c4 --- /dev/null +++ b/apps/extension/src/app/features/dappRequests/DappRequestContent.tsx @@ -0,0 +1,303 @@ +import { type GasFeeResult } from '@universe/api' +import { type PropsWithChildren } from 'react' +import { useTranslation } from 'react-i18next' +import { type Animated } from 'react-native' +import { useDispatch } from 'react-redux' +import { useDappLastChainId } from 'src/app/features/dapp/hooks' +import { useDappRequestQueueContext } from 'src/app/features/dappRequests/DappRequestQueueContext' +import { handleExternallySubmittedUniswapXOrder } from 'src/app/features/dappRequests/handleUniswapX' +import { useIsDappRequestConfirming } from 'src/app/features/dappRequests/hooks' +import { useIsRequestStale } from 'src/app/features/dappRequests/hooks/useIsRequestStale' +import { type DappRequestStoreItem } from 'src/app/features/dappRequests/shared' +import { type DappRequest, isBatchedSwapRequest } from 'src/app/features/dappRequests/types/DappRequestTypes' +import { AnimatePresence, Button, Flex, type GetThemeValueForKey, styled, Text } from 'ui/src' +import { useEnabledChains } from 'uniswap/src/features/chains/hooks/useEnabledChains' +import { type UniverseChainId } from 'uniswap/src/features/chains/types' +import { DappRequestType } from 'uniswap/src/features/dappRequests/types' +import { useChainGasToken } from 'uniswap/src/features/gas/hooks/useChainGasToken' +import { hasGasEstimationFailed, hasSufficientGasBalance } from 'uniswap/src/features/gas/utils' +import { type TransactionTypeInfo } from 'uniswap/src/features/transactions/types/transactionDetails' +import { extractNameFromUrl } from 'utilities/src/format/extractNameFromUrl' +import { logger } from 'utilities/src/logger/logger' +import { useEvent } from 'utilities/src/react/hooks' +import { useThrottledCallback } from 'utilities/src/react/useThrottledCallback' +import { MAX_HIDDEN_CALLS_BY_DEFAULT } from 'wallet/src/components/BatchedTransactions/BatchedTransactionDetails' +import { DappRequestHeader } from 'wallet/src/components/dappRequests/DappRequestHeader' +import { WarningBox } from 'wallet/src/components/WarningBox/WarningBox' +import { type DappVerificationStatus } from 'wallet/src/features/dappRequests/types' +import { AddressFooter } from 'wallet/src/features/transactions/TransactionRequest/AddressFooter' +import { NetworkFeeFooter } from 'wallet/src/features/transactions/TransactionRequest/NetworkFeeFooter' +import { useActiveAccountWithThrow } from 'wallet/src/features/wallet/hooks' + +interface DappRequestHeaderProps { + title: string + verificationStatus?: DappVerificationStatus + headerIcon?: JSX.Element +} + +interface DappRequestFooterProps { + chainId?: UniverseChainId + connectedAccountAddress?: string + confirmText?: string + maybeCloseOnConfirm?: boolean + onCancel?: (requestToConfirm?: DappRequestStoreItem, transactionTypeInfo?: TransactionTypeInfo) => void + onConfirm?: (requestToCancel?: DappRequestStoreItem) => void + showNetworkCost?: boolean + showSmartWalletActivation?: boolean + showAddressFooter?: boolean + transactionGasFeeResult?: GasFeeResult + isUniswapX?: boolean + disableConfirm?: boolean + contentHorizontalPadding?: number | Animated.AnimatedNode | GetThemeValueForKey<'paddingHorizontal'> | null +} + +type DappRequestContentProps = DappRequestHeaderProps & DappRequestFooterProps + +export const AnimatedPane = styled(Flex, { + variants: { + forwards: (dir: boolean) => ({ + enterStyle: { + x: dir ? 10 : -10, + opacity: 0, + }, + }), + increasing: (dir: boolean) => ({ + enterStyle: dir + ? { + y: 10, + opacity: 0, + } + : undefined, + exitStyle: !dir + ? { + y: 10, + opacity: 0, + } + : undefined, + }), + } as const, +}) + +export function DappRequestContent({ + chainId, + title, + verificationStatus, + headerIcon, + confirmText, + connectedAccountAddress, + maybeCloseOnConfirm, + onCancel, + onConfirm, + showNetworkCost, + showSmartWalletActivation, + transactionGasFeeResult, + children, + isUniswapX, + disableConfirm, + showAddressFooter = true, + contentHorizontalPadding = '$spacing12', +}: PropsWithChildren): JSX.Element { + const { forwards, currentIndex, dappIconUrl, dappUrl, frameUrl } = useDappRequestQueueContext() + const hostname = extractNameFromUrl(dappUrl).toUpperCase() + + return ( + <> + + + + + + {children} + + + + + ) +} + +const WINDOW_CLOSE_DELAY = 10 + + isUniswapX, + disableConfirm, +}: DappRequestFooterProps): JSX.Element { + const { t } = useTranslation() + const dispatch = useDispatch() + const activeAccount = useActiveAccountWithThrow() + const { defaultChainId } = useEnabledChains() + const { + dappUrl, + currentAccount, + request, + totalRequestCount, + onConfirm: defaultOnConfirm, + onCancel: defaultOnCancel, + } = useDappRequestQueueContext() + + const activeChain = useDappLastChainId(dappUrl) + + if (!request) { + const error = new Error('no request present') + logger.error(error, { tags: { file: 'DappRequestContent', function: 'DappRequestFooter' } }) + throw error + } + + const sendTransactionChainId = + request.dappRequest.type === DappRequestType.SendTransaction ? request.dappRequest.transaction.chainId : undefined + const currentChainId = chainId || sendTransactionChainId || activeChain || defaultChainId + const { gasBalance } = useChainGasToken({ chainId: currentChainId, accountAddress: currentAccount.address }) + const isRequestConfirming = useIsDappRequestConfirming(request.dappRequest.requestId) + const isRequestStale = useIsRequestStale(request.createdAt) + + const hasSufficientGas = hasSufficientGasBalance({ + chainId: currentChainId, + gasBalance, + gasFee: transactionGasFeeResult?.value, + }) + + const shouldCloseSidebar = request.isSidebarClosed && totalRequestCount <= 1 + + // Check if this is a transaction request that needs gas estimation + const isTransactionRequest = + request.dappRequest.type === DappRequestType.SendTransaction || + request.dappRequest.type === DappRequestType.SendCalls + + // Check if gas estimation failed (has error or no value after loading) + const gasEstimationFailed = hasGasEstimationFailed(isTransactionRequest, transactionGasFeeResult) + + // Disable submission when gas estimation fails or user has insufficient funds + const isConfirmEnabled = !isTransactionRequest || (!gasEstimationFailed && hasSufficientGas) + + const handleOnConfirm = useEvent(async () => { + if (isRequestConfirming) { + return + } + + if (onConfirm) { + onConfirm() + } else { + await defaultOnConfirm({ request }) + if (isUniswapX) { + await handleExternallySubmittedUniswapXOrder(activeAccount.address, dispatch) + } + } + + if (maybeCloseOnConfirm && shouldCloseSidebar) { + setTimeout(window.close, WINDOW_CLOSE_DELAY) + } + }) + + // This is strictly a UI debounce to prevent submitting the same confirmation multiple times. + const [debouncedHandleOnConfirm, isConfirming] = useThrottledCallback(handleOnConfirm) + + const handleOnCancel = useEvent(async () => { + if (onCancel) { + onCancel() + } else { + await defaultOnCancel(request) + } + + if (shouldCloseSidebar) { + setTimeout(window.close, WINDOW_CLOSE_DELAY) + } + }) + + const isDisabled = !isConfirmEnabled || disableConfirm || isConfirming || isRequestConfirming + const isLoading = isRequestConfirming || isConfirming + + return ( + <> + + {gasEstimationFailed && ( + + + {t('dapp.request.error.gasEstimation')} + + + )} + {!hasSufficientGas && !gasEstimationFailed && ( + + + {t('swap.warning.insufficientGas.title', { + currencySymbol: gasBalance?.currency.symbol ?? '', + })} + + + )} + {showNetworkCost && ( + + )} + {showAddressFooter && ( + + )} + + + + {confirmText && !isRequestStale && ( + + )} + + + + ) +} + +function WarningSection({ request, isRequestStale }: { request: DappRequest; isRequestStale: boolean }) { + const { t } = useTranslation() + + if (isRequestStale) { + return + } + + if (request.type === DappRequestType.SendCalls) { + if (request.calls.length <= 1 || isBatchedSwapRequest(request)) { + return null + } + const level = request.calls.length >= MAX_HIDDEN_CALLS_BY_DEFAULT ? 'critical' : 'warning' + return + } +} diff --git a/apps/extension/src/app/features/dappRequests/DappRequestQueue.tsx b/apps/extension/src/app/features/dappRequests/DappRequestQueue.tsx new file mode 100644 index 00000000..70ab7d4e --- /dev/null +++ b/apps/extension/src/app/features/dappRequests/DappRequestQueue.tsx @@ -0,0 +1,208 @@ +import { memo } from 'react' +import { Trans, useTranslation } from 'react-i18next' +import { useDispatch, useSelector } from 'react-redux' +import { rejectAllRequests } from 'src/app/features/dappRequests/actions' +import { TransactionConfirmationTrackerProvider } from 'src/app/features/dappRequests/context/TransactionConfirmationTracker' +import { AnimatedPane, DappRequestContent } from 'src/app/features/dappRequests/DappRequestContent' +import { DappRequestCards } from 'src/app/features/dappRequests/DappRequestQueueCards' +import { + DappRequestQueueProvider, + useDappRequestQueueContext, +} from 'src/app/features/dappRequests/DappRequestQueueContext' +import { ConnectionRequestContent } from 'src/app/features/dappRequests/requestContent/Connection/ConnectionRequestContent' +import { EthSendRequestContent } from 'src/app/features/dappRequests/requestContent/EthSend/EthSend' +import { PersonalSignRequestContent } from 'src/app/features/dappRequests/requestContent/PersonalSign/PersonalSignRequestContent' +import { SendCallsRequestHandler } from 'src/app/features/dappRequests/requestContent/SendCalls/SendCallsRequestContent' +import { SignTypedDataRequestContent } from 'src/app/features/dappRequests/requestContent/SignTypeData/SignTypedDataRequestContent' +import { + isDappRequestStoreItemForEthSendTxn, + isDappRequestStoreItemForSendCallsTxn, + selectAllDappRequests, +} from 'src/app/features/dappRequests/slice' +import { + isConnectionRequest, + isSignMessageRequest, + isSignTypedDataRequest, +} from 'src/app/features/dappRequests/types/DappRequestTypes' +import { AnimatePresence, Flex, Text, TouchableArea, useSporeColors } from 'ui/src' +import { ReceiptText, RotatableChevron } from 'ui/src/components/icons' +import { zIndexes } from 'ui/src/theme' +import { Modal } from 'uniswap/src/components/modals/Modal' +import { ModalName } from 'uniswap/src/features/telemetry/constants' + +const REJECT_MESSAGE_HEIGHT = 48 + +export function DappRequestQueue(): JSX.Element { + const dappRequests = useSelector(selectAllDappRequests) + const requestsExist = dappRequests.length > 0 + + return ( + + + + + + + + ) +} + +function DappRequestQueueContent(): JSX.Element { + const { t } = useTranslation() + const colors = useSporeColors() + const dispatch = useDispatch() + + const { totalRequestCount, onPressPrevious, onPressNext, currentIndex, increasing } = useDappRequestQueueContext() + + const disabledPrevious = currentIndex <= 0 + const disabledNext = currentIndex >= totalRequestCount - 1 + + const onRejectAll = async (): Promise => { + dispatch(rejectAllRequests()) + } + + return ( + + + {totalRequestCount > 1 && ( + + + + + + ), + }} + i18nKey="dapp.request.reject.info" + values={{ totalRequestCount }} + /> + + + + + {t('dapp.request.reject.action')} + + + + )} + + 1 ? '$spacing12' : '$none'} + width="100%" + py="$spacing12" + > + {totalRequestCount > 1 && ( + + + + + + {currentIndex + 1} + + + / + + + + + {totalRequestCount} + + + + + + + + )} + + + + + ) +} + +const DappRequest = memo(function DappRequestInner(): JSX.Element | null { + const { t } = useTranslation() + const { request } = useDappRequestQueueContext() + + if (!request) { + return null + } + + if (isSignMessageRequest(request.dappRequest)) { + return + } + if (isSignTypedDataRequest(request.dappRequest)) { + return + } + if (isDappRequestStoreItemForEthSendTxn(request)) { + return + } + if (isConnectionRequest(request.dappRequest)) { + return + } + if (isDappRequestStoreItemForSendCallsTxn(request)) { + return + } + + return +}) diff --git a/apps/extension/src/app/features/dappRequests/DappRequestQueueCards.tsx b/apps/extension/src/app/features/dappRequests/DappRequestQueueCards.tsx new file mode 100644 index 00000000..b30bf41f --- /dev/null +++ b/apps/extension/src/app/features/dappRequests/DappRequestQueueCards.tsx @@ -0,0 +1,53 @@ +import { useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { useDispatch } from 'react-redux' +import { useDappRequestQueueContext } from 'src/app/features/dappRequests/DappRequestQueueContext' +import { useShouldShowBridgingRequestCard } from 'src/app/features/dappRequests/hooks' +import { BRIDGING_BANNER } from 'ui/src/assets' +import { DappRequestCardLoggingName } from 'uniswap/src/features/telemetry/types' +import { CurrencyField } from 'uniswap/src/types/currency' +import { CardType, IntroCard, IntroCardGraphicType, IntroCardProps } from 'wallet/src/components/introCards/IntroCard' +import { useWalletNavigation } from 'wallet/src/contexts/WalletNavigationContext' +import { setHasViewedDappRequestBridgingBanner } from 'wallet/src/features/behaviorHistory/slice' + +export function DappRequestCards(): JSX.Element | null { + const { t } = useTranslation() + const dispatch = useDispatch() + const { request, dappUrl, onCancel, totalRequestCount } = useDappRequestQueueContext() + const { navigateToSwapFlow } = useWalletNavigation() + + const { numBridgingChains, shouldShowBridgingRequestCard } = useShouldShowBridgingRequestCard(request, dappUrl) + const card = useMemo( + (): IntroCardProps => ({ + graphic: { + type: IntroCardGraphicType.Image, + image: BRIDGING_BANNER, + }, + title: t('dapp.request.bridge.title'), + description: t('dapp.request.bridge.description', { numChains: numBridgingChains }), + cardType: CardType.Dismissible, + loggingName: DappRequestCardLoggingName.BridgingBanner, + onClose: (): void => { + dispatch(setHasViewedDappRequestBridgingBanner({ dappUrl, hasViewed: true })) + }, + onPress: (): void => { + if (request) { + onCancel(request).catch(() => {}) + } + dispatch(setHasViewedDappRequestBridgingBanner({ dappUrl, hasViewed: true })) + navigateToSwapFlow({ openTokenSelector: CurrencyField.OUTPUT }) + }, + containerProps: { + borderWidth: 0, + backgroundColor: '$surface1', + }, + }), + [t, numBridgingChains, dispatch, dappUrl, onCancel, request, navigateToSwapFlow], + ) + + if (!request || !shouldShowBridgingRequestCard || totalRequestCount !== 1) { + return null + } + + return +} diff --git a/apps/extension/src/app/features/dappRequests/DappRequestQueueContext.tsx b/apps/extension/src/app/features/dappRequests/DappRequestQueueContext.tsx new file mode 100644 index 00000000..0c767e95 --- /dev/null +++ b/apps/extension/src/app/features/dappRequests/DappRequestQueueContext.tsx @@ -0,0 +1,191 @@ +import { providerErrors, serializeError } from '@metamask/rpc-errors' +import { createContext, type PropsWithChildren, useContext, useEffect, useRef, useState } from 'react' +import { useDispatch, useSelector } from 'react-redux' +import { confirmRequest, confirmRequestNoDappInfo, rejectRequest } from 'src/app/features/dappRequests/actions' +import { useTransactionConfirmationTracker } from 'src/app/features/dappRequests/context/TransactionConfirmationTracker' +import { isDappRequestWithDappInfo } from 'src/app/features/dappRequests/saga' +import type { DappRequestStoreItem } from 'src/app/features/dappRequests/shared' +import { selectAllDappRequests, type WithMetadata } from 'src/app/features/dappRequests/slice' +import { DappResponseType } from 'uniswap/src/features/dappRequests/types' +import { ExtensionEventName } from 'uniswap/src/features/telemetry/constants' +import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { DappRequestAction } from 'uniswap/src/features/telemetry/types' +import { type TransactionTypeInfo } from 'uniswap/src/features/transactions/types/transactionDetails' +import { extractBaseUrl } from 'utilities/src/format/urls' +import { useEvent } from 'utilities/src/react/hooks' +import { type SignedTransactionRequest } from 'wallet/src/features/transactions/executeTransaction/types' +import { type Account } from 'wallet/src/features/wallet/accounts/types' +import { useActiveAccountWithThrow } from 'wallet/src/features/wallet/hooks' + +interface DappRequestQueueContextValue { + forwards: boolean // direction of sliding animation + increasing: boolean // direction of number increasing animation + request: WithMetadata | undefined + currentAccount: Account // Account the request is going to (not necessarily the active account) + dappUrl: string + frameUrl?: string + dappIconUrl: string + currentIndex: number + totalRequestCount: number + onPressNext: () => void + onPressPrevious: () => void + onConfirm: (params: { + request: WithMetadata + transactionTypeInfo?: TransactionTypeInfo + preSignedTransaction?: SignedTransactionRequest + }) => Promise + onCancel: (request: WithMetadata) => Promise +} + +const DappRequestQueueContext = createContext(undefined) + +export function DappRequestQueueProvider({ children }: PropsWithChildren): JSX.Element { + const dispatch = useDispatch() + const [currentIndex, setCurrentIndex] = useState(0) + + // Show the top most pending request + const dappRequests = useSelector(selectAllDappRequests) + + const request = dappRequests[currentIndex] + const totalRequestCount = dappRequests.length + + const activeAccount = useActiveAccountWithThrow() + const { markTransactionConfirmed } = useTransactionConfirmationTracker() + + // values to help with animations + const [forwards, setForwards] = useState(true) + const [increasing, setIncreasing] = useState(true) + const prevTotalRequestCountRef = useRef(totalRequestCount) + + useEffect(() => { + if (totalRequestCount > prevTotalRequestCountRef.current) { + setIncreasing(true) + } + + if (totalRequestCount < prevTotalRequestCountRef.current) { + setIncreasing(false) + } + + prevTotalRequestCountRef.current = totalRequestCount + }, [totalRequestCount]) + + const dappUrl = extractBaseUrl(request?.senderTabInfo.url) || '' + const frameUrl = extractBaseUrl(request?.senderTabInfo.frameUrl) || undefined + const dappIconUrl = request?.senderTabInfo.favIconUrl || '' + + let currentAccount = activeAccount + if (request?.dappInfo) { + const { activeConnectedAddress, connectedAccounts } = request.dappInfo + const connectedAccount = connectedAccounts.find((account) => account.address === activeConnectedAddress) + + if (connectedAccount) { + currentAccount = connectedAccount + } + } + + const onConfirm = useEvent( + async (params: { + request: WithMetadata + transactionTypeInfo?: TransactionTypeInfo + preSignedTransaction?: SignedTransactionRequest + }): Promise => { + const { request, transactionTypeInfo, preSignedTransaction } = params + const requestWithTxInfo = { + ...request, + transactionTypeInfo, + preSignedTransaction, + } + if (request.dappInfo) { + const { activeConnectedAddress, lastChainId } = request.dappInfo + const connectedAddresses = request.dappInfo.connectedAccounts.map((account) => account.address) + sendAnalyticsEvent(ExtensionEventName.DappRequest, { + action: DappRequestAction.Accept, + requestType: request.dappRequest.type, + dappUrl: extractBaseUrl(request.senderTabInfo.url), + chainId: lastChainId, + activeConnectedAddress, + connectedAddresses, + }) + } + + if (isDappRequestWithDappInfo(requestWithTxInfo)) { + await dispatch(confirmRequest(requestWithTxInfo)) + } else { + await dispatch(confirmRequestNoDappInfo(requestWithTxInfo)) + } + + // Mark transaction as confirmed for nonce delay tracking + // Only mark if we have chain info (transactions that could conflict) + if (request.dappInfo?.lastChainId) { + markTransactionConfirmed(request.dappInfo.lastChainId) + } + + setCurrentIndex((prev) => Math.max(0, prev - 1)) + }, + ) + + const onCancel = useEvent(async (requestToCancel: WithMetadata): Promise => { + if (requestToCancel.dappInfo) { + const { activeConnectedAddress, lastChainId } = requestToCancel.dappInfo + const connectedAddresses = requestToCancel.dappInfo.connectedAccounts.map((account) => account.address) + sendAnalyticsEvent(ExtensionEventName.DappRequest, { + action: DappRequestAction.Reject, + requestType: requestToCancel.dappRequest.type, + dappUrl: extractBaseUrl(requestToCancel.senderTabInfo.url), + chainId: lastChainId, + activeConnectedAddress, + connectedAddresses, + }) + } + await dispatch( + rejectRequest({ + senderTabInfo: requestToCancel.senderTabInfo, + errorResponse: { + requestId: requestToCancel.dappRequest.requestId, + type: DappResponseType.ErrorResponse, + error: serializeError(providerErrors.userRejectedRequest()), + }, + }), + ) + + setCurrentIndex((prev) => Math.max(0, prev - 1)) + }) + + const onPressNext = (): void => { + setForwards(true) + setCurrentIndex((prev) => Math.min(prev + 1, totalRequestCount - 1)) + } + + const onPressPrevious = (): void => { + setForwards(false) + setCurrentIndex((prev) => Math.max(0, prev - 1)) + } + + const value = { + forwards, + increasing, + currentIndex, + totalRequestCount, + request, + dappUrl, + frameUrl, + dappIconUrl, + currentAccount, + onConfirm, + onCancel, + onPressNext, + onPressPrevious, + } + + return {children} +} + +export function useDappRequestQueueContext(): DappRequestQueueContextValue { + const context = useContext(DappRequestQueueContext) + + if (context === undefined) { + throw new Error('useDappRequestQueueContext must be used within a DappRequestQueueProvider') + } + + return context +} diff --git a/apps/extension/src/app/features/dappRequests/__snapshots__/DappRequestContent.test.tsx.snap b/apps/extension/src/app/features/dappRequests/__snapshots__/DappRequestContent.test.tsx.snap new file mode 100644 index 00000000..393874f6 --- /dev/null +++ b/apps/extension/src/app/features/dappRequests/__snapshots__/DappRequestContent.test.tsx.snap @@ -0,0 +1,222 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DappRequestContent - Stale Request Rendering should match snapshot for fresh request 1`] = ` +
+ +
+
+
+
+ + E + +
+
+ + Transaction request + +
+
+
+
+ + example.com + +
+
+
+
+
+
+
+
+
+ + +
+
+
+ +
+`; + +exports[`DappRequestContent - Stale Request Rendering should match snapshot for stale request 1`] = ` +
+ +
+
+
+
+ + E + +
+
+ + Transaction request + +
+
+
+
+ + example.com + +
+
+
+
+
+
+
+
+
+
+ + + +
+ + This request has expired due to inactivity. Please try submitting again + +
+
+ +
+
+
+ +
+`; diff --git a/apps/extension/src/app/features/dappRequests/accounts.ts b/apps/extension/src/app/features/dappRequests/accounts.ts new file mode 100644 index 00000000..24557146 --- /dev/null +++ b/apps/extension/src/app/features/dappRequests/accounts.ts @@ -0,0 +1,172 @@ +/* oxlint-disable typescript/explicit-function-return-type */ +import { type JsonRpcProvider } from '@ethersproject/providers' +import { providerErrors, serializeError } from '@metamask/rpc-errors' +import { saveDappConnection } from 'src/app/features/dapp/actions' +import { type DappInfo, dappStore } from 'src/app/features/dapp/store' +import { getOrderedConnectedAddresses } from 'src/app/features/dapp/utils' +import type { SenderTabInfo } from 'src/app/features/dappRequests/shared' +import { + type AccountResponse, + type DappRequest, + type ErrorResponse, + type GetAccountRequest, + type RequestAccountRequest, +} from 'src/app/features/dappRequests/types/DappRequestTypes' +import { dappResponseMessageChannel } from 'src/background/messagePassing/messageChannels' +import { call, put, select } from 'typed-redux-saga' +import { type UniverseChainId } from 'uniswap/src/features/chains/types' +import { chainIdToHexadecimalString } from 'uniswap/src/features/chains/utils' +import { DappResponseType } from 'uniswap/src/features/dappRequests/types' +import { pushNotification } from 'uniswap/src/features/notifications/slice/slice' +import { AppNotificationType } from 'uniswap/src/features/notifications/slice/types' +import { Platform } from 'uniswap/src/features/platforms/types/Platform' +import { getEnabledChainIdsSaga } from 'uniswap/src/features/settings/saga' +import { ExtensionEventName } from 'uniswap/src/features/telemetry/constants' +import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { extractBaseUrl } from 'utilities/src/format/urls' +import { getProvider } from 'wallet/src/features/wallet/context' +import { selectActiveAccount } from 'wallet/src/features/wallet/selectors' + +function getAccountResponse({ + chainId, + dappRequest, + provider, + dappInfo, +}: { + chainId: UniverseChainId + dappRequest: DappRequest + provider: JsonRpcProvider + dappInfo: DappInfo +}): AccountResponse { + const orderedConnectedAddresses = getOrderedConnectedAddresses( + dappInfo.connectedAccounts, + dappInfo.activeConnectedAddress, + ) + + return { + type: DappResponseType.AccountResponse, + requestId: dappRequest.requestId, + connectedAddresses: orderedConnectedAddresses, + chainId: chainIdToHexadecimalString(chainId), + providerUrl: provider.connection.url, + } +} + +function sendAccountResponseAnalyticsEvent({ + senderUrl, + chainId, + dappInfo, + accountResponse, +}: { + senderUrl: string + chainId: UniverseChainId + dappInfo: DappInfo + accountResponse: AccountResponse +}): void { + const dappUrl = extractBaseUrl(senderUrl) + + sendAnalyticsEvent(ExtensionEventName.DappConnect, { + dappUrl: dappUrl ?? '', + chainId, + activeConnectedAddress: dappInfo.activeConnectedAddress, + connectedAddresses: accountResponse.connectedAddresses, + }) +} + +/** + * Gets the active account, and returns the account address, chainId, and providerUrl. + * Chain id + provider url are from the last connected chain for the dApp and wallet. If this has not been set, it will be the default chain and provider. + */ +export function* getAccount({ + dappRequest, + senderTabInfo: { id, url }, + dappInfo, +}: { + dappRequest: GetAccountRequest | RequestAccountRequest + senderTabInfo: SenderTabInfo + dappInfo: DappInfo +}) { + const chainId = dappInfo.lastChainId + const provider = yield* call(getProvider, chainId) + + const response = getAccountResponse({ chainId, dappRequest, provider, dappInfo }) + sendAccountResponseAnalyticsEvent({ senderUrl: url, chainId, dappInfo, accountResponse: response }) + + yield* call(dappResponseMessageChannel.sendMessageToTab, id, response) +} + +/** + * Saves the active account as connected to the dapp and parses out necessary data + * Triggers a notification for new connections + */ +export function* saveAccount({ url, favIconUrl }: SenderTabInfo) { + const activeAccount = yield* select(selectActiveAccount) + const dappUrl = extractBaseUrl(url) + const dappInfo = yield* call(dappStore.getDappInfo, dappUrl) + const { defaultChainId } = yield* call(getEnabledChainIdsSaga, Platform.EVM) + + if (!dappUrl || !activeAccount) { + return undefined + } + + yield* call(saveDappConnection, { dappUrl, account: activeAccount, iconUrl: favIconUrl }) + // No dapp info means that this is a first time connection request + if (!dappInfo) { + yield* put( + pushNotification({ + type: AppNotificationType.DappConnected, + dappIconUrl: favIconUrl, + }), + ) + } + + const chainId = dappInfo?.lastChainId ?? defaultChainId + const provider = yield* call(getProvider, chainId) + const connectedAddresses = (dappUrl && (yield* call(dappStore.getDappOrderedConnectedAddresses, dappUrl))) || [] + + return { + dappUrl, + activeAccount, + connectedAddresses, + chainId, + providerUrl: provider.connection.url, + } +} + +/** + * Gets the active account, and returns the account address, chainId, and providerUrl. + * Chain id + provider url are from the last connected chain for the dApp and wallet. If this has not been set, it will be the default chain and provider. + */ +export function* getAccountRequest(request: RequestAccountRequest, senderTabInfo: SenderTabInfo) { + const accountInfo = yield* call(saveAccount, senderTabInfo) + + if (!accountInfo) { + const errorResponse: ErrorResponse = { + type: DappResponseType.ErrorResponse, + error: serializeError(providerErrors.unauthorized()), + requestId: request.requestId, + } + + yield* call(dappResponseMessageChannel.sendMessageToTab, senderTabInfo.id, errorResponse) + } else { + const { dappUrl, activeAccount, connectedAddresses, chainId, providerUrl } = accountInfo + + const accountResponse: AccountResponse = { + type: DappResponseType.AccountResponse, + requestId: request.requestId, + connectedAddresses, + chainId: chainIdToHexadecimalString(chainId), + providerUrl, + } + + yield* call(dappResponseMessageChannel.sendMessageToTab, senderTabInfo.id, accountResponse) + + // Track that a connection was established + sendAnalyticsEvent(ExtensionEventName.DappConnect, { + dappUrl, + chainId, + activeConnectedAddress: activeAccount.address, + connectedAddresses, + }) + } +} diff --git a/apps/extension/src/app/features/dappRequests/actions.ts b/apps/extension/src/app/features/dappRequests/actions.ts new file mode 100644 index 00000000..2b3c7c51 --- /dev/null +++ b/apps/extension/src/app/features/dappRequests/actions.ts @@ -0,0 +1,15 @@ +import { createAction } from '@reduxjs/toolkit' +import type { + DappRequestNoDappInfo, + DappRequestRejectParams, + DappRequestWithDappInfo, +} from 'src/app/features/dappRequests/shared' + +/** This is for requests where the dapp info is not passed along as part of the request because it + * does not exist yet (i.e. GetAccountRequest). In these cases the dappInfo will need to be saved. + */ +export const confirmRequestNoDappInfo = createAction('dappRequest/confirmSaveConnectionRequest') +export const confirmRequest = createAction(`dappRequest/confirmRequest`) +export const addRequest = createAction(`dappRequest/handleRequest`) +export const rejectRequest = createAction(`dappRequest/rejectRequest`) +export const rejectAllRequests = createAction('dappRequest/rejectAllRequests') diff --git a/apps/extension/src/app/features/dappRequests/configuredSagas.ts b/apps/extension/src/app/features/dappRequests/configuredSagas.ts new file mode 100644 index 00000000..397a92d1 --- /dev/null +++ b/apps/extension/src/app/features/dappRequests/configuredSagas.ts @@ -0,0 +1,19 @@ +import { createPrepareAndSignDappTransactionSaga } from 'src/app/features/dappRequests/sagas/prepareAndSignDappTransactionSaga' +import { createMonitoredSaga } from 'uniswap/src/utils/saga' +import { getSharedTransactionSagaDependencies } from 'wallet/src/features/transactions/configuredSagas' + +// Create configured saga instance using shared transaction dependencies +const configuredPrepareAndSignDappTransactionSaga = createPrepareAndSignDappTransactionSaga( + getSharedTransactionSagaDependencies(), +) + +// Export the monitored saga +export const { + name: prepareAndSignDappTransactionSagaName, + wrappedSaga: prepareAndSignDappTransactionSaga, + reducer: prepareAndSignDappTransactionReducer, + actions: prepareAndSignDappTransactionActions, +} = createMonitoredSaga({ + saga: configuredPrepareAndSignDappTransactionSaga, + name: 'prepareAndSignDappTransaction', +}) diff --git a/apps/extension/src/app/features/dappRequests/context/TransactionConfirmationTracker.test.tsx b/apps/extension/src/app/features/dappRequests/context/TransactionConfirmationTracker.test.tsx new file mode 100644 index 00000000..78ebb387 --- /dev/null +++ b/apps/extension/src/app/features/dappRequests/context/TransactionConfirmationTracker.test.tsx @@ -0,0 +1,467 @@ +import { act, render, renderHook, screen } from '@testing-library/react' +import { PropsWithChildren } from 'react' +import { + TransactionConfirmationTrackerProvider, + useTransactionConfirmationTracker, +} from 'src/app/features/dappRequests/context/TransactionConfirmationTracker' +import { UniverseChainId } from 'uniswap/src/features/chains/types' + +// Helper component to test the hook +function TestComponent({ children }: PropsWithChildren): JSX.Element { + return {children} +} + +// Helper component that uses the hook for testing +function TestHookConsumer(): JSX.Element { + const { markTransactionConfirmed, getDelayForChainId, clearConfirmationTracking } = + useTransactionConfirmationTracker() + + return ( +
+ + + +
+ ) +} + +describe('TransactionConfirmationTracker', () => { + beforeEach(() => { + jest.useFakeTimers() + jest.setSystemTime(new Date('2024-01-01T00:00:00.000Z')) + }) + + afterEach(() => { + jest.runOnlyPendingTimers() + jest.useRealTimers() + // Clean up any elements added to document.body + const delayResults = document.querySelectorAll('[data-testid="delay-result"]') + delayResults.forEach((element) => element.remove()) + }) + + describe('Provider', () => { + it('should provide context value to children', () => { + const { result } = renderHook(() => useTransactionConfirmationTracker(), { + wrapper: TestComponent, + }) + + expect(result.current).toHaveProperty('markTransactionConfirmed') + expect(result.current).toHaveProperty('getDelayForChainId') + expect(result.current).toHaveProperty('clearConfirmationTracking') + expect(typeof result.current.markTransactionConfirmed).toBe('function') + expect(typeof result.current.getDelayForChainId).toBe('function') + expect(typeof result.current.clearConfirmationTracking).toBe('function') + }) + + it('should throw error when hook is used outside provider', () => { + // Suppress console.error for this test + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}) + + expect(() => { + renderHook(() => useTransactionConfirmationTracker()) + }).toThrow('useTransactionConfirmationTracker must be used within a TransactionConfirmationTrackerProvider') + + consoleSpy.mockRestore() + }) + }) + + describe('Provider initialization with default state', () => { + it('should return 0 delay for any chain when no transactions have been confirmed', () => { + const { result } = renderHook(() => useTransactionConfirmationTracker(), { + wrapper: TestComponent, + }) + + const delay = result.current.getDelayForChainId(UniverseChainId.Mainnet, 5000) + expect(delay).toBe(0) + }) + + it('should return 0 delay for multiple different chains', () => { + const { result } = renderHook(() => useTransactionConfirmationTracker(), { + wrapper: TestComponent, + }) + + expect(result.current.getDelayForChainId(UniverseChainId.Mainnet, 5000)).toBe(0) + expect(result.current.getDelayForChainId(UniverseChainId.Polygon, 5000)).toBe(0) + expect(result.current.getDelayForChainId(UniverseChainId.ArbitrumOne, 5000)).toBe(0) + }) + }) + + describe('markTransactionConfirmed functionality', () => { + it('should mark a transaction as confirmed and affect subsequent delay calculations', () => { + const { result } = renderHook(() => useTransactionConfirmationTracker(), { + wrapper: TestComponent, + }) + + // Initially no delay + expect(result.current.getDelayForChainId(UniverseChainId.Mainnet, 5000)).toBe(0) + + // Mark transaction confirmed + act(() => { + result.current.markTransactionConfirmed(UniverseChainId.Mainnet) + }) + + // Now should have full delay + expect(result.current.getDelayForChainId(UniverseChainId.Mainnet, 5000)).toBe(5000) + }) + + it('should update the confirmation timestamp when called multiple times', () => { + const { result } = renderHook(() => useTransactionConfirmationTracker(), { + wrapper: TestComponent, + }) + + // Mark first transaction + act(() => { + result.current.markTransactionConfirmed(UniverseChainId.Mainnet) + }) + + // Advance time by 2 seconds + act(() => { + jest.advanceTimersByTime(2000) + }) + + // Mark second transaction (should reset the timestamp) + act(() => { + result.current.markTransactionConfirmed(UniverseChainId.Mainnet) + }) + + // Should have full delay again (not reduced by the previous 2 seconds) + expect(result.current.getDelayForChainId(UniverseChainId.Mainnet, 5000)).toBe(5000) + }) + + it('should handle marking confirmations on different chains independently', () => { + const { result } = renderHook(() => useTransactionConfirmationTracker(), { + wrapper: TestComponent, + }) + + // Mark transaction on Mainnet + act(() => { + result.current.markTransactionConfirmed(UniverseChainId.Mainnet) + }) + + // Mainnet should have delay, Polygon should not + expect(result.current.getDelayForChainId(UniverseChainId.Mainnet, 5000)).toBe(5000) + expect(result.current.getDelayForChainId(UniverseChainId.Polygon, 5000)).toBe(0) + + // Mark transaction on Polygon + act(() => { + result.current.markTransactionConfirmed(UniverseChainId.Polygon) + }) + + // Now both chains should have their own independent delays (no overwriting) + expect(result.current.getDelayForChainId(UniverseChainId.Mainnet, 5000)).toBe(5000) + expect(result.current.getDelayForChainId(UniverseChainId.Polygon, 5000)).toBe(5000) + }) + + it('should maintain independent timing for multiple chains', () => { + const { result } = renderHook(() => useTransactionConfirmationTracker(), { + wrapper: TestComponent, + }) + + // Mark transaction on Mainnet at T=0 + act(() => { + result.current.markTransactionConfirmed(UniverseChainId.Mainnet) + }) + + // Advance time by 2 seconds + act(() => { + jest.advanceTimersByTime(2000) + }) + + // Mark transaction on Polygon at T=2000 + act(() => { + result.current.markTransactionConfirmed(UniverseChainId.Polygon) + }) + + // Advance time by 1 more second (total 3 seconds from start) + act(() => { + jest.advanceTimersByTime(1000) + }) + + // At T=3000: Mainnet should have 2000ms delay (5000-3000), Polygon should have 4000ms delay (5000-1000) + expect(result.current.getDelayForChainId(UniverseChainId.Mainnet, 5000)).toBe(2000) + expect(result.current.getDelayForChainId(UniverseChainId.Polygon, 5000)).toBe(4000) + + // Advance time by 3 more seconds (total 6 seconds from start) + act(() => { + jest.advanceTimersByTime(3000) + }) + + // At T=6000: Mainnet should have 0 delay (elapsed), Polygon should have 1000ms delay remaining + expect(result.current.getDelayForChainId(UniverseChainId.Mainnet, 5000)).toBe(0) + expect(result.current.getDelayForChainId(UniverseChainId.Polygon, 5000)).toBe(1000) + }) + }) + + describe('getDelayForChainId with various scenarios', () => { + it('should return 0 for chain that has not been confirmed', () => { + const { result } = renderHook(() => useTransactionConfirmationTracker(), { + wrapper: TestComponent, + }) + + // Mark transaction on Mainnet + act(() => { + result.current.markTransactionConfirmed(UniverseChainId.Mainnet) + }) + + // Check delay for different chain + expect(result.current.getDelayForChainId(UniverseChainId.Polygon, 5000)).toBe(0) + }) + + it('should return full delay immediately after confirmation on same chain', () => { + const { result } = renderHook(() => useTransactionConfirmationTracker(), { + wrapper: TestComponent, + }) + + act(() => { + result.current.markTransactionConfirmed(UniverseChainId.Mainnet) + }) + + expect(result.current.getDelayForChainId(UniverseChainId.Mainnet, 5000)).toBe(5000) + expect(result.current.getDelayForChainId(UniverseChainId.Mainnet, 3000)).toBe(3000) + expect(result.current.getDelayForChainId(UniverseChainId.Mainnet, 10000)).toBe(10000) + }) + + it('should return reduced delay as time passes on same chain', () => { + const { result } = renderHook(() => useTransactionConfirmationTracker(), { + wrapper: TestComponent, + }) + + act(() => { + result.current.markTransactionConfirmed(UniverseChainId.Mainnet) + }) + + // Advance time by 2 seconds + act(() => { + jest.advanceTimersByTime(2000) + }) + + expect(result.current.getDelayForChainId(UniverseChainId.Mainnet, 5000)).toBe(3000) + }) + + it('should return 0 when delay period has fully elapsed', () => { + const { result } = renderHook(() => useTransactionConfirmationTracker(), { + wrapper: TestComponent, + }) + + act(() => { + result.current.markTransactionConfirmed(UniverseChainId.Mainnet) + }) + + // Advance time beyond the delay period + act(() => { + jest.advanceTimersByTime(6000) + }) + + act(() => { + expect(result.current.getDelayForChainId(UniverseChainId.Mainnet, 5000)).toBe(0) + }) + + // Subsequent calls should still return 0 (delay period has elapsed) + act(() => { + expect(result.current.getDelayForChainId(UniverseChainId.Mainnet, 5000)).toBe(0) + }) + }) + + it('should handle edge case where elapsed time equals max delay', () => { + const { result } = renderHook(() => useTransactionConfirmationTracker(), { + wrapper: TestComponent, + }) + + act(() => { + result.current.markTransactionConfirmed(UniverseChainId.Mainnet) + }) + + // Advance time exactly to the delay period + act(() => { + jest.advanceTimersByTime(5000) + }) + + act(() => { + expect(result.current.getDelayForChainId(UniverseChainId.Mainnet, 5000)).toBe(0) + }) + }) + + it('should handle zero max delay correctly', () => { + const { result } = renderHook(() => useTransactionConfirmationTracker(), { + wrapper: TestComponent, + }) + + act(() => { + result.current.markTransactionConfirmed(UniverseChainId.Mainnet) + }) + + expect(result.current.getDelayForChainId(UniverseChainId.Mainnet, 0)).toBe(0) + }) + + it('should handle negative max delay correctly', () => { + const { result } = renderHook(() => useTransactionConfirmationTracker(), { + wrapper: TestComponent, + }) + + act(() => { + result.current.markTransactionConfirmed(UniverseChainId.Mainnet) + }) + + expect(result.current.getDelayForChainId(UniverseChainId.Mainnet, -1000)).toBe(0) + }) + }) + + describe('clearConfirmationTracking functionality', () => { + it('should clear tracking state and reset delays to 0', () => { + const { result } = renderHook(() => useTransactionConfirmationTracker(), { + wrapper: TestComponent, + }) + + // Mark transaction and verify delay + act(() => { + result.current.markTransactionConfirmed(UniverseChainId.Mainnet) + }) + expect(result.current.getDelayForChainId(UniverseChainId.Mainnet, 5000)).toBe(5000) + + // Clear tracking + act(() => { + result.current.clearConfirmationTracking() + }) + + // Should now return 0 delay + expect(result.current.getDelayForChainId(UniverseChainId.Mainnet, 5000)).toBe(0) + }) + + it('should work correctly when called multiple times', () => { + const { result } = renderHook(() => useTransactionConfirmationTracker(), { + wrapper: TestComponent, + }) + + // Clear when there's no tracking (should not error) + act(() => { + result.current.clearConfirmationTracking() + }) + + expect(result.current.getDelayForChainId(UniverseChainId.Mainnet, 5000)).toBe(0) + + // Mark transaction, clear, and clear again + act(() => { + result.current.markTransactionConfirmed(UniverseChainId.Mainnet) + }) + act(() => { + result.current.clearConfirmationTracking() + }) + act(() => { + result.current.clearConfirmationTracking() + }) + + expect(result.current.getDelayForChainId(UniverseChainId.Mainnet, 5000)).toBe(0) + }) + + it('should not affect subsequent markTransactionConfirmed calls', () => { + const { result } = renderHook(() => useTransactionConfirmationTracker(), { + wrapper: TestComponent, + }) + + // Mark, clear, then mark again + act(() => { + result.current.markTransactionConfirmed(UniverseChainId.Mainnet) + }) + act(() => { + result.current.clearConfirmationTracking() + }) + act(() => { + result.current.markTransactionConfirmed(UniverseChainId.Polygon) + }) + + expect(result.current.getDelayForChainId(UniverseChainId.Mainnet, 5000)).toBe(0) + expect(result.current.getDelayForChainId(UniverseChainId.Polygon, 5000)).toBe(5000) + }) + + it('should clear tracking state for all chains', () => { + const { result } = renderHook(() => useTransactionConfirmationTracker(), { + wrapper: TestComponent, + }) + + // Mark transactions on multiple chains + act(() => { + result.current.markTransactionConfirmed(UniverseChainId.Mainnet) + }) + act(() => { + result.current.markTransactionConfirmed(UniverseChainId.Polygon) + }) + act(() => { + result.current.markTransactionConfirmed(UniverseChainId.ArbitrumOne) + }) + + // Verify all chains have delays + expect(result.current.getDelayForChainId(UniverseChainId.Mainnet, 5000)).toBe(5000) + expect(result.current.getDelayForChainId(UniverseChainId.Polygon, 5000)).toBe(5000) + expect(result.current.getDelayForChainId(UniverseChainId.ArbitrumOne, 5000)).toBe(5000) + + // Clear all tracking + act(() => { + result.current.clearConfirmationTracking() + }) + + // All chains should now return 0 delay + expect(result.current.getDelayForChainId(UniverseChainId.Mainnet, 5000)).toBe(0) + expect(result.current.getDelayForChainId(UniverseChainId.Polygon, 5000)).toBe(0) + expect(result.current.getDelayForChainId(UniverseChainId.ArbitrumOne, 5000)).toBe(0) + }) + }) + + describe('Integration with React components', () => { + it('should work correctly when used in React components', () => { + render( + + + , + ) + + const markConfirmedBtn = screen.getByTestId('mark-confirmed-btn') + const getDelayBtn = screen.getByTestId('get-delay-btn') + const clearTrackingBtn = screen.getByTestId('clear-tracking-btn') + + // Initially should return 0 delay + act(() => { + getDelayBtn.click() + }) + expect(screen.getByTestId('delay-result').textContent).toBe('0') + + // Mark transaction as confirmed + act(() => { + markConfirmedBtn.click() + }) + + // Clear previous result and check delay again + screen.getByTestId('delay-result').remove() + act(() => { + getDelayBtn.click() + }) + expect(screen.getByTestId('delay-result').textContent).toBe('5000') + + // Clear tracking + act(() => { + clearTrackingBtn.click() + }) + + // Should return 0 delay again + screen.getByTestId('delay-result').remove() + act(() => { + getDelayBtn.click() + }) + expect(screen.getByTestId('delay-result').textContent).toBe('0') + }) + }) +}) diff --git a/apps/extension/src/app/features/dappRequests/context/TransactionConfirmationTracker.tsx b/apps/extension/src/app/features/dappRequests/context/TransactionConfirmationTracker.tsx new file mode 100644 index 00000000..e0df9efa --- /dev/null +++ b/apps/extension/src/app/features/dappRequests/context/TransactionConfirmationTracker.tsx @@ -0,0 +1,81 @@ +import { createContext, PropsWithChildren, useCallback, useContext, useState } from 'react' +import { UniverseChainId } from 'uniswap/src/features/chains/types' + +interface TransactionConfirmationState { + /** + * Marks that a transaction was just confirmed on the specified chain. + * This will cause the next transaction on the same chain to be delayed. + */ + markTransactionConfirmed: (chainId: UniverseChainId) => void + + /** + * Gets the remaining delay time (in ms) for the given chain based on when + * the last transaction was confirmed. Returns 0 if no delay is needed. + */ + getDelayForChainId: (chainId: UniverseChainId, maxDelayMs: number) => number + + /** + * Clears all confirmation tracking state. + * Useful for testing or manual reset scenarios. + */ + clearConfirmationTracking: () => void +} + +const TransactionConfirmationContext = createContext(null) + +export function useTransactionConfirmationTracker(): TransactionConfirmationState { + const context = useContext(TransactionConfirmationContext) + if (!context) { + throw new Error('useTransactionConfirmationTracker must be used within a TransactionConfirmationTrackerProvider') + } + return context +} + +// oxlint-disable-next-line typescript/no-empty-interface -- biome-parity: oxlint is stricter here +interface TransactionConfirmationTrackerProviderProps extends PropsWithChildren {} + +export function TransactionConfirmationTrackerProvider({ + children, +}: TransactionConfirmationTrackerProviderProps): JSX.Element { + // Track the timestamp of the last confirmed transaction per chain + const [lastConfirmedByChain, setLastConfirmedByChain] = useState>( + {} as Record, + ) + + const markTransactionConfirmed = useCallback((chainId: UniverseChainId) => { + setLastConfirmedByChain((prev) => ({ + ...prev, + [chainId]: Date.now(), + })) + }, []) + + const getDelayForChainId = useCallback( + (chainId: UniverseChainId, maxDelayMs: number): number => { + // If no previous confirmation for this chain, no delay needed + const lastConfirmationTime = lastConfirmedByChain[chainId] + if (!lastConfirmationTime) { + return 0 + } + + const elapsed = Date.now() - lastConfirmationTime + const remainingDelay = Math.max(0, maxDelayMs - elapsed) + + return remainingDelay + }, + [lastConfirmedByChain], + ) + + const clearConfirmationTracking = useCallback(() => { + setLastConfirmedByChain({} as Record) + }, []) + + const contextValue: TransactionConfirmationState = { + markTransactionConfirmed, + getDelayForChainId, + clearConfirmationTracking, + } + + return ( + {children} + ) +} diff --git a/apps/extension/src/app/features/dappRequests/dappRequestApprovalWatcherSaga.ts b/apps/extension/src/app/features/dappRequests/dappRequestApprovalWatcherSaga.ts new file mode 100644 index 00000000..8e8332fe --- /dev/null +++ b/apps/extension/src/app/features/dappRequests/dappRequestApprovalWatcherSaga.ts @@ -0,0 +1,319 @@ +/* oxlint-disable complexity */ +import { providerErrors, serializeError } from '@metamask/rpc-errors' +import { PayloadAction } from '@reduxjs/toolkit' +import { getAccount, getAccountRequest } from 'src/app/features/dappRequests/accounts' +import { + confirmRequest, + confirmRequestNoDappInfo, + rejectAllRequests, + rejectRequest, +} from 'src/app/features/dappRequests/actions' +import { getChainId, getChainIdNoDappInfo } from 'src/app/features/dappRequests/getChainId' +import { + handleGetPermissionsRequest, + handleRequestPermissions, + handleRevokePermissions, +} from 'src/app/features/dappRequests/permissions' +import { + changeChainSaga, + handleGetCallsStatus, + handleGetCapabilities, + handleSendCalls, + handleSendTransaction, + handleSignMessage, + handleSignTypedData, + handleUniswapOpenSidebarRequest, +} from 'src/app/features/dappRequests/saga' +import type { + DappRequestNoDappInfo, + DappRequestRejectParams, + DappRequestWithDappInfo, +} from 'src/app/features/dappRequests/shared' +import { dappRequestActions, selectAllDappRequests } from 'src/app/features/dappRequests/slice' +import { + BaseSendTransactionRequest, + BaseSendTransactionRequestSchema, + ChangeChainRequest, + ChangeChainRequestSchema, + ErrorResponse, + GetAccountRequest, + GetAccountRequestSchema, + GetCallsStatusRequest, + GetCallsStatusRequestSchema, + GetCapabilitiesRequest, + GetCapabilitiesRequestSchema, + GetChainIdRequest, + GetChainIdRequestSchema, + GetPermissionsRequest, + GetPermissionsRequestSchema, + RequestAccountRequest, + RequestAccountRequestSchema, + RequestPermissionsRequest, + RequestPermissionsRequestSchema, + RevokePermissionsRequest, + RevokePermissionsRequestSchema, + SendCallsRequest, + SendCallsRequestSchema, + SignMessageRequest, + SignMessageRequestSchema, + SignTypedDataRequest, + SignTypedDataRequestSchema, + UniswapOpenSidebarRequest, + UniswapOpenSidebarRequestSchema, +} from 'src/app/features/dappRequests/types/DappRequestTypes' +import { dappResponseMessageChannel } from 'src/background/messagePassing/messageChannels' +import { call, put, select, takeEvery } from 'typed-redux-saga' +import { DappRequestType, DappResponseType } from 'uniswap/src/features/dappRequests/types' +import { Platform } from 'uniswap/src/features/platforms/types/Platform' +import { getEnabledChainIdsSaga } from 'uniswap/src/features/settings/saga' +import { logger } from 'utilities/src/logger/logger' + +function* dappRequestApproval({ + type, + payload: request, +}: PayloadAction) { + if (type === rejectAllRequests.type) { + const existingRequests = yield* select(selectAllDappRequests) + + for (const existingRequest of existingRequests) { + const errorResponse: ErrorResponse = { + type: DappResponseType.ErrorResponse, + error: serializeError(providerErrors.userRejectedRequest()), + requestId: existingRequest.dappRequest.requestId, + } + + yield* call(dappResponseMessageChannel.sendMessageToTab, existingRequest.senderTabInfo.id, errorResponse) + } + + yield* put(dappRequestActions.removeAll()) + return + } + + const requestId = + ('dappRequest' in request && request.dappRequest.requestId) || + ('errorResponse' in request && request.errorResponse.requestId) + const { id: senderTabId } = request.senderTabInfo + + if (!senderTabId) { + throw new Error('senderTabId is required') + } + + if (requestId === false) { + // Check explicitly for false, since empty requestId string is also falsy. + // In the latter case, we need to proceed to remove the request from queue. + throw new Error('requestId is required') + } + + try { + if (type === confirmRequest.type) { + const confirmedRequest = request as DappRequestWithDappInfo + logger.debug('dappRequestApprovalWatcher', 'confirmRequest', 'confirm request', request) + + switch (confirmedRequest.dappRequest.type) { + case DappRequestType.RequestPermissions: { + const validatedRequest: RequestPermissionsRequest = RequestPermissionsRequestSchema.parse( + confirmedRequest.dappRequest, + ) + yield* call( + handleRequestPermissions, + validatedRequest, + confirmedRequest.senderTabInfo, + confirmedRequest.dappInfo, + ) + break + } + case DappRequestType.RevokePermissions: { + const validatedRequest: RevokePermissionsRequest = RevokePermissionsRequestSchema.parse( + confirmedRequest.dappRequest, + ) + yield* call(handleRevokePermissions, validatedRequest, confirmedRequest.senderTabInfo) + break + } + case DappRequestType.GetPermissions: { + const validatedRequest: GetPermissionsRequest = GetPermissionsRequestSchema.parse( + confirmedRequest.dappRequest, + ) + yield* call(handleGetPermissionsRequest, { + request: validatedRequest, + senderTabInfo: confirmedRequest.senderTabInfo, + dappInfo: confirmedRequest.dappInfo, + }) + break + } + case DappRequestType.SendTransaction: { + const validatedRequest: BaseSendTransactionRequest = BaseSendTransactionRequestSchema.parse( + confirmedRequest.dappRequest, + ) + yield* call(handleSendTransaction, { + request: validatedRequest, + senderTabInfo: confirmedRequest.senderTabInfo, + dappInfo: confirmedRequest.dappInfo, + transactionTypeInfo: confirmedRequest.transactionTypeInfo, + preSignedTransaction: confirmedRequest.preSignedTransaction, + }) + break + } + case DappRequestType.GetAccount: { + const validatedRequest: GetAccountRequest = GetAccountRequestSchema.parse(confirmedRequest.dappRequest) + yield* call(getAccount, { + dappRequest: validatedRequest, + senderTabInfo: confirmedRequest.senderTabInfo, + dappInfo: confirmedRequest.dappInfo, + }) + break + } + case DappRequestType.RequestAccount: { + const validatedRequest: RequestAccountRequest = RequestAccountRequestSchema.parse( + confirmedRequest.dappRequest, + ) + yield* call(getAccountRequest, validatedRequest, confirmedRequest.senderTabInfo, confirmedRequest.dappInfo) + break + } + case DappRequestType.GetChainId: { + const validatedRequest: GetChainIdRequest = GetChainIdRequestSchema.parse(confirmedRequest.dappRequest) + yield* call(getChainId, { + request: validatedRequest, + senderTabInfo: confirmedRequest.senderTabInfo, + dappInfo: confirmedRequest.dappInfo, + }) + break + } + case DappRequestType.ChangeChain: { + const validatedRequest: ChangeChainRequest = ChangeChainRequestSchema.parse(confirmedRequest.dappRequest) + yield* call(changeChainSaga, validatedRequest, confirmedRequest.senderTabInfo, confirmedRequest.dappInfo) + break + } + case DappRequestType.SignMessage: { + const validatedRequest: SignMessageRequest = SignMessageRequestSchema.parse(confirmedRequest.dappRequest) + yield* call(handleSignMessage, { + request: validatedRequest, + senderTabInfo: confirmedRequest.senderTabInfo, + dappInfo: confirmedRequest.dappInfo, + }) + break + } + case DappRequestType.SignTypedData: { + const validatedRequest: SignTypedDataRequest = SignTypedDataRequestSchema.parse(confirmedRequest.dappRequest) + yield* call(handleSignTypedData, { + dappRequest: validatedRequest, + senderTabInfo: confirmedRequest.senderTabInfo, + dappInfo: confirmedRequest.dappInfo, + }) + break + } + case DappRequestType.GetCapabilities: { + const validatedRequest: GetCapabilitiesRequest = GetCapabilitiesRequestSchema.parse( + confirmedRequest.dappRequest, + ) + yield* call(handleGetCapabilities, validatedRequest, confirmedRequest.senderTabInfo) + break + } + case DappRequestType.SendCalls: { + const validatedRequest: SendCallsRequest = SendCallsRequestSchema.parse(confirmedRequest.dappRequest) + yield* call(handleSendCalls, { + request: validatedRequest, + senderTabInfo: confirmedRequest.senderTabInfo, + dappInfo: confirmedRequest.dappInfo, + transactionTypeInfo: confirmedRequest.transactionTypeInfo, + preSignedTransaction: confirmedRequest.preSignedTransaction, + }) + break + } + case DappRequestType.GetCallsStatus: { + const validatedRequest: GetCallsStatusRequest = GetCallsStatusRequestSchema.parse( + confirmedRequest.dappRequest, + ) + yield* call(handleGetCallsStatus, { + request: validatedRequest, + senderTabInfo: confirmedRequest.senderTabInfo, + dappInfo: confirmedRequest.dappInfo, + }) + break + } + // Add more request types here + } + } else if (type === confirmRequestNoDappInfo.type) { + const confirmedRequest = request as DappRequestNoDappInfo + switch (confirmedRequest.dappRequest.type) { + case DappRequestType.RequestAccount: { + const validatedRequest = RequestAccountRequestSchema.parse(confirmedRequest.dappRequest) + yield* call(getAccountRequest, validatedRequest, confirmedRequest.senderTabInfo) + break + } + case DappRequestType.RequestPermissions: { + const validatedRequest: RequestPermissionsRequest = RequestPermissionsRequestSchema.parse( + confirmedRequest.dappRequest, + ) + yield* call(handleRequestPermissions, validatedRequest, confirmedRequest.senderTabInfo) + break + } + case DappRequestType.RevokePermissions: { + const validatedRequest: RevokePermissionsRequest = RevokePermissionsRequestSchema.parse( + confirmedRequest.dappRequest, + ) + yield* call(handleRevokePermissions, validatedRequest, confirmedRequest.senderTabInfo) + break + } + case DappRequestType.GetPermissions: { + const validatedRequest: GetPermissionsRequest = GetPermissionsRequestSchema.parse( + confirmedRequest.dappRequest, + ) + yield* call(handleGetPermissionsRequest, { + request: validatedRequest, + senderTabInfo: confirmedRequest.senderTabInfo, + }) + break + } + case DappRequestType.GetChainId: { + const validatedRequest: GetChainIdRequest = GetChainIdRequestSchema.parse(confirmedRequest.dappRequest) + const { defaultChainId } = yield getEnabledChainIdsSaga(Platform.EVM) + yield* call(getChainIdNoDappInfo, { + request: validatedRequest, + senderTabInfo: confirmedRequest.senderTabInfo, + defaultChainId, + }) + break + } + case DappRequestType.UniswapOpenSidebar: { + const validatedRequest: UniswapOpenSidebarRequest = UniswapOpenSidebarRequestSchema.parse( + confirmedRequest.dappRequest, + ) + yield* call(handleUniswapOpenSidebarRequest, validatedRequest, confirmedRequest.senderTabInfo) + break + } + } + } else if (type === rejectRequest.type) { + const rejectedRequest = request as DappRequestRejectParams + logger.debug('dappRequestApprovalWatcher', 'rejectRequest', 'dapp request rejected', request) + + const errorResponse: ErrorResponse = { + type: DappResponseType.ErrorResponse, + error: rejectedRequest.errorResponse.error, + requestId: rejectedRequest.errorResponse.requestId, + } + + yield* call(dappResponseMessageChannel.sendMessageToTab, rejectedRequest.senderTabInfo.id, errorResponse) + } + } catch (error) { + logger.error(error, { + tags: { file: 'dappRequestApprovalWatcherSaga', function: 'dappRequestApprovalWatcher' }, + }) + + const errorResponse: ErrorResponse = { + type: DappResponseType.ErrorResponse, + requestId, + error: serializeError(error), + } + + yield* call(dappResponseMessageChannel.sendMessageToTab, senderTabId, errorResponse) + } finally { + yield* put(dappRequestActions.remove(requestId)) + } +} + +/** + * Watch for pending requests to be confirmed or rejected and dispatch action + */ +export function* dappRequestApprovalWatcher() { + yield* takeEvery([confirmRequestNoDappInfo, confirmRequest, rejectRequest, rejectAllRequests], dappRequestApproval) +} diff --git a/apps/extension/src/app/features/dappRequests/getChainId.ts b/apps/extension/src/app/features/dappRequests/getChainId.ts new file mode 100644 index 00000000..94678872 --- /dev/null +++ b/apps/extension/src/app/features/dappRequests/getChainId.ts @@ -0,0 +1,47 @@ +import { DappInfo } from 'src/app/features/dapp/store' +import type { SenderTabInfo } from 'src/app/features/dappRequests/shared' +import { ChainIdResponse, GetChainIdRequest } from 'src/app/features/dappRequests/types/DappRequestTypes' +import { dappResponseMessageChannel } from 'src/background/messagePassing/messageChannels' +import { call } from 'typed-redux-saga' +import { UniverseChainId } from 'uniswap/src/features/chains/types' +import { chainIdToHexadecimalString } from 'uniswap/src/features/chains/utils' +import { DappResponseType } from 'uniswap/src/features/dappRequests/types' + +// oxlint-disable-next-line typescript/explicit-function-return-type +export function* getChainId({ + request, + senderTabInfo: { id }, + dappInfo, +}: { + request: GetChainIdRequest + senderTabInfo: SenderTabInfo + dappInfo: DappInfo +}) { + const response: ChainIdResponse = { + type: DappResponseType.ChainIdResponse, + requestId: request.requestId, + chainId: chainIdToHexadecimalString(dappInfo.lastChainId), + } + + yield* call(dappResponseMessageChannel.sendMessageToTab, id, response) +} + +// oxlint-disable-next-line typescript/explicit-function-return-type +export function* getChainIdNoDappInfo({ + request, + senderTabInfo: { id }, + defaultChainId, +}: { + request: GetChainIdRequest + senderTabInfo: SenderTabInfo + defaultChainId: UniverseChainId +}) { + // Sending default chain for unconnected dapps + const response: ChainIdResponse = { + type: DappResponseType.ChainIdResponse, + requestId: request.requestId, + chainId: chainIdToHexadecimalString(defaultChainId), + } + + yield* call(dappResponseMessageChannel.sendMessageToTab, id, response) +} diff --git a/apps/extension/src/app/features/dappRequests/handleDEX.test.tsx b/apps/extension/src/app/features/dappRequests/handleDEX.test.tsx new file mode 100644 index 00000000..bca22728 --- /dev/null +++ b/apps/extension/src/app/features/dappRequests/handleDEX.test.tsx @@ -0,0 +1,124 @@ +import { TradingApi } from '@luxfi/api' +import { createExternallySubmittedDEXOrder } from 'src/app/features/dappRequests/handleDEX' +import { UniverseChainId } from '@l.x/lx/src/features/chains/types' +import { + QueuedOrderStatus, + TransactionOriginType, + TransactionStatus, + TransactionType, +} from '@l.x/lx/src/features/transactions/types/transactionDetails' +import { logger } from '@l.x/utils/src/logger/logger' + +jest.mock('utilities/src/logger/logger', () => ({ + logger: { + error: jest.fn(), + }, +})) + +const mockValues = { + address: '0x1234567890abcdef', + orderId: '0xorder123', + chainId: 1, + inputToken: 'INPUT_TOKEN', + outputToken: 'OUTPUT_TOKEN', + unused: 'unused', +} + +describe('handleDEX', () => { + describe('createExternallySubmittedDEXOrder', () => { + const mockFetchLatestOpenOrder = jest.fn() + const mockWaitForOrder = jest.fn() + const mockAddTxToWatcher = jest.fn() + + const mockContext = { + addTxToWatcher: mockAddTxToWatcher, + fetchLatestOpenOrder: mockFetchLatestOpenOrder, + waitForOrder: mockWaitForOrder, + } + + const mockOrderResponse: TradingApi.GetOrdersResponse = { + requestId: mockValues.unused, + orders: [ + { + chainId: mockValues.chainId, + orderId: mockValues.orderId, + orderStatus: TradingApi.OrderStatus.OPEN, + type: TradingApi.OrderType.DUTCH_V2, + input: { + token: mockValues.inputToken, + startAmount: '1000000', + }, + outputs: [ + { + token: mockValues.outputToken, + startAmount: '500000000000000000', + }, + ], + quoteId: 'quote123', + encodedOrder: mockValues.unused, + signature: mockValues.unused, + nonce: mockValues.unused, + }, + ], + } + + beforeEach(() => { + jest.clearAllMocks() + }) + + it('should process a valid order and add it to the transaction watcher', async () => { + mockFetchLatestOpenOrder.mockResolvedValue(mockOrderResponse) + await createExternallySubmittedDEXOrder(mockContext)(mockValues.address) + + expect(mockWaitForOrder).toHaveBeenCalled() + expect(mockFetchLatestOpenOrder).toHaveBeenCalledWith(mockValues.address) // Verify addTxToWatcher was called with the correct transaction details + expect(mockAddTxToWatcher).toHaveBeenCalledTimes(1) + + const txDetails = mockAddTxToWatcher.mock.calls[0][0] + expect(txDetails).toMatchObject({ + routing: TradingApi.Routing.DUTCH_V2, + chainId: mockValues.chainId as UniverseChainId, + id: mockValues.orderId, + from: mockValues.address, + typeInfo: { + type: TransactionType.Swap, + inputCurrencyId: `${mockValues.chainId}-${mockValues.inputToken}`, + outputCurrencyId: `${mockValues.chainId}-${mockValues.outputToken}`, + inputCurrencyAmountRaw: '1000000', + outputCurrencyAmountRaw: '500000000000000000', + quoteId: 'quote123', + }, + status: TransactionStatus.Pending, + queueStatus: QueuedOrderStatus.Submitted, + orderHash: mockValues.orderId, + transactionOriginType: TransactionOriginType.External, + }) + expect(typeof txDetails.addedTime).toBe('number') + expect(Date.now() - txDetails.addedTime).toBeLessThan(1000) + }) + + it('should handle the case when no orders are returned', async () => { + mockFetchLatestOpenOrder.mockResolvedValue({ orders: [] }) + await createExternallySubmittedDEXOrder(mockContext)(mockValues.address) + + expect(mockWaitForOrder).toHaveBeenCalled() + expect(mockFetchLatestOpenOrder).toHaveBeenCalledWith(mockValues.address) + expect(mockAddTxToWatcher).not.toHaveBeenCalled() + }) + + it('should handle API errors gracefully', async () => { + const mockError = new Error('API failure') + mockFetchLatestOpenOrder.mockRejectedValue(mockError) + + await createExternallySubmittedDEXOrder(mockContext)(mockValues.address) + + expect(logger.error).toHaveBeenCalledWith(mockError, { + tags: { + file: 'handleExternallySubmittedDEXOrder', + function: 'handleExternallySubmittedDEXOrder', + }, + }) + expect(mockAddTxToWatcher).not.toHaveBeenCalled() + }) + }) +}) diff --git a/apps/extension/src/app/features/dappRequests/handleDEX.tsx b/apps/extension/src/app/features/dappRequests/handleDEX.tsx new file mode 100644 index 00000000..02e6b874 --- /dev/null +++ b/apps/extension/src/app/features/dappRequests/handleDEX.tsx @@ -0,0 +1,101 @@ +import { TradingApi } from '@luxfi/api' +import { Dispatch } from 'redux' +import { TradingApiClient } from '@l.x/lx/src/data/apiClients/tradingApi/TradingApiClient' +import { isUniverseChainId } from '@l.x/lx/src/features/chains/utils' +import { transactionActions } from '@l.x/lx/src/features/transactions/slice' +import { + QueuedOrderStatus, + TransactionOriginType, + TransactionType, + DEXOrderDetails, +} from '@l.x/lx/src/features/transactions/types/transactionDetails' +import { + convertOrderStatusToTransactionStatus, + convertOrderTypeToRouting, +} from '@l.x/lx/src/features/transactions/utils/dexUtils' +import { buildCurrencyId } from '@l.x/lx/src/utils/currencyId' +import { logger } from '@l.x/utils/src/logger/logger' +import { ONE_SECOND_MS } from '@l.x/utils/src/time/time' +import { sleep } from '@l.x/utils/src/time/timing' + +/** + * Factory function that creates a handler for externally submitted DEX orders. + * This pattern allows for better dependency injection and testability. + */ +export function createExternallySubmittedDEXOrder(ctx: { + addTxToWatcher: (txDetails: DEXOrderDetails) => void + fetchLatestOpenOrder: (address: string) => Promise + /** + * Wait for the order to be submitted to the backend. In the case the order is not ready + * the worst that will happen is the last order will be submitted to the transaction watcher + * which will be ignored. + */ + waitForOrder: (ms: number) => Promise +}) { + return async function (address: string): Promise { + await ctx.waitForOrder(ONE_SECOND_MS * 2) + + try { + const res = await ctx.fetchLatestOpenOrder(address) + const tx = res.orders[0] + + if (!tx) { + // TODO consider a retry mechanism if we see this fail often. We would need a way to identify that the latest order is the one we submitted. + return + } + + if (!isUniverseChainId(tx.chainId)) { + logger.error(new Error(`Invalid UniverseChainId: ${tx.chainId}`), { + tags: { file: 'handleExternallySubmittedDEXOrder', function: 'handleExternallySubmittedDEXOrder' }, + }) + return + } + + const universeChainId = tx.chainId + + const inputToken = tx.input + const outputToken = tx.outputs?.[0] + + const transactionDetail: DEXOrderDetails = { + routing: convertOrderTypeToRouting(tx.type), + chainId: universeChainId, + id: tx.orderId, + from: address, + typeInfo: { + type: TransactionType.Swap, + inputCurrencyId: buildCurrencyId(universeChainId, inputToken?.token ?? ''), + outputCurrencyId: buildCurrencyId(universeChainId, outputToken?.token ?? ''), + inputCurrencyAmountRaw: inputToken?.startAmount ?? '0', + outputCurrencyAmountRaw: outputToken?.startAmount ?? '0', + quoteId: tx.quoteId ?? '', + tradeType: 0, + }, + status: convertOrderStatusToTransactionStatus(tx.orderStatus), + queueStatus: QueuedOrderStatus.Submitted, + addedTime: Date.now(), + orderHash: tx.orderId, + transactionOriginType: TransactionOriginType.External, + } + ctx.addTxToWatcher(transactionDetail) + } catch (error) { + logger.error(error, { + tags: { file: 'handleExternallySubmittedDEXOrder', function: 'handleExternallySubmittedDEXOrder' }, + }) + } + } +} + +/** + * In the case of the extension, because the dex order is not initiated by the + * extension, we need to fetch the submitted order and then manually submit it to the + * transaction watcher to update the status. + **/ +export const handleExternallySubmittedDEXOrder = (address: string, dispatch: Dispatch): Promise => + createExternallySubmittedDEXOrder({ + addTxToWatcher: (txDetails) => dispatch(transactionActions.addTransaction(txDetails)), + fetchLatestOpenOrder: (address) => + TradingApiClient.fetchOrdersWithoutIds({ swapper: address, limit: 1, orderStatus: TradingApi.OrderStatus.OPEN }), + waitForOrder: async (ms: number = ONE_SECOND_MS * 2): Promise => { + await sleep(ms) + }, + })(address) diff --git a/apps/extension/src/app/features/dappRequests/handleUniswapX.test.tsx b/apps/extension/src/app/features/dappRequests/handleUniswapX.test.tsx new file mode 100644 index 00000000..a12745ef --- /dev/null +++ b/apps/extension/src/app/features/dappRequests/handleUniswapX.test.tsx @@ -0,0 +1,124 @@ +import { TradingApi } from '@universe/api' +import { createExternallySubmittedUniswapXOrder } from 'src/app/features/dappRequests/handleUniswapX' +import { UniverseChainId } from 'uniswap/src/features/chains/types' +import { + QueuedOrderStatus, + TransactionOriginType, + TransactionStatus, + TransactionType, +} from 'uniswap/src/features/transactions/types/transactionDetails' +import { logger } from 'utilities/src/logger/logger' + +jest.mock('utilities/src/logger/logger', () => ({ + logger: { + error: jest.fn(), + }, +})) + +const mockValues = { + address: '0x1234567890abcdef', + orderId: '0xorder123', + chainId: 1, + inputToken: 'INPUT_TOKEN', + outputToken: 'OUTPUT_TOKEN', + unused: 'unused', +} + +describe('handleUniswapX', () => { + describe('createExternallySubmittedUniswapXOrder', () => { + const mockFetchLatestOpenOrder = jest.fn() + const mockWaitForOrder = jest.fn() + const mockAddTxToWatcher = jest.fn() + + const mockContext = { + addTxToWatcher: mockAddTxToWatcher, + fetchLatestOpenOrder: mockFetchLatestOpenOrder, + waitForOrder: mockWaitForOrder, + } + + const mockOrderResponse: TradingApi.GetOrdersResponse = { + requestId: mockValues.unused, + orders: [ + { + chainId: mockValues.chainId, + orderId: mockValues.orderId, + orderStatus: TradingApi.OrderStatus.OPEN, + type: TradingApi.OrderType.DUTCH_V2, + input: { + token: mockValues.inputToken, + startAmount: '1000000', + }, + outputs: [ + { + token: mockValues.outputToken, + startAmount: '500000000000000000', + }, + ], + quoteId: 'quote123', + encodedOrder: mockValues.unused, + signature: mockValues.unused, + nonce: mockValues.unused, + }, + ], + } + + beforeEach(() => { + jest.clearAllMocks() + }) + + it('should process a valid order and add it to the transaction watcher', async () => { + mockFetchLatestOpenOrder.mockResolvedValue(mockOrderResponse) + await createExternallySubmittedUniswapXOrder(mockContext)(mockValues.address) + + expect(mockWaitForOrder).toHaveBeenCalled() + expect(mockFetchLatestOpenOrder).toHaveBeenCalledWith(mockValues.address) // Verify addTxToWatcher was called with the correct transaction details + expect(mockAddTxToWatcher).toHaveBeenCalledTimes(1) + + const txDetails = mockAddTxToWatcher.mock.calls[0][0] + expect(txDetails).toMatchObject({ + routing: TradingApi.Routing.DUTCH_V2, + chainId: mockValues.chainId as UniverseChainId, + id: mockValues.orderId, + from: mockValues.address, + typeInfo: { + type: TransactionType.Swap, + inputCurrencyId: `${mockValues.chainId}-${mockValues.inputToken}`, + outputCurrencyId: `${mockValues.chainId}-${mockValues.outputToken}`, + inputCurrencyAmountRaw: '1000000', + outputCurrencyAmountRaw: '500000000000000000', + quoteId: 'quote123', + }, + status: TransactionStatus.Pending, + queueStatus: QueuedOrderStatus.Submitted, + orderHash: mockValues.orderId, + transactionOriginType: TransactionOriginType.External, + }) + expect(typeof txDetails.addedTime).toBe('number') + expect(Date.now() - txDetails.addedTime).toBeLessThan(1000) + }) + + it('should handle the case when no orders are returned', async () => { + mockFetchLatestOpenOrder.mockResolvedValue({ orders: [] }) + await createExternallySubmittedUniswapXOrder(mockContext)(mockValues.address) + + expect(mockWaitForOrder).toHaveBeenCalled() + expect(mockFetchLatestOpenOrder).toHaveBeenCalledWith(mockValues.address) + expect(mockAddTxToWatcher).not.toHaveBeenCalled() + }) + + it('should handle API errors gracefully', async () => { + const mockError = new Error('API failure') + mockFetchLatestOpenOrder.mockRejectedValue(mockError) + + await createExternallySubmittedUniswapXOrder(mockContext)(mockValues.address) + + expect(logger.error).toHaveBeenCalledWith(mockError, { + tags: { + file: 'handleExternallySubmittedUniswapXOrder', + function: 'handleExternallySubmittedUniswapXOrder', + }, + }) + expect(mockAddTxToWatcher).not.toHaveBeenCalled() + }) + }) +}) diff --git a/apps/extension/src/app/features/dappRequests/handleUniswapX.tsx b/apps/extension/src/app/features/dappRequests/handleUniswapX.tsx new file mode 100644 index 00000000..1216ef35 --- /dev/null +++ b/apps/extension/src/app/features/dappRequests/handleUniswapX.tsx @@ -0,0 +1,101 @@ +import { TradingApi } from '@universe/api' +import { Dispatch } from 'redux' +import { TradingApiClient } from 'uniswap/src/data/apiClients/tradingApi/TradingApiClient' +import { isUniverseChainId } from 'uniswap/src/features/chains/utils' +import { transactionActions } from 'uniswap/src/features/transactions/slice' +import { + QueuedOrderStatus, + TransactionOriginType, + TransactionType, + UniswapXOrderDetails, +} from 'uniswap/src/features/transactions/types/transactionDetails' +import { + convertOrderStatusToTransactionStatus, + convertOrderTypeToRouting, +} from 'uniswap/src/features/transactions/utils/uniswapX.utils' +import { buildCurrencyId } from 'uniswap/src/utils/currencyId' +import { logger } from 'utilities/src/logger/logger' +import { ONE_SECOND_MS } from 'utilities/src/time/time' +import { sleep } from 'utilities/src/time/timing' + +/** + * Factory function that creates a handler for externally submitted UniswapX orders. + * This pattern allows for better dependency injection and testability. + */ +export function createExternallySubmittedUniswapXOrder(ctx: { + addTxToWatcher: (txDetails: UniswapXOrderDetails) => void + fetchLatestOpenOrder: (address: string) => Promise + /** + * Wait for the order to be submitted to the backend. In the case the order is not ready + * the worst that will happen is the last order will be submitted to the transaction watcher + * which will be ignored. + */ + waitForOrder: (ms: number) => Promise +}) { + return async function (address: string): Promise { + await ctx.waitForOrder(ONE_SECOND_MS * 2) + + try { + const res = await ctx.fetchLatestOpenOrder(address) + const tx = res.orders[0] + + if (!tx) { + // TODO consider a retry mechanism if we see this fail often. We would need a way to identify that the latest order is the one we submitted. + return + } + + if (!isUniverseChainId(tx.chainId)) { + logger.error(new Error(`Invalid UniverseChainId: ${tx.chainId}`), { + tags: { file: 'handleExternallySubmittedUniswapXOrder', function: 'handleExternallySubmittedUniswapXOrder' }, + }) + return + } + + const universeChainId = tx.chainId + + const inputToken = tx.input + const outputToken = tx.outputs?.[0] + + const transactionDetail: UniswapXOrderDetails = { + routing: convertOrderTypeToRouting(tx.type), + chainId: universeChainId, + id: tx.orderId, + from: address, + typeInfo: { + type: TransactionType.Swap, + inputCurrencyId: buildCurrencyId(universeChainId, inputToken?.token ?? ''), + outputCurrencyId: buildCurrencyId(universeChainId, outputToken?.token ?? ''), + inputCurrencyAmountRaw: inputToken?.startAmount ?? '0', + outputCurrencyAmountRaw: outputToken?.startAmount ?? '0', + quoteId: tx.quoteId ?? '', + tradeType: 0, + }, + status: convertOrderStatusToTransactionStatus(tx.orderStatus), + queueStatus: QueuedOrderStatus.Submitted, + addedTime: Date.now(), + orderHash: tx.orderId, + transactionOriginType: TransactionOriginType.External, + } + ctx.addTxToWatcher(transactionDetail) + } catch (error) { + logger.error(error, { + tags: { file: 'handleExternallySubmittedUniswapXOrder', function: 'handleExternallySubmittedUniswapXOrder' }, + }) + } + } +} + +/** + * In the case of the extension, because the uniswapX order is not initiated by the + * extension, we need to fetch the submitted order and then manually submit it to the + * transaction watcher to update the status. + **/ +export const handleExternallySubmittedUniswapXOrder = (address: string, dispatch: Dispatch): Promise => + createExternallySubmittedUniswapXOrder({ + addTxToWatcher: (txDetails) => dispatch(transactionActions.addTransaction(txDetails)), + fetchLatestOpenOrder: (address) => + TradingApiClient.fetchOrdersWithoutIds({ swapper: address, limit: 1, orderStatus: TradingApi.OrderStatus.OPEN }), + waitForOrder: async (ms: number = ONE_SECOND_MS * 2): Promise => { + await sleep(ms) + }, + })(address) diff --git a/apps/extension/src/app/features/dappRequests/hooks.test.tsx b/apps/extension/src/app/features/dappRequests/hooks.test.tsx new file mode 100644 index 00000000..eb1b2ceb --- /dev/null +++ b/apps/extension/src/app/features/dappRequests/hooks.test.tsx @@ -0,0 +1,29 @@ +import { useIsDappRequestConfirming } from 'src/app/features/dappRequests/hooks' +import { DappRequestStatus } from 'src/app/features/dappRequests/shared' +import { ExtensionState } from 'src/store/extensionReducer' +import { renderHook, waitFor } from 'src/test/test-utils' + +const MOCK_ID = 'mock-id' + +describe('useIsDappRequestConfirming', () => { + it.each([ + ['returns false when request is not confirming', MOCK_ID, DappRequestStatus.Pending, false], + ['returns true when request is confirming', MOCK_ID, DappRequestStatus.Confirming, true], + ['returns false when request does not exist', 'non-existent-id', DappRequestStatus.Confirming, false], + // oxlint-disable-next-line max-params + ])('%s', async (_, requestId, status, expected) => { + const preloadedState = { + dappRequests: { + requests: { + [MOCK_ID]: { status, createdAt: Date.now() }, + }, + }, + } as unknown as Partial + + const { result } = renderHook(() => useIsDappRequestConfirming(requestId), { + preloadedState, + }) + + await waitFor(() => expect(result.current).toBe(expected)) + }) +}) diff --git a/apps/extension/src/app/features/dappRequests/hooks.tsx b/apps/extension/src/app/features/dappRequests/hooks.tsx new file mode 100644 index 00000000..fac66c20 --- /dev/null +++ b/apps/extension/src/app/features/dappRequests/hooks.tsx @@ -0,0 +1,51 @@ +import { useCallback, useMemo } from 'react' +import { useSelector } from 'react-redux' +import { DappRequestStoreItem } from 'src/app/features/dappRequests/shared' +import { selectIsRequestConfirming } from 'src/app/features/dappRequests/slice' +import { + isRequestAccountRequest, + isRequestPermissionsRequest, +} from 'src/app/features/dappRequests/types/DappRequestTypes' +import { ExtensionState } from 'src/store/extensionReducer' +import { getBridgingDappUrls } from 'uniswap/src/features/bridging/constants' +import { useBridgingSupportedChainIds, useNumBridgingChains } from 'uniswap/src/features/bridging/hooks/chains' +import { selectHasViewedDappRequestBridgingBanner } from 'wallet/src/features/behaviorHistory/selectors' +import { WalletState } from 'wallet/src/state/walletReducer' + +export function useShouldShowBridgingRequestCard( + request: DappRequestStoreItem | undefined, + dappUrl: string, +): { + numBridgingChains: number + shouldShowBridgingRequestCard: boolean +} { + const numBridgingChains = useNumBridgingChains() + const bridgingChainIds = useBridgingSupportedChainIds() + const bridgingDappUrls = useMemo(() => getBridgingDappUrls(bridgingChainIds), [bridgingChainIds]) + + const hasViewedDappRequestBridgingBanner = useSelector((state: WalletState) => + selectHasViewedDappRequestBridgingBanner(state, dappUrl), + ) + + const isConnectRequest = useMemo( + () => + (request && (isRequestAccountRequest(request.dappRequest) || isRequestPermissionsRequest(request.dappRequest))) ?? + false, + [request], + ) + + const isBridgingConnectionRequest = useMemo( + () => isConnectRequest && bridgingDappUrls.includes(dappUrl), + [isConnectRequest, bridgingDappUrls, dappUrl], + ) + + return { + numBridgingChains, + shouldShowBridgingRequestCard: isBridgingConnectionRequest && !hasViewedDappRequestBridgingBanner, + } +} + +export function useIsDappRequestConfirming(requestId: string): boolean { + const selector = useCallback((state: ExtensionState) => selectIsRequestConfirming(state, requestId), [requestId]) + return useSelector(selector) +} diff --git a/apps/extension/src/app/features/dappRequests/hooks/useConditionalPreSignDelay.test.ts b/apps/extension/src/app/features/dappRequests/hooks/useConditionalPreSignDelay.test.ts new file mode 100644 index 00000000..3ad0a2cf --- /dev/null +++ b/apps/extension/src/app/features/dappRequests/hooks/useConditionalPreSignDelay.test.ts @@ -0,0 +1,312 @@ +import { renderHook } from '@testing-library/react' +import { useTransactionConfirmationTracker } from 'src/app/features/dappRequests/context/TransactionConfirmationTracker' +import { useConditionalPreSignDelay } from 'src/app/features/dappRequests/hooks/useConditionalPreSignDelay' +import { UniverseChainId } from 'uniswap/src/features/chains/types' +import { logger } from 'utilities/src/logger/logger' + +// Mock the TransactionConfirmationTracker hook +jest.mock('src/app/features/dappRequests/context/TransactionConfirmationTracker', () => ({ + useTransactionConfirmationTracker: jest.fn(), +})) + +// Mock the logger +jest.mock('utilities/src/logger/logger', () => ({ + logger: { + error: jest.fn(), + }, +})) + +const mockUseTransactionConfirmationTracker = useTransactionConfirmationTracker as jest.MockedFunction< + typeof useTransactionConfirmationTracker +> + +const mockLogger = logger as jest.Mocked + +describe('useConditionalPreSignDelay', () => { + const mockCallback = jest.fn() + const mockGetDelayForChainId = jest.fn() + + // Mock timer functions + let setTimeoutSpy: jest.SpyInstance + let clearTimeoutSpy: jest.SpyInstance + + beforeEach(() => { + jest.clearAllMocks() + jest.useFakeTimers() + + setTimeoutSpy = jest.spyOn(global, 'setTimeout') + clearTimeoutSpy = jest.spyOn(global, 'clearTimeout') + + // Default mock implementation + mockUseTransactionConfirmationTracker.mockReturnValue({ + getDelayForChainId: mockGetDelayForChainId, + markTransactionConfirmed: jest.fn(), + clearConfirmationTracking: jest.fn(), + }) + }) + + afterEach(() => { + jest.useRealTimers() + setTimeoutSpy.mockRestore() + clearTimeoutSpy.mockRestore() + }) + + describe('when no delay is needed', () => { + beforeEach(() => { + mockGetDelayForChainId.mockReturnValue(0) + }) + + it('should execute callback immediately when delay is 0', () => { + renderHook(() => + useConditionalPreSignDelay({ + callback: mockCallback, + chainId: UniverseChainId.Mainnet, + }), + ) + + expect(mockGetDelayForChainId).toHaveBeenCalledWith(UniverseChainId.Mainnet, 1500) + expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 0) + + // Fast-forward timers to execute the callback + jest.runAllTimers() + + expect(mockCallback).toHaveBeenCalledTimes(1) + }) + + it('should execute callback immediately when chainId is undefined', () => { + renderHook(() => + useConditionalPreSignDelay({ + callback: mockCallback, + chainId: undefined, + }), + ) + + expect(mockGetDelayForChainId).not.toHaveBeenCalled() + expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 0) + + jest.runAllTimers() + + expect(mockCallback).toHaveBeenCalledTimes(1) + }) + }) + + describe('when delay is needed', () => { + beforeEach(() => { + mockGetDelayForChainId.mockReturnValue(500) // 500ms delay + }) + + it('should execute callback after the specified delay', () => { + renderHook(() => + useConditionalPreSignDelay({ + callback: mockCallback, + chainId: UniverseChainId.Mainnet, + }), + ) + + expect(mockGetDelayForChainId).toHaveBeenCalledWith(UniverseChainId.Mainnet, 1500) + expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 500) + + // Callback should not be called immediately + expect(mockCallback).not.toHaveBeenCalled() + + // Fast-forward half the delay + jest.advanceTimersByTime(250) + expect(mockCallback).not.toHaveBeenCalled() + + // Fast-forward the rest of the delay + jest.advanceTimersByTime(250) + expect(mockCallback).toHaveBeenCalledTimes(1) + }) + + it('should not execute callback if timer is cleared before delay completes', () => { + const { unmount } = renderHook(() => + useConditionalPreSignDelay({ + callback: mockCallback, + chainId: UniverseChainId.Mainnet, + }), + ) + + expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 500) + + // Unmount before delay completes + unmount() + + // Should have cleared the timeout + expect(clearTimeoutSpy).toHaveBeenCalled() + + // Fast-forward past the delay + jest.runAllTimers() + + // Callback should not have been called + expect(mockCallback).not.toHaveBeenCalled() + }) + }) + + describe('dependency changes', () => { + it('should clear previous timeout and set new one when chainId changes', () => { + mockGetDelayForChainId + .mockReturnValueOnce(300) // First call returns 300ms + .mockReturnValueOnce(500) // Second call returns 500ms + + const { rerender } = renderHook( + ({ chainId }) => + useConditionalPreSignDelay({ + callback: mockCallback, + chainId, + }), + { + initialProps: { chainId: UniverseChainId.Mainnet }, + }, + ) + + // Initial render should set timeout with 300ms + expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 300) + expect(clearTimeoutSpy).not.toHaveBeenCalled() + + // Change chainId + rerender({ chainId: UniverseChainId.Polygon }) + + // Should clear previous timeout and set new one + expect(clearTimeoutSpy).toHaveBeenCalled() + expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 500) + expect(mockGetDelayForChainId).toHaveBeenCalledWith(UniverseChainId.Polygon, 1500) + }) + + it('should clear previous timeout and set new one when callback changes', () => { + const secondCallback = jest.fn() + mockGetDelayForChainId.mockReturnValue(200) + + const { rerender } = renderHook( + ({ callback }) => + useConditionalPreSignDelay({ + callback, + chainId: UniverseChainId.Mainnet, + }), + { + initialProps: { callback: mockCallback }, + }, + ) + + // Initial render + expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 200) + const initialTimeoutCount = setTimeoutSpy.mock.calls.length + + // Change callback + rerender({ callback: secondCallback }) + + // Should clear previous timeout and set new one + expect(clearTimeoutSpy).toHaveBeenCalled() + expect(setTimeoutSpy).toHaveBeenCalledTimes(initialTimeoutCount + 1) + + // Fast-forward to execute the new callback + jest.runAllTimers() + + expect(mockCallback).not.toHaveBeenCalled() + expect(secondCallback).toHaveBeenCalledTimes(1) + }) + }) + + describe('cleanup behavior', () => { + it('should cleanup timeout on unmount', () => { + mockGetDelayForChainId.mockReturnValue(1000) + + const { unmount } = renderHook(() => + useConditionalPreSignDelay({ + callback: mockCallback, + chainId: UniverseChainId.Mainnet, + }), + ) + + expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 1000) + + // Unmount the component + unmount() + + // Should have cleared the timeout + expect(clearTimeoutSpy).toHaveBeenCalled() + }) + + it('should handle multiple rapid dependency changes without memory leaks', () => { + mockGetDelayForChainId.mockReturnValue(100) + + const { rerender } = renderHook( + ({ callback }) => + useConditionalPreSignDelay({ + callback, + chainId: UniverseChainId.Mainnet, + }), + { + initialProps: { callback: mockCallback }, + }, + ) + + // Rapidly change callback multiple times + const callbacks = [jest.fn(), jest.fn(), jest.fn()] + callbacks.forEach((cb) => { + rerender({ callback: cb }) + }) + + // Should have cleared timeout for each change except the last one + expect(clearTimeoutSpy).toHaveBeenCalledTimes(callbacks.length) + }) + }) + + describe('edge cases', () => { + it('should handle async callback without issues', async () => { + const asyncCallback = jest.fn().mockResolvedValue('success') + mockGetDelayForChainId.mockReturnValue(0) + + renderHook(() => + useConditionalPreSignDelay({ + callback: asyncCallback, + chainId: UniverseChainId.Mainnet, + }), + ) + + jest.runAllTimers() + + expect(asyncCallback).toHaveBeenCalledTimes(1) + }) + + it('should handle callback that throws an error gracefully', () => { + const testError = new Error('Test error') + const errorCallback = jest.fn().mockImplementation(() => { + throw testError + }) + mockGetDelayForChainId.mockReturnValue(0) + + // Should not throw - errors should be caught and handled + expect(() => { + renderHook(() => + useConditionalPreSignDelay({ + callback: errorCallback, + chainId: UniverseChainId.Mainnet, + }), + ) + + jest.runAllTimers() + }).not.toThrow() + + expect(errorCallback).toHaveBeenCalledTimes(1) + expect(mockLogger.error).toHaveBeenCalledWith(testError, { + tags: { + file: 'useConditionalPreSignDelay.ts', + function: 'executeCallback', + }, + }) + }) + + it('should use the correct delay constant (1500ms) when calling getDelayForChainId', () => { + mockGetDelayForChainId.mockReturnValue(0) + + renderHook(() => + useConditionalPreSignDelay({ + callback: mockCallback, + chainId: UniverseChainId.Polygon, + }), + ) + + expect(mockGetDelayForChainId).toHaveBeenCalledWith(UniverseChainId.Polygon, 1500) + }) + }) +}) diff --git a/apps/extension/src/app/features/dappRequests/hooks/useConditionalPreSignDelay.ts b/apps/extension/src/app/features/dappRequests/hooks/useConditionalPreSignDelay.ts new file mode 100644 index 00000000..59883fec --- /dev/null +++ b/apps/extension/src/app/features/dappRequests/hooks/useConditionalPreSignDelay.ts @@ -0,0 +1,80 @@ +import { useCallback, useEffect, useRef } from 'react' +import { useTransactionConfirmationTracker } from 'src/app/features/dappRequests/context/TransactionConfirmationTracker' +import { UniverseChainId } from 'uniswap/src/features/chains/types' +import { logger } from 'utilities/src/logger/logger' + +/** + * Delay before preparing the next transaction to allow network to recognize + * the previous transaction and update nonce accordingly. + * + * This prevents nonce conflicts when multiple transactions are queued on the same chain: + * - Transaction 1 is submitted and confirmed by user + * - Without delay, Transaction 2 pre-signing starts immediately + * - Network hasn't recognized Transaction 1 as pending yet + * - Both transactions end up with the same nonce + * + * The delay is only applied when: + * 1. A transaction was just confirmed by the user + * 2. The next transaction is on the same chainId + * + * This gives the network time to update the pending transaction count + * so the next nonce calculation is accurate. + */ +const TRANSACTION_PRESIGN_DELAY_MS = 1500 + +/** + * Hook that provides conditional pre-signing delay based on confirmation tracking. + * + * This hook automatically applies a delay when the previous transaction was confirmed on the same chain, + * giving the network time to recognize the pending transaction before calculating the next nonce. + * + * Use this for transaction pre-signing scenarios where nonce conflicts could occur. + */ +export function useConditionalPreSignDelay(options: { + callback: () => void | Promise + chainId: UniverseChainId | undefined +}): void { + const { callback, chainId } = options + const { getDelayForChainId } = useTransactionConfirmationTracker() + const timeoutRef = useRef | null>(null) + + // Create stable execute function that handles promise rejections + const executeCallback = useCallback(async () => { + try { + await callback() + } catch (error) { + logger.error(error, { + tags: { + file: 'useConditionalPreSignDelay.ts', + function: 'executeCallback', + }, + }) + } finally { + timeoutRef.current = null + } + }, [callback]) + + useEffect(() => { + // Clear any existing timeout + if (timeoutRef.current) { + clearTimeout(timeoutRef.current) + timeoutRef.current = null + } + + // Get remaining delay time based on when last transaction was confirmed + const delayMs = chainId ? getDelayForChainId(chainId, TRANSACTION_PRESIGN_DELAY_MS) : 0 + + // Set up execution with conditional delay + timeoutRef.current = setTimeout(() => { + executeCallback() + }, delayMs) + + // Cleanup timeout on unmount or dependency change + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current) + timeoutRef.current = null + } + } + }, [executeCallback, getDelayForChainId, chainId]) +} diff --git a/apps/extension/src/app/features/dappRequests/hooks/useIsRequestStale.test.ts b/apps/extension/src/app/features/dappRequests/hooks/useIsRequestStale.test.ts new file mode 100644 index 00000000..655cb70b --- /dev/null +++ b/apps/extension/src/app/features/dappRequests/hooks/useIsRequestStale.test.ts @@ -0,0 +1,109 @@ +import { act, renderHook } from '@testing-library/react' +import { + isRequestStale, + REQUEST_EXPIRY_TIME_MS, + useIsRequestStale, +} from 'src/app/features/dappRequests/hooks/useIsRequestStale' + +describe('isRequestStale', () => { + it('returns false for requests created less than 30 minutes ago', () => { + const createdAt = Date.now() - (REQUEST_EXPIRY_TIME_MS - 60000) // 1 minute before expiry + expect(isRequestStale(createdAt)).toBe(false) + }) + + it('returns true for requests created exactly 30 minutes ago', () => { + const createdAt = Date.now() - REQUEST_EXPIRY_TIME_MS // Exactly at expiry time + expect(isRequestStale(createdAt)).toBe(true) + }) + + it('returns true for requests created more than 30 minutes ago', () => { + const createdAt = Date.now() - (REQUEST_EXPIRY_TIME_MS + 60000) // 1 minute past expiry + expect(isRequestStale(createdAt)).toBe(true) + }) + + it('handles edge case where createdAt is in the future', () => { + const createdAt = Date.now() + 60 * 1000 // 1 minute in the future + expect(isRequestStale(createdAt)).toBe(false) + }) +}) + +describe('useIsRequestStale', () => { + beforeEach(() => { + jest.useFakeTimers() + }) + + afterEach(() => { + jest.runOnlyPendingTimers() + jest.useRealTimers() + }) + + it('returns false initially for fresh request', () => { + const createdAt = Date.now() - 1000 // 1 second ago + const { result } = renderHook(() => useIsRequestStale(createdAt)) + expect(result.current).toBe(false) + }) + + it('returns true initially for stale request', () => { + const createdAt = Date.now() - (REQUEST_EXPIRY_TIME_MS + 60000) // 1 minute past expiry + const { result } = renderHook(() => useIsRequestStale(createdAt)) + expect(result.current).toBe(true) + }) + + it('updates from false to true when request becomes stale', () => { + const createdAt = Date.now() - (REQUEST_EXPIRY_TIME_MS - 60000) // 1 minute before expiry + const { result } = renderHook(() => useIsRequestStale(createdAt)) + + expect(result.current).toBe(false) + + // Fast-forward past expiry time + act(() => { + jest.advanceTimersByTime(120000) // 2 minutes + }) + + expect(result.current).toBe(true) + }) + + it('recalculates when createdAt changes', () => { + const initialCreatedAt = Date.now() - 1000 // 1 second ago + const { result, rerender } = renderHook(({ timestamp }) => useIsRequestStale(timestamp), { + initialProps: { timestamp: initialCreatedAt }, + }) + + expect(result.current).toBe(false) + + // Change createdAt to a stale timestamp + const staleCreatedAt = Date.now() - (REQUEST_EXPIRY_TIME_MS + 60000) // 1 minute past expiry + act(() => { + rerender({ timestamp: staleCreatedAt }) + }) + + // Need to advance timer to trigger the interval check + act(() => { + jest.advanceTimersByTime(1000) + }) + + expect(result.current).toBe(true) + }) + + it('checks staleness every second', () => { + const createdAt = Date.now() - (REQUEST_EXPIRY_TIME_MS - 30000) // 30 seconds before expiry + const { result } = renderHook(() => useIsRequestStale(createdAt)) + + expect(result.current).toBe(false) + + // Fast-forward by 30 seconds to reach expiry time + act(() => { + jest.advanceTimersByTime(30000) + }) + + // Should now be stale + expect(result.current).toBe(true) + + // Verify it stays stale after more time passes + act(() => { + jest.advanceTimersByTime(10000) + }) + + expect(result.current).toBe(true) + }) +}) diff --git a/apps/extension/src/app/features/dappRequests/hooks/useIsRequestStale.ts b/apps/extension/src/app/features/dappRequests/hooks/useIsRequestStale.ts new file mode 100644 index 00000000..88e68390 --- /dev/null +++ b/apps/extension/src/app/features/dappRequests/hooks/useIsRequestStale.ts @@ -0,0 +1,33 @@ +import ms from 'ms' +import { useEffect, useState } from 'react' +import { useInterval } from 'utilities/src/time/timing' + +export const REQUEST_EXPIRY_TIME_MS = ms('30m') + +export function isRequestStale(createdAt: number): boolean { + return Date.now() - createdAt >= REQUEST_EXPIRY_TIME_MS +} + +/** + * Hook that monitors whether a request has become stale (older than REQUEST_EXPIRY_TIME_MS). + * + * @param createdAt - Timestamp when the request was created (in milliseconds) + * @returns boolean indicating whether the request is stale + */ +export function useIsRequestStale(createdAt: number): boolean { + const [isStale, setIsStale] = useState(() => isRequestStale(createdAt)) + + useEffect(() => { + setIsStale(isRequestStale(createdAt)) + }, [createdAt]) + + useInterval( + () => { + setIsStale(isRequestStale(createdAt)) + }, + 1000, + true, + ) + + return isStale +} diff --git a/apps/extension/src/app/features/dappRequests/hooks/usePrepareAndSignDappTransaction.test.ts b/apps/extension/src/app/features/dappRequests/hooks/usePrepareAndSignDappTransaction.test.ts new file mode 100644 index 00000000..5a460fa8 --- /dev/null +++ b/apps/extension/src/app/features/dappRequests/hooks/usePrepareAndSignDappTransaction.test.ts @@ -0,0 +1,551 @@ +import { act, renderHook } from '@testing-library/react' +import { useDispatch } from 'react-redux' +import { prepareAndSignDappTransactionActions } from 'src/app/features/dappRequests/configuredSagas' +import { useConditionalPreSignDelay } from 'src/app/features/dappRequests/hooks/useConditionalPreSignDelay' +import { usePrepareAndSignDappTransaction } from 'src/app/features/dappRequests/hooks/usePrepareAndSignDappTransaction' +import { AccountType } from 'uniswap/src/features/accounts/types' +import { UniverseChainId } from 'uniswap/src/features/chains/types' +import { + isValidTransactionRequest, + ValidatedTransactionRequest, +} from 'uniswap/src/features/transactions/types/transactionRequests' +import { logger } from 'utilities/src/logger/logger' +import { SignedTransactionRequest } from 'wallet/src/features/transactions/executeTransaction/types' +import { Account, SignerMnemonicAccount } from 'wallet/src/features/wallet/accounts/types' + +// Mock dependencies +jest.mock('react-redux', () => ({ + useDispatch: jest.fn(), +})) + +jest.mock('src/app/features/dappRequests/hooks/useConditionalPreSignDelay', () => ({ + useConditionalPreSignDelay: jest.fn(), +})) + +jest.mock('src/app/features/dappRequests/configuredSagas', () => ({ + prepareAndSignDappTransactionActions: { + trigger: jest.fn((payload) => ({ type: 'test/trigger', payload })), + cancel: jest.fn(() => ({ type: 'test/cancel' })), + }, +})) + +jest.mock('uniswap/src/features/transactions/types/transactionRequests', () => ({ + isValidTransactionRequest: jest.fn(), +})) + +jest.mock('utilities/src/logger/logger', () => ({ + logger: { + error: jest.fn(), + }, +})) + +// Typed mocks +const mockUseDispatch = useDispatch as jest.MockedFunction +const mockUseConditionalPreSignDelay = useConditionalPreSignDelay as jest.MockedFunction< + typeof useConditionalPreSignDelay +> +const mockIsValidTransactionRequest = isValidTransactionRequest as jest.MockedFunction +const mockLogger = logger as jest.Mocked + +describe('usePrepareAndSignDappTransaction', () => { + const mockDispatch = jest.fn() + + const mockAccount: SignerMnemonicAccount = { + type: AccountType.SignerMnemonic, + address: '0x1234567890123456789012345678901234567890', + derivationIndex: 0, + mnemonicId: 'test-mnemonic-id', + timeImportedMs: 0, + pushNotificationsEnabled: false, + } + + const mockChainId = UniverseChainId.Mainnet + + const mockRequest: ValidatedTransactionRequest = { + to: '0xabcdef1234567890123456789012345678901234', + value: '1000000000000000000', // 1 ETH + data: '0x', + chainId: mockChainId, + } + + const mockSignedTransaction: SignedTransactionRequest = { + request: mockRequest, + signedRequest: '0xsignedtransaction', + timestampBeforeSign: 0, + } + + beforeEach(() => { + jest.clearAllMocks() + + mockUseDispatch.mockReturnValue(mockDispatch) + mockIsValidTransactionRequest.mockReturnValue(true) + + // Default mock for useConditionalPreSignDelay - captures the callback for manual execution + let capturedCallback: (() => void) | undefined + mockUseConditionalPreSignDelay.mockImplementation(({ callback }) => { + capturedCallback = callback + // Expose callback for test control + ;(mockUseConditionalPreSignDelay as any)._capturedCallback = capturedCallback + }) + }) + + describe('initial state', () => { + it('should return undefined preSignedTransaction initially', () => { + const { result } = renderHook(() => + usePrepareAndSignDappTransaction({ + request: mockRequest, + account: mockAccount, + chainId: mockChainId, + }), + ) + + expect(result.current.preSignedTransaction).toBeUndefined() + }) + + it('should set up useConditionalPreSignDelay with correct params', () => { + renderHook(() => + usePrepareAndSignDappTransaction({ + request: mockRequest, + account: mockAccount, + chainId: mockChainId, + }), + ) + + expect(mockUseConditionalPreSignDelay).toHaveBeenCalledWith({ + callback: expect.any(Function), + chainId: mockChainId, + }) + }) + }) + + describe('dependency changes', () => { + it('should reset preSignedTransaction when chainId changes', () => { + const { result, rerender } = renderHook((props) => usePrepareAndSignDappTransaction(props), { + initialProps: { + request: mockRequest, + account: mockAccount, + chainId: mockChainId, + }, + }) + + // Simulate having a pre-signed transaction + mockDispatch.mockImplementation((action: any) => { + if (action.type === 'test/trigger' && action.payload?.onSuccess) { + action.payload.onSuccess(mockSignedTransaction) + } + }) + + // Trigger preparation + const callback = (mockUseConditionalPreSignDelay as any)._capturedCallback + act(() => { + callback?.() + }) + + expect(result.current.preSignedTransaction).toBe(mockSignedTransaction) + + // Change chainId + rerender({ + request: mockRequest, + account: mockAccount, + chainId: UniverseChainId.Polygon, + }) + + expect(result.current.preSignedTransaction).toBeUndefined() + }) + + it('should reset preSignedTransaction when request changes', () => { + const { result, rerender } = renderHook((props) => usePrepareAndSignDappTransaction(props), { + initialProps: { + request: mockRequest, + account: mockAccount, + chainId: mockChainId, + }, + }) + + // Simulate having a pre-signed transaction + mockDispatch.mockImplementation((action: any) => { + if (action.type === 'test/trigger' && action.payload?.onSuccess) { + action.payload.onSuccess(mockSignedTransaction) + } + }) + + const callback = (mockUseConditionalPreSignDelay as any)._capturedCallback + act(() => { + callback?.() + }) + + expect(result.current.preSignedTransaction).toBe(mockSignedTransaction) + + // Change request + const newRequest = { ...mockRequest, value: '2000000000000000000' } + rerender({ + request: newRequest, + account: mockAccount, + chainId: mockChainId, + }) + + expect(result.current.preSignedTransaction).toBeUndefined() + }) + + it('should call cancel on ongoing preparation when dependencies change', () => { + const { rerender } = renderHook((props) => usePrepareAndSignDappTransaction(props), { + initialProps: { + request: mockRequest, + account: mockAccount, + chainId: mockChainId, + }, + }) + + // Start a preparation + const callback = (mockUseConditionalPreSignDelay as any)._capturedCallback + callback?.() + + expect(mockDispatch).toHaveBeenCalledWith(prepareAndSignDappTransactionActions.trigger(expect.any(Object))) + + // Change chainId to trigger cancellation + rerender({ + request: mockRequest, + account: mockAccount, + chainId: UniverseChainId.Polygon, + }) + + expect(mockDispatch).toHaveBeenCalledWith(prepareAndSignDappTransactionActions.cancel()) + }) + }) + + describe('prepareAndSignTransaction function', () => { + it('should not prepare when preparation is already in progress', () => { + renderHook(() => + usePrepareAndSignDappTransaction({ + request: mockRequest, + account: mockAccount, + chainId: mockChainId, + }), + ) + + const callback = (mockUseConditionalPreSignDelay as any)._capturedCallback + + // Start first preparation + callback?.() + expect(mockDispatch).toHaveBeenCalledTimes(1) + + // Try to start second preparation while first is in progress + callback?.() + expect(mockDispatch).toHaveBeenCalledTimes(1) // Should still be 1 + }) + + it('should not prepare when chainId is missing', () => { + renderHook(() => + usePrepareAndSignDappTransaction({ + request: mockRequest, + account: mockAccount, + chainId: undefined, + }), + ) + + const callback = (mockUseConditionalPreSignDelay as any)._capturedCallback + callback?.() + + expect(mockDispatch).not.toHaveBeenCalled() + }) + + it('should not prepare when account is not SignerMnemonic type', () => { + const readOnlyAccount: Account = { + ...mockAccount, + type: AccountType.Readonly, + } + + renderHook(() => + usePrepareAndSignDappTransaction({ + request: mockRequest, + account: readOnlyAccount, + chainId: mockChainId, + }), + ) + + const callback = (mockUseConditionalPreSignDelay as any)._capturedCallback + callback?.() + + expect(mockDispatch).not.toHaveBeenCalled() + }) + + it('should not prepare when request is missing', () => { + renderHook(() => + usePrepareAndSignDappTransaction({ + request: undefined, + account: mockAccount, + chainId: mockChainId, + }), + ) + + const callback = (mockUseConditionalPreSignDelay as any)._capturedCallback + callback?.() + + expect(mockDispatch).not.toHaveBeenCalled() + }) + + it('should not prepare when request is invalid', () => { + mockIsValidTransactionRequest.mockReturnValue(false) + + renderHook(() => + usePrepareAndSignDappTransaction({ + request: mockRequest, + account: mockAccount, + chainId: mockChainId, + }), + ) + + const callback = (mockUseConditionalPreSignDelay as any)._capturedCallback + callback?.() + + expect(mockDispatch).not.toHaveBeenCalled() + }) + + it('should dispatch prepare action with correct parameters when conditions are met', () => { + renderHook(() => + usePrepareAndSignDappTransaction({ + request: mockRequest, + account: mockAccount, + chainId: mockChainId, + }), + ) + + const callback = (mockUseConditionalPreSignDelay as any)._capturedCallback + callback?.() + + expect(mockDispatch).toHaveBeenCalledWith( + prepareAndSignDappTransactionActions.trigger({ + request: mockRequest, + account: mockAccount, + chainId: mockChainId, + onSuccess: expect.any(Function), + onFailure: expect.any(Function), + }), + ) + }) + + it('should set preSignedTransaction when preparation succeeds', () => { + mockDispatch.mockImplementation((action: any) => { + if (action.type === 'test/trigger' && action.payload?.onSuccess) { + action.payload.onSuccess(mockSignedTransaction) + } + }) + + const { result } = renderHook(() => + usePrepareAndSignDappTransaction({ + request: mockRequest, + account: mockAccount, + chainId: mockChainId, + }), + ) + + const callback = (mockUseConditionalPreSignDelay as any)._capturedCallback + act(() => { + callback?.() + }) + + expect(result.current.preSignedTransaction).toBe(mockSignedTransaction) + }) + + it('should handle preparation failure and log error', async () => { + const mockError = new Error('Preparation failed') + + mockDispatch.mockImplementation((action: any) => { + if (action.type === 'test/trigger' && action.payload?.onFailure) { + // Simulate async failure + setTimeout(() => action.payload.onFailure(mockError), 0) + } + }) + + const { result } = renderHook(() => + usePrepareAndSignDappTransaction({ + request: mockRequest, + account: mockAccount, + chainId: mockChainId, + }), + ) + + const callback = (mockUseConditionalPreSignDelay as any)._capturedCallback + callback?.() + + // Wait for async error handling + await new Promise((resolve) => setTimeout(resolve, 10)) + + expect(result.current.preSignedTransaction).toBeUndefined() + expect(mockLogger.error).toHaveBeenCalledWith(mockError, { + tags: { file: 'usePrepareAndSignDappTransaction', function: 'prepareAndSignTransaction' }, + extra: { request: mockRequest }, + }) + }) + + it('should handle non-Error failures and create proper Error object', async () => { + const mockNonError = 'String error' + + mockDispatch.mockImplementation((action: any) => { + if (action.type === 'test/trigger' && action.payload?.onFailure) { + setTimeout(() => action.payload.onFailure(mockNonError), 0) + } + }) + + renderHook(() => + usePrepareAndSignDappTransaction({ + request: mockRequest, + account: mockAccount, + chainId: mockChainId, + }), + ) + + const callback = (mockUseConditionalPreSignDelay as any)._capturedCallback + callback?.() + + // Wait for async error handling + await new Promise((resolve) => setTimeout(resolve, 10)) + + expect(mockLogger.error).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Unknown preparation error', + }), + { + tags: { file: 'usePrepareAndSignDappTransaction', function: 'prepareAndSignTransaction' }, + extra: { request: mockRequest }, + }, + ) + }) + + it('should clean up preparation state after completion', async () => { + mockDispatch.mockImplementation((action: any) => { + if (action.type === 'test/trigger' && action.payload?.onSuccess) { + action.payload.onSuccess(mockSignedTransaction) + } + }) + + renderHook(() => + usePrepareAndSignDappTransaction({ + request: mockRequest, + account: mockAccount, + chainId: mockChainId, + }), + ) + + const callback = (mockUseConditionalPreSignDelay as any)._capturedCallback + + // First preparation should work + callback?.() + expect(mockDispatch).toHaveBeenCalledTimes(1) + + // Wait for cleanup + await new Promise((resolve) => setTimeout(resolve, 10)) + + // Second preparation should also work (not blocked by previous state) + callback?.() + expect(mockDispatch).toHaveBeenCalledTimes(2) + }) + + it('should clean up preparation state after error', async () => { + const mockError = new Error('Preparation failed') + + mockDispatch.mockImplementation((action: any) => { + if (action.type === 'test/trigger' && action.payload?.onFailure) { + setTimeout(() => action.payload.onFailure(mockError), 0) + } + }) + + renderHook(() => + usePrepareAndSignDappTransaction({ + request: mockRequest, + account: mockAccount, + chainId: mockChainId, + }), + ) + + const callback = (mockUseConditionalPreSignDelay as any)._capturedCallback + + // First preparation should work + callback?.() + expect(mockDispatch).toHaveBeenCalledTimes(1) + + // Wait for error handling and cleanup + await new Promise((resolve) => setTimeout(resolve, 10)) + + // Second preparation should also work (not blocked by previous state) + callback?.() + expect(mockDispatch).toHaveBeenCalledTimes(2) + }) + }) + + describe('useConditionalPreSignDelay integration', () => { + it('should update useConditionalPreSignDelay when chainId changes', () => { + const { rerender } = renderHook((props) => usePrepareAndSignDappTransaction(props), { + initialProps: { + request: mockRequest, + account: mockAccount, + chainId: mockChainId, + }, + }) + + expect(mockUseConditionalPreSignDelay).toHaveBeenCalledWith({ + callback: expect.any(Function), + chainId: mockChainId, + }) + + const newChainId = UniverseChainId.Polygon + rerender({ + request: mockRequest, + account: mockAccount, + chainId: newChainId, + }) + + expect(mockUseConditionalPreSignDelay).toHaveBeenLastCalledWith({ + callback: expect.any(Function), + chainId: newChainId, + }) + }) + + it('should pass the same callback function across re-renders when dependencies do not change', () => { + const { rerender } = renderHook((props) => usePrepareAndSignDappTransaction(props), { + initialProps: { + request: mockRequest, + account: mockAccount, + chainId: mockChainId, + }, + }) + + const firstCallback = mockUseConditionalPreSignDelay.mock.calls[0]![0].callback + + // Re-render with same props + rerender({ + request: mockRequest, + account: mockAccount, + chainId: mockChainId, + }) + + const secondCallback = mockUseConditionalPreSignDelay.mock.calls[1]![0].callback + + expect(firstCallback).toBe(secondCallback) + }) + + it('should create new callback function when dependencies change', () => { + const { rerender } = renderHook((props) => usePrepareAndSignDappTransaction(props), { + initialProps: { + request: mockRequest, + account: mockAccount, + chainId: mockChainId, + }, + }) + + const firstCallback = mockUseConditionalPreSignDelay.mock.calls[0]![0].callback + + // Re-render with different account + const newAccount = { ...mockAccount, address: '0x9876543210987654321098765432109876543210' } + rerender({ + request: mockRequest, + account: newAccount, + chainId: mockChainId, + }) + + const secondCallback = mockUseConditionalPreSignDelay.mock.calls[1]![0].callback + + expect(firstCallback).not.toBe(secondCallback) + }) + }) +}) diff --git a/apps/extension/src/app/features/dappRequests/hooks/usePrepareAndSignDappTransaction.ts b/apps/extension/src/app/features/dappRequests/hooks/usePrepareAndSignDappTransaction.ts new file mode 100644 index 00000000..3d031de1 --- /dev/null +++ b/apps/extension/src/app/features/dappRequests/hooks/usePrepareAndSignDappTransaction.ts @@ -0,0 +1,100 @@ +import { TransactionRequest } from '@ethersproject/providers' +import { useCallback, useEffect, useRef, useState } from 'react' +import { useDispatch } from 'react-redux' +import { prepareAndSignDappTransactionActions } from 'src/app/features/dappRequests/configuredSagas' +import { useConditionalPreSignDelay } from 'src/app/features/dappRequests/hooks/useConditionalPreSignDelay' +import { AccountType } from 'uniswap/src/features/accounts/types' +import { UniverseChainId } from 'uniswap/src/features/chains/types' +import { isValidTransactionRequest } from 'uniswap/src/features/transactions/types/transactionRequests' +import { logger } from 'utilities/src/logger/logger' +import { SignedTransactionRequest } from 'wallet/src/features/transactions/executeTransaction/types' +import { Account } from 'wallet/src/features/wallet/accounts/types' + +interface UsePrepareAndSignDappTransactionParams { + /** Dependencies that when changed, cancel ongoing preparations */ + request?: TransactionRequest + /** The account to use for signing */ + account: Account + /** The chain ID for the transaction */ + chainId?: UniverseChainId +} + +/** + * Shared hook for the common prepare and sign transaction pattern + */ +export function usePrepareAndSignDappTransaction({ + request, + account, + chainId, +}: UsePrepareAndSignDappTransactionParams): { + preSignedTransaction: SignedTransactionRequest | undefined +} { + const dispatch = useDispatch() + const [preSignedTransaction, setPreSignedTransaction] = useState(undefined) + const currentPreparationRef = useRef<{ cancel: () => void } | null>(null) + + // Cancel ongoing preparations when dependencies change + // oxlint-disable-next-line react/exhaustive-deps -- chainId and request changes should reset preparation state + useEffect(() => { + currentPreparationRef.current?.cancel() + currentPreparationRef.current = null + setPreSignedTransaction(undefined) + }, [chainId, request]) + + const prepareAndSignTransaction = useCallback(async (): Promise => { + if ( + currentPreparationRef.current || + !chainId || + account.type !== AccountType.SignerMnemonic || + !request || + !isValidTransactionRequest(request) + ) { + return + } + + // Mark that we're currently preparing + currentPreparationRef.current = { cancel: () => dispatch(prepareAndSignDappTransactionActions.cancel()) } + + try { + // Create the promise for preparing and signing + const preparePromise = new Promise((resolve, reject) => { + dispatch( + prepareAndSignDappTransactionActions.trigger({ + request, + account, + chainId, + onSuccess: (result: SignedTransactionRequest) => { + setPreSignedTransaction(result) + resolve(result) + }, + onFailure: (error: Error) => { + reject(error) + }, + }), + ) + }) + + await preparePromise + } catch (error) { + const prepError = error instanceof Error ? error : new Error('Unknown preparation error') + logger.error(prepError, { + tags: { file: 'usePrepareAndSignDappTransaction', function: 'prepareAndSignTransaction' }, + extra: { request }, + }) + } finally { + // Clean up preparation state + currentPreparationRef.current = null + } + }, [account, chainId, dispatch, request]) + + // Automatically prepare and sign transaction when conditions change + // Apply delay only when a transaction was just confirmed on the same chain + useConditionalPreSignDelay({ + callback: prepareAndSignTransaction, + chainId, + }) + + return { + preSignedTransaction, + } +} diff --git a/apps/extension/src/app/features/dappRequests/hooks/usePrepareAndSignEthSendTransaction.ts b/apps/extension/src/app/features/dappRequests/hooks/usePrepareAndSignEthSendTransaction.ts new file mode 100644 index 00000000..78f899f5 --- /dev/null +++ b/apps/extension/src/app/features/dappRequests/hooks/usePrepareAndSignEthSendTransaction.ts @@ -0,0 +1,73 @@ +import { GasFeeResult } from '@universe/api' +import { useMemo } from 'react' +import { usePrepareAndSignDappTransaction } from 'src/app/features/dappRequests/hooks/usePrepareAndSignDappTransaction' +import { useTransactionGasEstimation } from 'src/app/features/dappRequests/hooks/useTransactionGasEstimation' +import { DappRequestStoreItemForEthSendTxn } from 'src/app/features/dappRequests/slice' +import { UniverseChainId } from 'uniswap/src/features/chains/types' +import { formatExternalTxnWithGasEstimates } from 'wallet/src/features/gas/formatExternalTxnWithGasEstimates' +import { SignedTransactionRequest } from 'wallet/src/features/transactions/executeTransaction/types' +import { Account } from 'wallet/src/features/wallet/accounts/types' + +interface UsePrepareAndSignDappTransactionParams { + request: DappRequestStoreItemForEthSendTxn + account: Account + chainId?: UniverseChainId +} + +interface UsePrepareAndSignDappTransactionResult { + /** The gas fee result from estimation */ + gasFeeResult: GasFeeResult + + /** Whether the gas fee result is invalid */ + isInvalidGasFeeResult: boolean + + /** The request with gas values formatted */ + requestWithGasValues: DappRequestStoreItemForEthSendTxn + + /** The pre-signed transaction (available after gas info is loaded) */ + preSignedTransaction: SignedTransactionRequest | undefined +} + +/** + * Hook that fetches gas information for a dapp transaction and automatically + * prepares and signs the transaction once gas info is available + */ +export function usePrepareAndSignEthSendTransaction({ + request, + account, + chainId, +}: UsePrepareAndSignDappTransactionParams): UsePrepareAndSignDappTransactionResult { + const { gasFeeResult, isInvalidGasFeeResult } = useTransactionGasEstimation({ + baseTx: request.dappRequest.transaction, + chainId, + skip: !request.dappRequest.transaction, + }) + + const requestWithGasValues = useMemo(() => { + const txnWithFormattedGasEstimates = formatExternalTxnWithGasEstimates({ + transaction: request.dappRequest.transaction, + gasFeeResult, + }) + + return { + ...request, + dappRequest: { + ...request.dappRequest, + transaction: txnWithFormattedGasEstimates, + }, + } + }, [request, gasFeeResult]) + + const { preSignedTransaction } = usePrepareAndSignDappTransaction({ + request: requestWithGasValues.dappRequest.transaction, + account, + chainId, + }) + + return { + gasFeeResult, + isInvalidGasFeeResult, + requestWithGasValues, + preSignedTransaction, + } +} diff --git a/apps/extension/src/app/features/dappRequests/hooks/usePrepareAndSignSendCallsTransaction.ts b/apps/extension/src/app/features/dappRequests/hooks/usePrepareAndSignSendCallsTransaction.ts new file mode 100644 index 00000000..3c38ed93 --- /dev/null +++ b/apps/extension/src/app/features/dappRequests/hooks/usePrepareAndSignSendCallsTransaction.ts @@ -0,0 +1,106 @@ +import { GasFeeResult } from '@universe/api' +import { useMemo } from 'react' +import { usePrepareAndSignDappTransaction } from 'src/app/features/dappRequests/hooks/usePrepareAndSignDappTransaction' +import { useTransactionGasEstimation } from 'src/app/features/dappRequests/hooks/useTransactionGasEstimation' +import { DappRequestStoreItemForSendCallsTxn } from 'src/app/features/dappRequests/slice' +import { UNISWAP_DELEGATION_ADDRESS } from 'uniswap/src/constants/addresses' +import { useWalletEncode7702Query } from 'uniswap/src/data/apiClients/tradingApi/useWalletEncode7702Query' +import { UniverseChainId } from 'uniswap/src/features/chains/types' +import { EthTransaction } from 'uniswap/src/types/walletConnect' +import { transformCallsToTransactionRequests } from 'wallet/src/features/batchedTransactions/utils' +import { useLiveAccountDelegationDetails } from 'wallet/src/features/smartWallet/hooks/useLiveAccountDelegationDetails' +import { SignedTransactionRequest } from 'wallet/src/features/transactions/executeTransaction/types' +import { Account } from 'wallet/src/features/wallet/accounts/types' + +interface UsePrepareAndSignSendCallsTransactionParams { + request: DappRequestStoreItemForSendCallsTxn + account: Account + chainId?: UniverseChainId +} + +interface UsePrepareAndSignSendCallsTransactionResult { + /** The gas fee result from estimation */ + gasFeeResult: GasFeeResult + + /** Whether the gas fee result is invalid */ + isInvalidGasFeeResult: boolean + + /** Whether smart wallet activation is needed */ + showSmartWalletActivation: boolean + + /** The encoded transaction data */ + encodedTransactionRequest?: EthTransaction + + /** The encoded request ID */ + encodedRequestId?: string + + /** The pre-signed transaction (available after gas info is loaded) */ + preSignedTransaction?: SignedTransactionRequest +} + +/** + * Hook that fetches gas information for a SendCalls dapp transaction and automatically + * prepares and signs the transaction once gas info is available + */ +export function usePrepareAndSignSendCallsTransaction({ + request, + account, + chainId, +}: UsePrepareAndSignSendCallsTransactionParams): UsePrepareAndSignSendCallsTransactionResult { + const { data: encoded7702data } = useWalletEncode7702Query({ + enabled: !!chainId && !!account.address, + params: { + calls: chainId + ? transformCallsToTransactionRequests({ + calls: request.dappRequest.calls, + chainId, + accountAddress: account.address, + }) + : [], + smartContractDelegationAddress: UNISWAP_DELEGATION_ADDRESS, + walletAddress: account.address, + }, + }) + + const delegationData = useLiveAccountDelegationDetails({ + address: account.address, + chainId, + }) + + const encodedTransaction = encoded7702data?.encoded + const encodedRequestId = encoded7702data?.requestId + + const { gasFeeResult, isInvalidGasFeeResult } = useTransactionGasEstimation({ + baseTx: encodedTransaction, + chainId, + skip: !encodedTransaction?.to, + smartContractDelegationAddress: delegationData?.contractAddress, + }) + + const encodedTransactionRequestWithGasInfo: EthTransaction | undefined = useMemo( + () => + encodedTransaction && gasFeeResult.params && !isInvalidGasFeeResult && chainId + ? { + ...encodedTransaction, + ...gasFeeResult.params, + chainId, + } + : undefined, + [encodedTransaction, gasFeeResult, isInvalidGasFeeResult, chainId], + ) + + const { preSignedTransaction } = usePrepareAndSignDappTransaction({ + request: encodedTransactionRequestWithGasInfo, + account, + chainId, + }) + + return { + gasFeeResult, + isInvalidGasFeeResult, + showSmartWalletActivation: delegationData?.needsDelegation ?? false, + encodedTransactionRequest: encodedTransactionRequestWithGasInfo, + encodedRequestId, + preSignedTransaction, + } +} diff --git a/apps/extension/src/app/features/dappRequests/hooks/useTransactionGasEstimation.test.ts b/apps/extension/src/app/features/dappRequests/hooks/useTransactionGasEstimation.test.ts new file mode 100644 index 00000000..0e687291 --- /dev/null +++ b/apps/extension/src/app/features/dappRequests/hooks/useTransactionGasEstimation.test.ts @@ -0,0 +1,420 @@ +import { TransactionRequest } from '@ethersproject/providers' +import { renderHook } from '@testing-library/react' +import { GasFeeResult } from '@universe/api' +import { useTransactionGasEstimation } from 'src/app/features/dappRequests/hooks/useTransactionGasEstimation' +import { PollingInterval } from 'uniswap/src/constants/misc' +import { UniverseChainId } from 'uniswap/src/features/chains/types' +import { useTransactionGasFee } from 'uniswap/src/features/gas/hooks' +import { logger } from 'utilities/src/logger/logger' + +// Mock dependencies +jest.mock('uniswap/src/features/gas/hooks', () => ({ + useTransactionGasFee: jest.fn(), +})) + +jest.mock('utilities/src/logger/logger', () => ({ + logger: { + error: jest.fn(), + }, +})) + +const mockUseTransactionGasFee = useTransactionGasFee as jest.MockedFunction +const mockLogger = logger as jest.Mocked + +describe('useTransactionGasEstimation', () => { + const mockBaseTx: TransactionRequest = { + to: '0x1234567890123456789012345678901234567890', + value: '1000000000000000000', // 1 ETH + data: '0x', + } + + const mockChainId = UniverseChainId.Mainnet + const mockSmartContractDelegationAddress = '0xabcdef1234567890123456789012345678901234' + + beforeEach(() => { + jest.clearAllMocks() + }) + + describe('successful gas estimation', () => { + it('should return valid gas fee result when estimation succeeds', () => { + const mockGasFeeResult: GasFeeResult = { + value: '21000000000000000', // 0.021 ETH + displayValue: '21000000000000000', + isLoading: false, + error: null, + params: { + gasLimit: '21000', + gasPrice: '1000000000', // 1 gwei + }, + } + + mockUseTransactionGasFee.mockReturnValue(mockGasFeeResult) + + const { result } = renderHook(() => + useTransactionGasEstimation({ + baseTx: mockBaseTx, + chainId: mockChainId, + }), + ) + + expect(result.current.gasFeeResult).toEqual(mockGasFeeResult) + expect(result.current.isInvalidGasFeeResult).toBe(false) + expect(mockUseTransactionGasFee).toHaveBeenCalledWith({ + tx: { ...mockBaseTx, chainId: mockChainId }, + skip: false, + refetchInterval: PollingInterval.LightningMcQueen, + }) + expect(mockLogger.error).not.toHaveBeenCalled() + }) + + it('should include smart contract delegation address when provided', () => { + const mockGasFeeResult: GasFeeResult = { + value: '21000000000000000', + displayValue: '21000000000000000', + isLoading: false, + error: null, + params: { + gasLimit: '21000', + gasPrice: '1000000000', + }, + } + + mockUseTransactionGasFee.mockReturnValue(mockGasFeeResult) + + renderHook(() => + useTransactionGasEstimation({ + baseTx: mockBaseTx, + chainId: mockChainId, + smartContractDelegationAddress: mockSmartContractDelegationAddress, + }), + ) + + expect(mockUseTransactionGasFee).toHaveBeenCalledWith({ + tx: { ...mockBaseTx, chainId: mockChainId }, + skip: false, + refetchInterval: PollingInterval.LightningMcQueen, + smartContractDelegationAddress: mockSmartContractDelegationAddress, + }) + }) + }) + + describe('loading state', () => { + it('should handle loading state correctly', () => { + const mockGasFeeResult: GasFeeResult = { + isLoading: true, + error: null, + } + + mockUseTransactionGasFee.mockReturnValue(mockGasFeeResult) + + const { result } = renderHook(() => + useTransactionGasEstimation({ + baseTx: mockBaseTx, + chainId: mockChainId, + }), + ) + + expect(result.current.gasFeeResult).toEqual(mockGasFeeResult) + expect(result.current.isInvalidGasFeeResult).toBe(false) // Loading state is valid + expect(mockLogger.error).not.toHaveBeenCalled() // No error should be logged during loading + }) + }) + + describe('error handling', () => { + it('should handle gas fee estimation error and log it', () => { + const mockError = new Error('Gas estimation failed') + const mockGasFeeResult: GasFeeResult = { + isLoading: false, + error: mockError, + } + + mockUseTransactionGasFee.mockReturnValue(mockGasFeeResult) + + const { result } = renderHook(() => + useTransactionGasEstimation({ + baseTx: mockBaseTx, + chainId: mockChainId, + }), + ) + + expect(result.current.gasFeeResult).toEqual(mockGasFeeResult) + expect(result.current.isInvalidGasFeeResult).toBe(true) + expect(mockLogger.error).toHaveBeenCalledWith(mockError, { + tags: { file: 'useTransactionGasEstimation', function: 'useEffect' }, + extra: { request: { ...mockBaseTx, chainId: mockChainId } }, + }) + }) + + it('should handle missing params as invalid result', () => { + const mockGasFeeResult: GasFeeResult = { + value: '21000000000000000', + displayValue: '21000000000000000', + isLoading: false, + error: null, + // params intentionally missing + } + + mockUseTransactionGasFee.mockReturnValue(mockGasFeeResult) + + const { result } = renderHook(() => + useTransactionGasEstimation({ + baseTx: mockBaseTx, + chainId: mockChainId, + }), + ) + + expect(result.current.isInvalidGasFeeResult).toBe(true) + expect(mockLogger.error).toHaveBeenCalledWith(new Error('Invalid gas fee result for dapp request.'), { + tags: { file: 'useTransactionGasEstimation', function: 'useEffect' }, + extra: { request: { ...mockBaseTx, chainId: mockChainId } }, + }) + }) + + it('should handle missing value as invalid result', () => { + const mockGasFeeResult: GasFeeResult = { + isLoading: false, + error: null, + params: { + gasLimit: '21000', + gasPrice: '1000000000', + }, + // value intentionally missing + } + + mockUseTransactionGasFee.mockReturnValue(mockGasFeeResult) + + const { result } = renderHook(() => + useTransactionGasEstimation({ + baseTx: mockBaseTx, + chainId: mockChainId, + }), + ) + + expect(result.current.isInvalidGasFeeResult).toBe(true) + expect(mockLogger.error).toHaveBeenCalledWith(new Error('Invalid gas fee result for dapp request.'), { + tags: { file: 'useTransactionGasEstimation', function: 'useEffect' }, + extra: { request: { ...mockBaseTx, chainId: mockChainId } }, + }) + }) + }) + + describe('skip functionality', () => { + it('should skip gas estimation when skip is true', () => { + const mockGasFeeResult: GasFeeResult = { + isLoading: false, + error: null, + } + + mockUseTransactionGasFee.mockReturnValue(mockGasFeeResult) + + renderHook(() => + useTransactionGasEstimation({ + baseTx: mockBaseTx, + chainId: mockChainId, + skip: true, + }), + ) + + expect(mockUseTransactionGasFee).toHaveBeenCalledWith({ + tx: { ...mockBaseTx, chainId: mockChainId }, + skip: true, + refetchInterval: PollingInterval.LightningMcQueen, + }) + }) + }) + + describe('memoization', () => { + it('should not recreate formatted tx when baseTx and chainId remain the same', () => { + const mockGasFeeResult: GasFeeResult = { + value: '21000000000000000', + displayValue: '21000000000000000', + isLoading: false, + error: null, + params: { + gasLimit: '21000', + gasPrice: '1000000000', + }, + } + + mockUseTransactionGasFee.mockReturnValue(mockGasFeeResult) + + const { rerender } = renderHook( + ({ baseTx, chainId }) => + useTransactionGasEstimation({ + baseTx, + chainId, + }), + { + initialProps: { + baseTx: mockBaseTx, + chainId: mockChainId, + }, + }, + ) + + const firstCallArgs = mockUseTransactionGasFee.mock.calls[0]![0] + + // Rerender with same props + rerender({ + baseTx: mockBaseTx, + chainId: mockChainId, + }) + + const secondCallArgs = mockUseTransactionGasFee.mock.calls[1]![0] + + // The tx object should be the same reference due to memoization + expect(firstCallArgs.tx).toBe(secondCallArgs.tx) + }) + + it('should recreate formatted tx when baseTx changes', () => { + const mockGasFeeResult: GasFeeResult = { + value: '21000000000000000', + displayValue: '21000000000000000', + isLoading: false, + error: null, + params: { + gasLimit: '21000', + gasPrice: '1000000000', + }, + } + + mockUseTransactionGasFee.mockReturnValue(mockGasFeeResult) + + const newBaseTx = { ...mockBaseTx, value: '2000000000000000000' } // 2 ETH + + const { rerender } = renderHook( + ({ baseTx, chainId }) => + useTransactionGasEstimation({ + baseTx, + chainId, + }), + { + initialProps: { + baseTx: mockBaseTx, + chainId: mockChainId, + }, + }, + ) + + const firstCallArgs = mockUseTransactionGasFee.mock.calls[0]![0] + + // Rerender with different baseTx + rerender({ + baseTx: newBaseTx, + chainId: mockChainId, + }) + + const secondCallArgs = mockUseTransactionGasFee.mock.calls[1]![0] + + // The tx object should be different due to baseTx change + expect(firstCallArgs.tx).not.toBe(secondCallArgs.tx) + expect(secondCallArgs.tx!.value).toBe('2000000000000000000') + }) + }) + + describe('isInvalidGasFeeResultHelper', () => { + it('should return false for valid gas fee results', () => { + const mockGasFeeResult: GasFeeResult = { + value: '21000000000000000', + displayValue: '21000000000000000', + isLoading: false, + error: null, + params: { + gasLimit: '21000', + gasPrice: '1000000000', + }, + } + + mockUseTransactionGasFee.mockReturnValue(mockGasFeeResult) + + const { result } = renderHook(() => + useTransactionGasEstimation({ + baseTx: mockBaseTx, + chainId: mockChainId, + }), + ) + + expect(result.current.isInvalidGasFeeResult).toBe(false) + }) + + it('should return true when there is an error', () => { + const mockGasFeeResult: GasFeeResult = { + isLoading: false, + error: new Error('Test error'), + } + + mockUseTransactionGasFee.mockReturnValue(mockGasFeeResult) + + const { result } = renderHook(() => + useTransactionGasEstimation({ + baseTx: mockBaseTx, + chainId: mockChainId, + }), + ) + + expect(result.current.isInvalidGasFeeResult).toBe(true) + }) + + it('should return true when not loading but missing params', () => { + const mockGasFeeResult: GasFeeResult = { + value: '21000000000000000', + isLoading: false, + error: null, + // params missing + } + + mockUseTransactionGasFee.mockReturnValue(mockGasFeeResult) + + const { result } = renderHook(() => + useTransactionGasEstimation({ + baseTx: mockBaseTx, + chainId: mockChainId, + }), + ) + + expect(result.current.isInvalidGasFeeResult).toBe(true) + }) + + it('should return true when not loading but missing value', () => { + const mockGasFeeResult: GasFeeResult = { + isLoading: false, + error: null, + params: { + gasLimit: '21000', + gasPrice: '1000000000', + }, + // value missing + } + + mockUseTransactionGasFee.mockReturnValue(mockGasFeeResult) + + const { result } = renderHook(() => + useTransactionGasEstimation({ + baseTx: mockBaseTx, + chainId: mockChainId, + }), + ) + + expect(result.current.isInvalidGasFeeResult).toBe(true) + }) + + it('should return false when loading even with missing data', () => { + const mockGasFeeResult: GasFeeResult = { + isLoading: true, + error: null, + // params and value missing, but loading is true + } + + mockUseTransactionGasFee.mockReturnValue(mockGasFeeResult) + + const { result } = renderHook(() => + useTransactionGasEstimation({ + baseTx: mockBaseTx, + chainId: mockChainId, + }), + ) + + expect(result.current.isInvalidGasFeeResult).toBe(false) // Should be false because loading state means data is still being fetched + }) + }) +}) diff --git a/apps/extension/src/app/features/dappRequests/hooks/useTransactionGasEstimation.ts b/apps/extension/src/app/features/dappRequests/hooks/useTransactionGasEstimation.ts new file mode 100644 index 00000000..7e9ddae7 --- /dev/null +++ b/apps/extension/src/app/features/dappRequests/hooks/useTransactionGasEstimation.ts @@ -0,0 +1,70 @@ +import { TransactionRequest } from '@ethersproject/providers' +import { GasFeeResult } from '@universe/api' +import { useEffect, useMemo } from 'react' +import { PollingInterval } from 'uniswap/src/constants/misc' +import { UniverseChainId } from 'uniswap/src/features/chains/types' +import { useTransactionGasFee } from 'uniswap/src/features/gas/hooks' +import { logger } from 'utilities/src/logger/logger' + +interface UseTransactionGasEstimationParams { + /** Base transaction data (will be formatted with chainId) */ + baseTx?: TransactionRequest + /** Chain ID to add to transaction */ + chainId?: UniverseChainId + /** Whether to skip gas estimation */ + skip?: boolean + /** Smart contract delegation address (for SendCalls) */ + smartContractDelegationAddress?: string +} + +interface UseTransactionGasEstimationResult { + /** The gas fee result from estimation */ + gasFeeResult: GasFeeResult + /** Whether the gas fee result is invalid */ + isInvalidGasFeeResult: boolean +} + +/** + * Shared hook for transaction gas estimation with error handling and validation + */ +export function useTransactionGasEstimation({ + baseTx, + chainId, + skip = false, + smartContractDelegationAddress, +}: UseTransactionGasEstimationParams): UseTransactionGasEstimationResult { + const formattedTx = useMemo(() => { + return baseTx && chainId ? { ...baseTx, chainId } : undefined + }, [baseTx, chainId]) + + const gasFeeResult = useTransactionGasFee({ + tx: formattedTx, + skip: skip || !formattedTx, + refetchInterval: PollingInterval.LightningMcQueen, + ...(smartContractDelegationAddress && { smartContractDelegationAddress }), + }) + + const isInvalidGasFeeResult = isInvalidGasFeeResultHelper(gasFeeResult) + + useEffect(() => { + if (formattedTx && isInvalidGasFeeResult) { + const error = gasFeeResult.error ?? new Error('Invalid gas fee result for dapp request.') + logger.error(error, { + tags: { file: 'useTransactionGasEstimation', function: 'useEffect' }, + extra: { request: formattedTx }, + }) + } + }, [formattedTx, isInvalidGasFeeResult, gasFeeResult]) + + return { + gasFeeResult, + isInvalidGasFeeResult, + } +} + +/** + * Helper function to validate gas fee results + */ +function isInvalidGasFeeResultHelper(gasFeeResult: GasFeeResult): boolean { + return !!gasFeeResult.error || (!gasFeeResult.isLoading && (!gasFeeResult.params || !gasFeeResult.value)) +} diff --git a/apps/extension/src/app/features/dappRequests/permissions.ts b/apps/extension/src/app/features/dappRequests/permissions.ts new file mode 100644 index 00000000..2ce93f16 --- /dev/null +++ b/apps/extension/src/app/features/dappRequests/permissions.ts @@ -0,0 +1,139 @@ +/* oxlint-disable typescript/explicit-function-return-type */ +import { rpcErrors, serializeError } from '@metamask/rpc-errors' +import { removeDappConnection } from 'src/app/features/dapp/actions' +import { type DappInfo } from 'src/app/features/dapp/store' +import { saveAccount } from 'src/app/features/dappRequests/accounts' +import type { SenderTabInfo } from 'src/app/features/dappRequests/shared' +import { + type ErrorResponse, + type GetPermissionsRequest, + type GetPermissionsResponse, + type RequestPermissionsRequest, + type RequestPermissionsResponse, + type RevokePermissionsRequest, + type RevokePermissionsResponse, +} from 'src/app/features/dappRequests/types/DappRequestTypes' +import { dappResponseMessageChannel } from 'src/background/messagePassing/messageChannels' +import { type Permission } from 'src/contentScript/WindowEthereumRequestTypes' +import { call, put } from 'typed-redux-saga' +import { chainIdToHexadecimalString } from 'uniswap/src/features/chains/utils' +import { DappResponseType, EthMethod } from 'uniswap/src/features/dappRequests/types' +import { pushNotification } from 'uniswap/src/features/notifications/slice/slice' +import { AppNotificationType } from 'uniswap/src/features/notifications/slice/types' +import { ExtensionEventName } from 'uniswap/src/features/telemetry/constants' +import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { extractBaseUrl } from 'utilities/src/format/urls' +import { logger } from 'utilities/src/logger/logger' + +export function getPermissions(dappUrl: string | undefined, connectedAddresses: Address[] | undefined): Permission[] { + const permissions: Permission[] = [] + const isDappConnected = connectedAddresses && connectedAddresses.length > 0 + if (isDappConnected && dappUrl) { + // Safe to assume the eth_accounts permission can be added here, + // since dappInfo will only exist if it as been approved already + permissions.push({ + invoker: dappUrl, + parentCapability: EthMethod.EthAccounts, + caveats: [], + }) + } + + return permissions +} + +export function* handleGetPermissionsRequest({ + request, + senderTabInfo: { id, url }, + dappInfo, +}: { + request: GetPermissionsRequest + senderTabInfo: SenderTabInfo + dappInfo?: DappInfo +}) { + const permissions: Permission[] = [] + if (dappInfo) { + permissions.push({ + invoker: url, + parentCapability: EthMethod.EthAccounts, + caveats: [], + }) + } + + const response: GetPermissionsResponse = { + type: DappResponseType.GetPermissionsResponse, + requestId: request.requestId, + permissions, + } + yield* call(dappResponseMessageChannel.sendMessageToTab, id, response) +} + +export function* handleRequestPermissions(request: RequestPermissionsRequest, senderTabInfo: SenderTabInfo) { + const requestedPermissions = Object.keys(request.permissions) + + if (requestedPermissions.includes(EthMethod.EthAccounts)) { + // Pre-emptively save the dapp connection, to avoid double-approval when dapp calls eth_requestAccounts + const accountInfo = yield* call(saveAccount, senderTabInfo) + const accounts = accountInfo && { + connectedAddresses: accountInfo.connectedAddresses, + chainId: chainIdToHexadecimalString(accountInfo.chainId), + providerUrl: accountInfo.providerUrl, + } + + const permissions: Permission[] = [ + { + invoker: senderTabInfo.url, + parentCapability: EthMethod.EthAccounts, + caveats: [], + }, + ] + const response: RequestPermissionsResponse = { + type: DappResponseType.RequestPermissionsResponse, + requestId: request.requestId, + permissions, + accounts, + } + yield* call(dappResponseMessageChannel.sendMessageToTab, senderTabInfo.id, response) + + // Track that a connection was established + sendAnalyticsEvent(ExtensionEventName.DappConnect, { + dappUrl: accountInfo?.dappUrl ?? extractBaseUrl(senderTabInfo.url), + chainId: accountInfo?.chainId, + activeConnectedAddress: accountInfo?.activeAccount.address, + connectedAddresses: accountInfo?.connectedAddresses ?? [], + }) + } else { + logger.info('saga.ts', 'handleRequestPermissions', 'Unknown permissions requested', requestedPermissions) + yield* call(dappResponseMessageChannel.sendMessageToTab, senderTabInfo.id, { + type: DappResponseType.ErrorResponse, + error: serializeError(rpcErrors.methodNotFound()), + requestId: request.requestId, + } satisfies ErrorResponse) + } +} + +export function* handleRevokePermissions(request: RevokePermissionsRequest, senderTabInfo: SenderTabInfo) { + const revokedPermissions = Object.keys(request.permissions) + + if (revokedPermissions.includes(EthMethod.EthAccounts)) { + const dappUrl = extractBaseUrl(senderTabInfo.url) + + if (!dappUrl) { + return + } + + yield* call(removeDappConnection, dappUrl, undefined) + yield* put(pushNotification({ type: AppNotificationType.DappDisconnected, dappIconUrl: senderTabInfo.favIconUrl })) + + yield* call(dappResponseMessageChannel.sendMessageToTab, senderTabInfo.id, { + type: DappResponseType.RevokePermissionsResponse, + requestId: request.requestId, + } satisfies RevokePermissionsResponse) + } else { + logger.info('saga.ts', 'handleRevokePermissions', 'Unknown permissions requested', revokedPermissions) + yield* call(dappResponseMessageChannel.sendMessageToTab, senderTabInfo.id, { + type: DappResponseType.ErrorResponse, + error: serializeError(rpcErrors.methodNotFound()), + requestId: request.requestId, + } satisfies ErrorResponse) + } +} diff --git a/apps/extension/src/app/features/dappRequests/requestContent/ActionCanNotBeCompleted/ActionCanNotBeCompletedContent.tsx b/apps/extension/src/app/features/dappRequests/requestContent/ActionCanNotBeCompleted/ActionCanNotBeCompletedContent.tsx new file mode 100644 index 00000000..18a1e0fd --- /dev/null +++ b/apps/extension/src/app/features/dappRequests/requestContent/ActionCanNotBeCompleted/ActionCanNotBeCompletedContent.tsx @@ -0,0 +1,47 @@ +import { useTranslation } from 'react-i18next' +import { DappRequestContent } from 'src/app/features/dappRequests/DappRequestContent' +import { Flex, Text } from 'ui/src' +import { AlertTriangleFilled } from 'ui/src/components/icons' +import { LearnMoreLink } from 'uniswap/src/components/text/LearnMoreLink' +import { uniswapUrls } from 'uniswap/src/constants/urls' +import { WalletEventName } from 'uniswap/src/features/telemetry/constants' +import Trace from 'uniswap/src/features/telemetry/Trace' + +export function ActionCanNotBeCompletedContent(): JSX.Element { + const { t } = useTranslation() + + return ( + + + + + + + + + + {t('dapp.request.actionCannotBeCompleted.title')} + + + {t('dapp.request.actionCannotBeCompleted.description')} + + + + + + + + ) +} diff --git a/apps/extension/src/app/features/dappRequests/requestContent/Connection/ConnectionRequestContent.tsx b/apps/extension/src/app/features/dappRequests/requestContent/Connection/ConnectionRequestContent.tsx new file mode 100644 index 00000000..424e65d7 --- /dev/null +++ b/apps/extension/src/app/features/dappRequests/requestContent/Connection/ConnectionRequestContent.tsx @@ -0,0 +1,35 @@ +import { useTranslation } from 'react-i18next' +import { DappRequestContent } from 'src/app/features/dappRequests/DappRequestContent' +import { useDappRequestQueueContext } from 'src/app/features/dappRequests/DappRequestQueueContext' +import { AccountType } from 'uniswap/src/features/accounts/types' +import { DappConnectionContent } from 'wallet/src/components/dappRequests/DappConnectionContent' +import { useBlockaidVerification } from 'wallet/src/features/dappRequests/hooks/useBlockaidVerification' +import { useDappConnectionConfirmation } from 'wallet/src/features/dappRequests/hooks/useDappConnectionConfirmation' + +export function ConnectionRequestContent(): JSX.Element { + const { t } = useTranslation() + const { currentAccount, dappUrl } = useDappRequestQueueContext() + const { verificationStatus } = useBlockaidVerification(dappUrl) + + const isViewOnly = currentAccount.type === AccountType.Readonly + const { confirmedWarning, setConfirmedWarning, disableConfirm } = useDappConnectionConfirmation({ + verificationStatus, + isViewOnly, + }) + + return ( + + + + ) +} diff --git a/apps/extension/src/app/features/dappRequests/requestContent/EthSend/Approve/ApproveRequestContent.tsx b/apps/extension/src/app/features/dappRequests/requestContent/EthSend/Approve/ApproveRequestContent.tsx new file mode 100644 index 00000000..b4fd0dfc --- /dev/null +++ b/apps/extension/src/app/features/dappRequests/requestContent/EthSend/Approve/ApproveRequestContent.tsx @@ -0,0 +1,124 @@ +import { BigNumber } from '@ethersproject/bignumber' +import { GasFeeResult } from '@universe/api' +import { useTranslation } from 'react-i18next' +import { useDappLastChainId } from 'src/app/features/dapp/hooks' +import { DappRequestContent } from 'src/app/features/dappRequests/DappRequestContent' +import { useDappRequestQueueContext } from 'src/app/features/dappRequests/DappRequestQueueContext' +import { + ApproveSendTransactionRequest, + DappRequest as DappRequestBaseType, +} from 'src/app/features/dappRequests/types/DappRequestTypes' +import { Flex, Text } from 'ui/src' +import { iconSizes } from 'ui/src/theme' +import { CurrencyLogo } from 'uniswap/src/components/CurrencyLogo/CurrencyLogo' +import { LearnMoreLink } from 'uniswap/src/components/text/LearnMoreLink' +import { uniswapUrls } from 'uniswap/src/constants/urls' +import { DappRequestType } from 'uniswap/src/features/dappRequests/types' +import { CurrencyInfo } from 'uniswap/src/features/dataApi/types' +import { useCurrencyInfo } from 'uniswap/src/features/tokens/useCurrencyInfo' +import { TransactionType, TransactionTypeInfo } from 'uniswap/src/features/transactions/types/transactionDetails' +import { buildCurrencyId } from 'uniswap/src/utils/currencyId' +import { useNoYoloParser } from 'wallet/src/utils/useNoYoloParser' + +function useDappRequestTokenRecipientInfo(request: DappRequestBaseType, dappUrl: string): Maybe { + const activeChain = useDappLastChainId(dappUrl) + const type = request.type + const to = type === DappRequestType.SendTransaction ? request.transaction.to : undefined + + const identifier = + activeChain && type === DappRequestType.SendTransaction && to ? buildCurrencyId(activeChain, to) : undefined + + return useCurrencyInfo(identifier) +} + +function parseSpenderAddress(data: string): string { + // Check if the data is of the correct length for "approve(address,uint256)" + // It should have 10 characters for "0x" + function selector and 64 characters for each parameter + if (data.length !== 10 + 64 * 2) { + throw new Error('Invalid data length') + } + + // The first argument (address) starts 10 characters in (after "0x" + 8 characters for function selector) + // and spans the next 64 characters, but the first 24 are padding zeros for the 40-character address + const addressHex = data.slice(34, 74) // From position 34 to 74 to capture the address + + // Validate if the address hex is correctly formatted + if (!/^[0-9a-fA-F]{40}$/.test(addressHex)) { + throw new Error('Invalid characters in hex string') + } + + return `0x${addressHex}` +} + +interface ApproveRequestContentProps { + transactionGasFeeResult: GasFeeResult + dappRequest: ApproveSendTransactionRequest + onCancel: () => Promise + onConfirm: (transactionTypeInfo?: TransactionTypeInfo) => Promise +} + +export function ApproveRequestContent({ + dappRequest, + transactionGasFeeResult, + onCancel, + onConfirm, +}: ApproveRequestContentProps): JSX.Element { + const { t } = useTranslation() + const { dappUrl } = useDappRequestQueueContext() + const activeChain = useDappLastChainId(dappUrl) + const { parsedTransactionData } = useNoYoloParser(dappRequest.transaction, activeChain) + + // To detect a revoke, both the transaction value and the parsed arg amount value must be zero + const isArgAmountZero = parsedTransactionData?.args.some( + (arg) => + arg !== null && typeof arg === 'object' && !Array.isArray(arg) && arg._hex && BigNumber.from(arg._hex).isZero(), + ) + const isRevoke = dappRequest.transaction.value === '0x0' && isArgAmountZero + + const tokenInfo = useDappRequestTokenRecipientInfo(dappRequest, dappUrl) + const tokenSymbol = tokenInfo?.currency.symbol + const spender = parseSpenderAddress(dappRequest.transaction.data ?? '') + const transactionTypeInfo: TransactionTypeInfo | undefined = dappRequest.transaction.to + ? { + type: TransactionType.Approve, + tokenAddress: dappRequest.transaction.to, + spender, + } + : undefined + const onConfirmWithTransactionTypeInfo = (): Promise => onConfirm(transactionTypeInfo) + const titleCopy = tokenSymbol + ? isRevoke + ? t('dapp.request.revoke.title', { tokenSymbol }) + : t('dapp.request.approve.title', { tokenSymbol }) + : t('dapp.request.approve.fallbackTitle') + + return ( + } + title={titleCopy} + transactionGasFeeResult={transactionGasFeeResult} + onCancel={onCancel} + onConfirm={onConfirmWithTransactionTypeInfo} + > + + + {isRevoke ? t('dapp.request.revoke.helptext') : t('dapp.request.approve.helptext')} + + + + + ) +} diff --git a/apps/extension/src/app/features/dappRequests/requestContent/EthSend/EthSend.tsx b/apps/extension/src/app/features/dappRequests/requestContent/EthSend/EthSend.tsx new file mode 100644 index 00000000..1499b776 --- /dev/null +++ b/apps/extension/src/app/features/dappRequests/requestContent/EthSend/EthSend.tsx @@ -0,0 +1,165 @@ +import type { GasFeeResult } from '@universe/api' +import { useCallback } from 'react' +import { useDappLastChainId } from 'src/app/features/dapp/hooks' +import { useDappRequestQueueContext } from 'src/app/features/dappRequests/DappRequestQueueContext' +import { usePrepareAndSignEthSendTransaction } from 'src/app/features/dappRequests/hooks/usePrepareAndSignEthSendTransaction' +import { ApproveRequestContent } from 'src/app/features/dappRequests/requestContent/EthSend/Approve/ApproveRequestContent' +import { FallbackEthSendRequestContent } from 'src/app/features/dappRequests/requestContent/EthSend/FallbackEthSend/FallbackEthSend' +import { LPRequestContent } from 'src/app/features/dappRequests/requestContent/EthSend/LP/LPRequestContent' +import { ParsedTransactionRequestContent } from 'src/app/features/dappRequests/requestContent/EthSend/ParsedTransaction/ParsedTransactionRequestContent' +import { Permit2ApproveRequestContent } from 'src/app/features/dappRequests/requestContent/EthSend/Permit2Approve/Permit2ApproveRequestContent' +import { SwapRequestContent } from 'src/app/features/dappRequests/requestContent/EthSend/Swap/SwapRequestContent' +import { WrapRequestContent } from 'src/app/features/dappRequests/requestContent/EthSend/Wrap/WrapRequestContent' +import { DappRequestStoreItemForEthSendTxn } from 'src/app/features/dappRequests/slice' +import { + isApproveRequest, + isLPRequest, + isPermit2ApproveRequest, + isSwapRequest, + isWrapRequest, + SendTransactionRequest, +} from 'src/app/features/dappRequests/types/DappRequestTypes' +import { TransactionTypeInfo } from 'uniswap/src/features/transactions/types/transactionDetails' +import { logger } from 'utilities/src/logger/logger' +import { ErrorBoundary } from 'wallet/src/components/ErrorBoundary/ErrorBoundary' + +interface EthSendRequestContentProps { + request: DappRequestStoreItemForEthSendTxn +} + +export function EthSendRequestContent({ request }: EthSendRequestContentProps): JSX.Element { + const { dappRequest } = request + const { dappUrl, currentAccount, onConfirm, onCancel } = useDappRequestQueueContext() + const chainId = useDappLastChainId(dappUrl) + + const { + gasFeeResult: transactionGasFeeResult, + requestWithGasValues, + preSignedTransaction, + } = usePrepareAndSignEthSendTransaction({ + request, + account: currentAccount, + chainId, + }) + + const onConfirmRequest = useCallback( + async (transactionTypeInfo?: TransactionTypeInfo) => { + await onConfirm({ + request: requestWithGasValues, + transactionTypeInfo, + preSignedTransaction, + }) + }, + [onConfirm, requestWithGasValues, preSignedTransaction], + ) + + const onCancelRequest = useCallback(async () => { + await onCancel(requestWithGasValues) + }, [onCancel, requestWithGasValues]) + + // Use Blockaid transaction scanning for ALL transaction types + // If the API fails, the ErrorBoundary will catch it and fallback to specialized UIs + return ( + + } + onError={(error) => { + if (error) { + logger.error(error, { + tags: { file: 'EthSend', function: 'ErrorBoundary-Blockaid' }, + extra: { + dappRequest, + useSimulationResultUI: true, + }, + }) + } + }} + > + + + ) +} + +/** + * Fallback component that renders the appropriate specialized UI based on transaction type + * Used when simulation result UI is disabled or fails + */ +function SpecializedTransactionFallback({ + dappRequest, + transactionGasFeeResult, + onCancel, + onConfirm, +}: { + dappRequest: SendTransactionRequest + transactionGasFeeResult: GasFeeResult + onCancel: () => Promise + onConfirm: (transactionTypeInfo?: TransactionTypeInfo) => Promise +}): JSX.Element { + switch (true) { + case isSwapRequest(dappRequest): + return ( + + ) + case isPermit2ApproveRequest(dappRequest): + return ( + + ) + case isWrapRequest(dappRequest): + return ( + + ) + case isLPRequest(dappRequest): + return ( + + ) + case isApproveRequest(dappRequest): + return ( + + ) + default: + return ( + + ) + } +} diff --git a/apps/extension/src/app/features/dappRequests/requestContent/EthSend/FallbackEthSend/FallbackEthSend.tsx b/apps/extension/src/app/features/dappRequests/requestContent/EthSend/FallbackEthSend/FallbackEthSend.tsx new file mode 100644 index 00000000..ef55b047 --- /dev/null +++ b/apps/extension/src/app/features/dappRequests/requestContent/EthSend/FallbackEthSend/FallbackEthSend.tsx @@ -0,0 +1,136 @@ +import { GasFeeResult } from '@universe/api' +import { useCallback } from 'react' +import { useTranslation } from 'react-i18next' +import { useDappLastChainId } from 'src/app/features/dapp/hooks' +import { DappRequestContent } from 'src/app/features/dappRequests/DappRequestContent' +import { useDappRequestQueueContext } from 'src/app/features/dappRequests/DappRequestQueueContext' +import { isNonZeroBigNumber } from 'src/app/features/dappRequests/requestContent/EthSend/Swap/utils' +import { SendTransactionRequest } from 'src/app/features/dappRequests/types/DappRequestTypes' +import { Anchor, Flex, Text, TouchableArea } from 'ui/src' +import { AnimatedCopySheets, ExternalLink } from 'ui/src/components/icons' +import { ContentRow } from 'uniswap/src/components/transactions/requests/ContentRow' +import { CopyNotificationType } from 'uniswap/src/features/notifications/slice/types' +import { ExplorerDataType, getExplorerLink } from 'uniswap/src/utils/linking' +import { ellipseMiddle, shortenAddress } from 'utilities/src/addresses' +import { useCopyToClipboard } from 'wallet/src/components/copy/useCopyToClipboard' +import { + SpendingDetails, + SpendingEthDetails, +} from 'wallet/src/features/transactions/TransactionRequest/SpendingDetails' +import { useNoYoloParser } from 'wallet/src/utils/useNoYoloParser' +import { useTransactionCurrencies } from 'wallet/src/utils/useTransactionCurrencies' + +interface FallbackEthSendRequestProps { + transactionGasFeeResult: GasFeeResult + dappRequest: SendTransactionRequest + onCancel: () => Promise + onConfirm: () => Promise +} + +// Minimum valid calldata is '0x' + 4 bytes (8 hex chars) for the function selector +const MIN_CALLDATA_LENGTH = 10 + +export function FallbackEthSendRequestContent({ + dappRequest, + transactionGasFeeResult, + onCancel, + onConfirm, +}: FallbackEthSendRequestProps): JSX.Element | null { + const { t } = useTranslation() + const { dappUrl } = useDappRequestQueueContext() + const activeChain = useDappLastChainId(dappUrl) + + const { value: sending, to: toAddress, chainId: transactionChainId } = dappRequest.transaction + const chainId = transactionChainId || activeChain + const recipientLink = + chainId && toAddress ? getExplorerLink({ chainId, data: toAddress, type: ExplorerDataType.ADDRESS }) : '' + const contractFunction = dappRequest.transaction.type + const calldata = dappRequest.transaction.data ?? '' + + const copyToClipboard = useCopyToClipboard() + + const copyCalldata = useCallback( + () => + copyToClipboard({ + textToCopy: calldata, + copyType: CopyNotificationType.Calldata, + }), + [calldata, copyToClipboard], + ) + const { parsedTransactionData } = useNoYoloParser(dappRequest.transaction, chainId) + const transactionCurrencies = useTransactionCurrencies({ chainId, to: toAddress, parsedTransactionData }) + const showSpendingEthDetails = isNonZeroBigNumber(sending) && chainId + + return ( + + + {showSpendingEthDetails && } + {transactionCurrencies.map((currencyInfo, i) => ( + + ))} + {toAddress && ( + + + + + {shortenAddress({ address: toAddress })} + + + + + + )} + + + {parsedTransactionData?.name || contractFunction || t('common.text.unknown')} + + + {calldata && ( + + + + {calldata.length > MIN_CALLDATA_LENGTH ? ellipseMiddle({ str: calldata }) : calldata} + + + + + )} + + + ) +} diff --git a/apps/extension/src/app/features/dappRequests/requestContent/EthSend/LP/LPRequestContent.tsx b/apps/extension/src/app/features/dappRequests/requestContent/EthSend/LP/LPRequestContent.tsx new file mode 100644 index 00000000..af545233 --- /dev/null +++ b/apps/extension/src/app/features/dappRequests/requestContent/EthSend/LP/LPRequestContent.tsx @@ -0,0 +1,49 @@ +import { GasFeeResult } from '@universe/api' +import { useTranslation } from 'react-i18next' +import { DappRequestContent } from 'src/app/features/dappRequests/DappRequestContent' +import { LPSendTransactionRequest } from 'src/app/features/dappRequests/types/DappRequestTypes' +import { Flex, Text } from 'ui/src' + +interface LPRequestContentProps { + transactionGasFeeResult: GasFeeResult + dappRequest: LPSendTransactionRequest + onCancel: () => Promise + onConfirm: () => Promise +} + +export function LPRequestContent({ + dappRequest, + transactionGasFeeResult, + onCancel, + onConfirm, +}: LPRequestContentProps): JSX.Element { + const { t } = useTranslation() + + return ( + + + {dappRequest.parsedCalldata.commands.map((command) => ( + + {command.commandName} + + ))} + + + ) +} diff --git a/apps/extension/src/app/features/dappRequests/requestContent/EthSend/ParsedTransaction/ParsedTransactionRequestContent.tsx b/apps/extension/src/app/features/dappRequests/requestContent/EthSend/ParsedTransaction/ParsedTransactionRequestContent.tsx new file mode 100644 index 00000000..ca118160 --- /dev/null +++ b/apps/extension/src/app/features/dappRequests/requestContent/EthSend/ParsedTransaction/ParsedTransactionRequestContent.tsx @@ -0,0 +1,74 @@ +import type { GasFeeResult } from '@universe/api' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useDappLastChainId } from 'src/app/features/dapp/hooks' +import { DappRequestContent } from 'src/app/features/dappRequests/DappRequestContent' +import { useDappRequestQueueContext } from 'src/app/features/dappRequests/DappRequestQueueContext' +import { SendTransactionRequest } from 'src/app/features/dappRequests/types/DappRequestTypes' +import { useBooleanState } from 'utilities/src/react/useBooleanState' +import { DappTransactionScanningContent } from 'wallet/src/components/dappRequests/DappTransactionScanningContent' +import { TransactionRiskLevel } from 'wallet/src/features/dappRequests/types' +import { shouldDisableConfirm } from 'wallet/src/features/dappRequests/utils/riskUtils' + +interface ParsedTransactionRequestContentProps { + transactionGasFeeResult: GasFeeResult + dappRequest: SendTransactionRequest + onCancel: () => Promise + onConfirm: () => Promise +} + +/** + * Transaction request content with Blockaid security scanning + * Parses transaction data and displays it with asset transfers, security warnings, and detailed information + */ +export function ParsedTransactionRequestContent({ + dappRequest, + transactionGasFeeResult, + onCancel, + onConfirm, +}: ParsedTransactionRequestContentProps): JSX.Element | null { + const { t } = useTranslation() + const { dappUrl, currentAccount } = useDappRequestQueueContext() + const activeChain = useDappLastChainId(dappUrl) + const { value: confirmedRisk, setValue: setConfirmedRisk } = useBooleanState(false) + // Initialize with null to indicate scan hasn't completed yet + const [riskLevel, setRiskLevel] = useState(null) + + const { chainId: transactionChainId } = dappRequest.transaction + const chainId = transactionChainId ?? activeChain + + // If no valid chainId, throw so that we fall back to the legacy UI + if (!chainId) { + throw new Error('No valid chainId available for transaction scanning') + } + + const disableConfirm = shouldDisableConfirm({ + riskLevel, + confirmedRisk, + hasGasFee: !!transactionGasFeeResult.value, + }) + + return ( + + + + ) +} diff --git a/apps/extension/src/app/features/dappRequests/requestContent/EthSend/Permit2Approve/Permit2ApproveRequestContent.tsx b/apps/extension/src/app/features/dappRequests/requestContent/EthSend/Permit2Approve/Permit2ApproveRequestContent.tsx new file mode 100644 index 00000000..18be8180 --- /dev/null +++ b/apps/extension/src/app/features/dappRequests/requestContent/EthSend/Permit2Approve/Permit2ApproveRequestContent.tsx @@ -0,0 +1,54 @@ +import { GasFeeResult } from '@universe/api' +import { useTranslation } from 'react-i18next' +import { DappRequestContent } from 'src/app/features/dappRequests/DappRequestContent' +import { Permit2ApproveSendTransactionRequest } from 'src/app/features/dappRequests/types/DappRequestTypes' +import { Flex, Text } from 'ui/src' +import { TransactionType, TransactionTypeInfo } from 'uniswap/src/features/transactions/types/transactionDetails' + +interface Permit2ApproveRequestContentProps { + transactionGasFeeResult: GasFeeResult + dappRequest: Permit2ApproveSendTransactionRequest + onCancel: () => Promise + onConfirm: (transactionTypeInfo?: TransactionTypeInfo) => Promise +} + +export function Permit2ApproveRequestContent({ + dappRequest, + transactionGasFeeResult, + onCancel, + onConfirm, +}: Permit2ApproveRequestContentProps): JSX.Element { + const { t } = useTranslation() + const transactionTypeInfo: TransactionTypeInfo | undefined = dappRequest.transaction.to + ? { + type: TransactionType.Permit2Approve, + spender: dappRequest.transaction.to, + } + : undefined + const onConfirmWithTransactionTypeInfo = (): Promise => onConfirm(transactionTypeInfo) + + return ( + + + + {t('dapp.request.permit2approve.helptext')} + + + + ) +} diff --git a/apps/extension/src/app/features/dappRequests/requestContent/EthSend/Swap/SwapDisplay.tsx b/apps/extension/src/app/features/dappRequests/requestContent/EthSend/Swap/SwapDisplay.tsx new file mode 100644 index 00000000..975e5de6 --- /dev/null +++ b/apps/extension/src/app/features/dappRequests/requestContent/EthSend/Swap/SwapDisplay.tsx @@ -0,0 +1,143 @@ +import { GasFeeResult } from '@universe/api' +import { useTranslation } from 'react-i18next' +import { DappRequestContent } from 'src/app/features/dappRequests/DappRequestContent' +import { Flex, Separator, Text } from 'ui/src' +import { ArrowDown } from 'ui/src/components/icons' +import { iconSizes } from 'ui/src/theme' +import { CurrencyLogo } from 'uniswap/src/components/CurrencyLogo/CurrencyLogo' +import { SplitLogo } from 'uniswap/src/components/CurrencyLogo/SplitLogo' +import { UniverseChainId } from 'uniswap/src/features/chains/types' +import { CurrencyInfo } from 'uniswap/src/features/dataApi/types' +import { useLocalizationContext } from 'uniswap/src/features/language/LocalizationContext' +import { getCurrencyAmount, ValueType } from 'uniswap/src/features/tokens/getCurrencyAmount' +import { useUSDCValue } from 'uniswap/src/features/transactions/hooks/useUSDCPriceWrapper' +import { NumberType } from 'utilities/src/format/types' + +// oxlint-disable-next-line complexity -- biome-parity: oxlint is stricter here +export function SwapDisplay({ + inputAmount, + outputAmount, + inputCurrencyInfo, + outputCurrencyInfo, + chainId, + transactionGasFeeResult, + showSmartWalletActivation, + onCancel, + onConfirm, + isUniswapX, + isWrap, + isUnwrap, +}: { + inputAmount: string + outputAmount: string + inputCurrencyInfo: Maybe + outputCurrencyInfo: Maybe + chainId: UniverseChainId | null + transactionGasFeeResult?: GasFeeResult + showSmartWalletActivation?: boolean + onCancel?: () => Promise + onConfirm?: () => Promise + isUniswapX?: boolean + isWrap?: boolean + isUnwrap?: boolean +}): JSX.Element { + const { t } = useTranslation() + const { formatCurrencyAmount } = useLocalizationContext() + + const inputCurrencyAmount = getCurrencyAmount({ + value: inputAmount, + valueType: ValueType.Exact, + currency: inputCurrencyInfo?.currency, + }) + const inputValue = useUSDCValue(inputCurrencyAmount) + + const outputCurrencyAmount = getCurrencyAmount({ + value: outputAmount, + valueType: ValueType.Exact, + currency: outputCurrencyInfo?.currency, + }) + const outputValue = useUSDCValue(outputCurrencyAmount) + + const currencyIn = inputCurrencyInfo?.currency + const currencyOut = outputCurrencyInfo?.currency + const showSplitLogo = Boolean(inputCurrencyInfo?.logoUrl && outputCurrencyInfo?.logoUrl) + const showSwapDetails = Boolean(currencyIn?.symbol && currencyOut?.symbol) + + // Determine the appropriate title based on transaction type + let title = '' + if (isWrap && currencyIn?.symbol) { + title = t('common.wrap', { symbol: currencyIn.symbol }) + } else if (isUnwrap && currencyIn?.symbol) { + title = t('position.wrapped.unwrap', { wrappedToken: currencyIn.symbol }) + } else if (currencyIn?.symbol && currencyOut?.symbol) { + title = t('swap.request.title.full', { + inputCurrencySymbol: currencyIn.symbol, + outputCurrencySymbol: currencyOut.symbol, + }) + } else { + title = t('swap.request.title.short') + } + + return ( + + ) : undefined + } + isUniswapX={isUniswapX} + title={title} + transactionGasFeeResult={transactionGasFeeResult} + showSmartWalletActivation={showSmartWalletActivation} + onCancel={onCancel} + onConfirm={onConfirm} + > + {showSwapDetails && ( + <> + + + + + + {formatCurrencyAmount({ value: inputCurrencyAmount, type: NumberType.TokenTx })} {currencyIn?.symbol} + + + {formatCurrencyAmount({ value: inputValue, type: NumberType.FiatTokenPrice })} + + + + + + + + + {formatCurrencyAmount({ value: outputCurrencyAmount, type: NumberType.TokenTx })}{' '} + {currencyOut?.symbol} + + + {formatCurrencyAmount({ value: outputValue, type: NumberType.FiatTokenPrice })} + + + + + + + )} + + ) +} diff --git a/apps/extension/src/app/features/dappRequests/requestContent/EthSend/Swap/SwapRequestContent.tsx b/apps/extension/src/app/features/dappRequests/requestContent/EthSend/Swap/SwapRequestContent.tsx new file mode 100644 index 00000000..5758e25c --- /dev/null +++ b/apps/extension/src/app/features/dappRequests/requestContent/EthSend/Swap/SwapRequestContent.tsx @@ -0,0 +1,150 @@ +import { GasFeeResult } from '@universe/api' +import { useDappLastChainId } from 'src/app/features/dapp/hooks' +import { useDappRequestQueueContext } from 'src/app/features/dappRequests/DappRequestQueueContext' +import { SwapDisplay } from 'src/app/features/dappRequests/requestContent/EthSend/Swap/SwapDisplay' +import { formatUnits, useSwapDetails } from 'src/app/features/dappRequests/requestContent/EthSend/Swap/utils' +import { UniversalRouterCall } from 'src/app/features/dappRequests/types/UniversalRouterTypes' +import { DEFAULT_NATIVE_ADDRESS, DEFAULT_NATIVE_ADDRESS_LEGACY } from 'uniswap/src/features/chains/evm/defaults' +import { useEnabledChains } from 'uniswap/src/features/chains/hooks/useEnabledChains' +import { toSupportedChainId } from 'uniswap/src/features/chains/utils' +import { CurrencyInfo } from 'uniswap/src/features/dataApi/types' +import { useCurrencyInfo, useNativeCurrencyInfo } from 'uniswap/src/features/tokens/useCurrencyInfo' +import { TransactionType, TransactionTypeInfo } from 'uniswap/src/features/transactions/types/transactionDetails' +import { buildCurrencyId } from 'uniswap/src/utils/currencyId' +import { assert } from 'utilities/src/errors' +import { UniswapXSwapRequest } from 'wallet/src/components/dappRequests/types/Permit2Types' + +function getTransactionTypeInfo({ + inputCurrencyInfo, + outputCurrencyInfo, + inputAmountRaw, + outputAmountRaw, +}: { + inputCurrencyInfo: Maybe + outputCurrencyInfo: Maybe + inputAmountRaw: string + outputAmountRaw: string +}): TransactionTypeInfo | undefined { + return inputCurrencyInfo?.currencyId && outputCurrencyInfo?.currencyId + ? { + type: TransactionType.Swap, + tradeType: 0, // TradeType.EXACT_INPUT, but TradeType doesn't matter for the UI + inputCurrencyId: inputCurrencyInfo.currencyId, + outputCurrencyId: outputCurrencyInfo.currencyId, + inputCurrencyAmountRaw: inputAmountRaw, + expectedOutputCurrencyAmountRaw: outputAmountRaw, + minimumOutputCurrencyAmountRaw: outputAmountRaw, + } + : undefined +} + +interface SwapRequestContentProps { + transactionGasFeeResult: GasFeeResult + parsedCalldata: UniversalRouterCall + showSmartWalletActivation?: boolean + onCancel: () => Promise + onConfirm: (transactionTypeInfo?: TransactionTypeInfo) => Promise +} + +export function SwapRequestContent({ + transactionGasFeeResult, + parsedCalldata, + showSmartWalletActivation, + onCancel, + onConfirm, +}: SwapRequestContentProps): JSX.Element { + const { dappUrl } = useDappRequestQueueContext() + const { defaultChainId } = useEnabledChains() + const activeChain = useDappLastChainId(dappUrl) || defaultChainId + + const { inputIdentifier, outputIdentifier, inputValue, outputValue } = useSwapDetails(parsedCalldata, dappUrl) + + const inputCurrencyInfo = useCurrencyInfo(inputIdentifier) + const outputCurrencyInfo = useCurrencyInfo(outputIdentifier) + + const isFirstCommandWrappingEth = parsedCalldata.commands[0]?.commandName === 'WRAP_ETH' + const isLastCommandUnwrappingEth = + parsedCalldata.commands[parsedCalldata.commands.length - 1]?.commandName === 'UNWRAP_WETH' + + const nativeCurrencyInfo = useNativeCurrencyInfo(activeChain) + const nativeCurrency = nativeCurrencyInfo?.currency + + const nativeInput = + isFirstCommandWrappingEth && nativeCurrency && inputCurrencyInfo?.currency.equals(nativeCurrency.wrapped) + const nativeOutput = + isLastCommandUnwrappingEth && nativeCurrency && outputCurrencyInfo?.currency.equals(nativeCurrency.wrapped) + const currencyInfo0 = nativeInput ? nativeCurrencyInfo : inputCurrencyInfo + const currencyInfo1 = nativeOutput ? nativeCurrencyInfo : outputCurrencyInfo + + const inputAmount = formatUnits(inputValue, inputCurrencyInfo?.currency.decimals || 18) + const outputAmount = formatUnits(outputValue, outputCurrencyInfo?.currency.decimals || 18) + + // TODO (EXT-1083): add USDC values to SwapTransactionTypeInfo and display on notification toast + // Need the raw uint256 amounts, not the exact floating point amounts + const inputAmountRaw = formatUnits(inputValue, 0) + const outputAmountRaw = formatUnits(outputValue, 0) + const transactionTypeInfo = getTransactionTypeInfo({ + inputCurrencyInfo: currencyInfo0, + outputCurrencyInfo: currencyInfo1, + inputAmountRaw, + outputAmountRaw, + }) + const onConfirmWithTransactionTypeInfo = (): Promise => onConfirm(transactionTypeInfo) + + return ( + + ) +} + +// this is a special cased version of SwapRequestContent used for UniswapX swaps +export function UniswapXSwapRequestContent({ typedData }: { typedData: UniswapXSwapRequest }): JSX.Element { + const { defaultChainId } = useEnabledChains() + const { chainId: domainChainId } = typedData.domain + const activeChain = toSupportedChainId(domainChainId) || defaultChainId + + const { token: inputToken, amount: firstAmountInParam } = typedData.message.permitted + const { token: outputToken, startAmount: lastAmountOutParam } = typedData.message.witness.baseOutputs[0] + + const inputCurrencyInfo = useCurrencyInfo(buildCurrencyId(activeChain, inputToken)) + const nativeEthOrOutputToken = outputToken === DEFAULT_NATIVE_ADDRESS ? DEFAULT_NATIVE_ADDRESS_LEGACY : outputToken + const outputCurrencyInfo = useCurrencyInfo(buildCurrencyId(activeChain, nativeEthOrOutputToken)) + + assert( + firstAmountInParam && lastAmountOutParam, + 'SwapRequestContent: All swaps must have a defined input and output amount parameter.', + ) + + const inputAmount = formatUnits( + firstAmountInParam || '0', // should always be defined--`assert` above catches this case + inputCurrencyInfo?.currency.decimals || 18, + ) + const outputAmount = formatUnits( + lastAmountOutParam || '0', // should always be defined--`assert` above catches this case + outputCurrencyInfo?.currency.decimals || 18, + ) + + return ( + + ) +} diff --git a/apps/extension/src/app/features/dappRequests/requestContent/EthSend/Swap/constants.ts b/apps/extension/src/app/features/dappRequests/requestContent/EthSend/Swap/constants.ts new file mode 100644 index 00000000..a5c9b876 --- /dev/null +++ b/apps/extension/src/app/features/dappRequests/requestContent/EthSend/Swap/constants.ts @@ -0,0 +1,5 @@ +import { BigNumber } from '@ethersproject/bignumber' + +export const CONTRACT_BALANCE = BigNumber.from(2).pow(255) +export const MAX_UINT256 = BigNumber.from(2).pow(256).sub(1) +export const MAX_UINT160 = BigNumber.from(2).pow(160).sub(1) diff --git a/apps/extension/src/app/features/dappRequests/requestContent/EthSend/Swap/utils.ts b/apps/extension/src/app/features/dappRequests/requestContent/EthSend/Swap/utils.ts new file mode 100644 index 00000000..22b2ae84 --- /dev/null +++ b/apps/extension/src/app/features/dappRequests/requestContent/EthSend/Swap/utils.ts @@ -0,0 +1,380 @@ +/* oxlint-disable max-depth */ +/* oxlint-disable complexity */ +import { BigNumber, BigNumberish } from '@ethersproject/bignumber' +import { formatUnits as formatUnitsEthers } from 'ethers/lib/utils' +import { useDappLastChainId } from 'src/app/features/dapp/hooks' +import { + CONTRACT_BALANCE, + MAX_UINT160, + MAX_UINT256, +} from 'src/app/features/dappRequests/requestContent/EthSend/Swap/constants' +import { + AmountInMaxParam, + AmountInParam, + AmountOutMinParam, + AmountOutParam, + isAmountInMaxParam, + isAmountInParam, + isAmountMinParam, + isAmountOutMinParam, + isAmountOutParam, + isSettleParam, + isURCommandASwap, + isUrCommandSweep, + isUrCommandUnwrapWeth, + Param, + UniversalRouterCall, + UniversalRouterCommand, + V4SwapExactInParamSchema, + V4SwapExactInSingleParamSchema, + V4SwapExactOutParamSchema, + V4SwapExactOutSingleParamSchema, +} from 'src/app/features/dappRequests/types/UniversalRouterTypes' +import { DEFAULT_NATIVE_ADDRESS, DEFAULT_NATIVE_ADDRESS_LEGACY } from 'uniswap/src/features/chains/evm/defaults' +import { buildCurrencyId } from 'uniswap/src/utils/currencyId' + +// Like ethers.formatUnits except it parses specific constants +export function formatUnits(amount: BigNumberish, units: number): string { + if (BigNumber.from(CONTRACT_BALANCE).eq(amount)) { + return 'CONTRACT_BALANCE' + } + if (BigNumber.from(MAX_UINT256).eq(amount)) { + return 'MAX_UINT256' + } + if (BigNumber.from(MAX_UINT160).eq(amount)) { + return 'MAX_UINT160' + } + + return formatUnitsEthers(amount, units) +} + +export function useSwapDetails( + parsedCalldata: UniversalRouterCall, + dappUrl: string, +): { + inputIdentifier: string | undefined + outputIdentifier: string | undefined + inputValue: string + outputValue: string +} { + const activeChain = useDappLastChainId(dappUrl) + const commands = parsedCalldata.commands + + // Find first and last swap commands + const firstSwapCommand = commands.find(isURCommandASwap) + const lastSwapCommand = commands.findLast(isURCommandASwap) + + // Extract input details from first swap command + const inputDetails = firstSwapCommand ? extractInputDetails(firstSwapCommand) : { address: undefined, value: '0' } + + // Extract output details from last swap command + const outputDetails = lastSwapCommand + ? extractOutputDetails(lastSwapCommand, commands) + : { address: undefined, value: '0' } + + // Normalize addresses (handling ETH address) + const inputAddress = + inputDetails.address === DEFAULT_NATIVE_ADDRESS ? DEFAULT_NATIVE_ADDRESS_LEGACY : inputDetails.address + const outputAddress = + outputDetails.address === DEFAULT_NATIVE_ADDRESS ? DEFAULT_NATIVE_ADDRESS_LEGACY : outputDetails.address + + // Build currency identifiers + const inputIdentifier = activeChain && inputAddress ? buildCurrencyId(activeChain, inputAddress) : undefined + const outputIdentifier = activeChain && outputAddress ? buildCurrencyId(activeChain, outputAddress) : undefined + + return { + inputIdentifier, + outputIdentifier, + inputValue: inputDetails.value, + outputValue: outputDetails.value, + } +} + +// Predicate Functions +function isAmountInOrMaxParam(param: Param): param is AmountInParam | AmountInMaxParam { + return isAmountInParam(param) || isAmountInMaxParam(param) +} + +function isAmountOutMinOrOutParam(param: Param): param is AmountOutMinParam | AmountOutParam { + return isAmountOutMinParam(param) || isAmountOutParam(param) +} + +// Extract input details (address and value) from a swap command +function extractInputDetails(command: UniversalRouterCommand): { address: string | undefined; value: string } { + // Default values + let address: string | undefined + let value = '0' + + // Handle V4 swap commands + if (command.commandName === 'V4_SWAP') { + const v4Details = getTokenDetailsFromV4SwapCommands(command) + address = v4Details.inputAddress + value = v4Details.inputValue || '0' + } + // Handle V2/V3 swap commands + else if (command.commandName.startsWith('V2_SWAP') || command.commandName.startsWith('V3_SWAP')) { + const addressDetails = getTokenAddressesFromV2V3SwapCommands(command) + address = addressDetails.inputAddress + + const amountInParam = command.params.find(isAmountInOrMaxParam) + value = amountInParam?.value || '0' + } + + // Handle edge case where input amount is zero - look for SETTLE parameter + if (address && isZeroBigNumberParam(value)) { + value = getFallbackInputValue(command) || '0' + } + + return { address, value } +} + +// Extract output details (address and value) from a swap command +function extractOutputDetails( + command: UniversalRouterCommand, + allCommands: UniversalRouterCommand[], +): { address: string | undefined; value: string } { + // Default values + let address: string | undefined + let value = '0' + + // Handle V4 swap commands + if (command.commandName === 'V4_SWAP') { + const v4Details = getTokenDetailsFromV4SwapCommands(command) + address = v4Details.outputAddress + value = v4Details.outputValue || '0' + } + // Handle V2/V3 swap commands + else if (command.commandName.startsWith('V2_SWAP') || command.commandName.startsWith('V3_SWAP')) { + const addressDetails = getTokenAddressesFromV2V3SwapCommands(command) + address = addressDetails.outputAddress + + const amountOutParam = command.params.find(isAmountOutMinOrOutParam) + value = amountOutParam?.value || '0' + } + + // Handle special case where V3_SWAP command's amountOutMin param is zero + if (isZeroBigNumberParam(value)) { + value = getFallbackOutputValue(allCommands) || '0' + } + + return { address, value } +} + +// Helper Function to Extract Addresses from V2 and V3 Swap Commands +function getTokenAddressesFromV2V3SwapCommands(command: UniversalRouterCommand): { + inputAddress?: string + outputAddress?: string +} { + let inputAddress: string | undefined + let outputAddress: string | undefined + + const pathParam = command.params.find(({ name }) => name === 'path') + if (!pathParam) { + return { inputAddress, outputAddress } + } + + if (command.commandName.startsWith('V2_SWAP_EXACT')) { + const path = pathParam.value as string[] + if (path.length > 0) { + inputAddress = path[0] + outputAddress = path[path.length - 1] + } + } else if (command.commandName.startsWith('V3_SWAP_EXACT')) { + const path = pathParam.value as { fee: number; tokenIn: string; tokenOut: string }[] + if (path.length > 0) { + const first = path[0] + if (first) { + inputAddress = first.tokenIn + } + const last = path[path.length - 1] + if (last) { + outputAddress = last.tokenOut + } + } + } + + return { inputAddress, outputAddress } +} + +function getTokenDetailsFromV4SwapCommands(command: UniversalRouterCommand): { + inputAddress?: string + outputAddress?: string + inputValue?: string + outputValue?: string +} { + let inputAddress: string | undefined + let outputAddress: string | undefined + let inputValue: string | undefined + let outputValue: string | undefined + + if (command.commandName !== 'V4_SWAP') { + return { inputAddress, outputAddress, inputValue, outputValue } + } + + for (const param of command.params) { + switch (param.name) { + case 'SWAP_EXACT_IN': + { + const parsed = V4SwapExactInParamSchema.safeParse(param) + if (!parsed.success) { + break + } + + for (const p of parsed.data.value) { + // oxlint-disable-next-line typescript/no-unnecessary-condition + if (p.name === 'swap') { + const swap = p.value + + inputAddress = swap.currencyIn + inputValue = swap.amountIn + outputValue = swap.amountOutMinimum + + const lastPath = swap.path[swap.path.length - 1] + if (lastPath) { + outputAddress = lastPath.intermediateCurrency + } + } + } + } + break + + case 'SWAP_EXACT_OUT': + { + const parsed = V4SwapExactOutParamSchema.safeParse(param) + if (!parsed.success) { + break + } + + for (const p of parsed.data.value) { + // oxlint-disable-next-line typescript/no-unnecessary-condition + if (p.name === 'swap') { + const swap = p.value + + outputAddress = swap.currencyOut + outputValue = swap.amountOut + inputValue = swap.amountInMaximum + + const firstPath = swap.path[0] + if (firstPath) { + inputAddress = firstPath.intermediateCurrency + } + } + } + } + break + + case 'SWAP_EXACT_IN_SINGLE': + { + const parsed = V4SwapExactInSingleParamSchema.safeParse(param) + if (!parsed.success) { + break + } + + for (const p of parsed.data.value) { + // oxlint-disable-next-line typescript/no-unnecessary-condition + if (p.name === 'swap') { + const swap = p.value + + inputValue = swap.amountIn + outputValue = swap.amountOutMinimum + + if (swap.zeroForOne) { + inputAddress = swap.poolKey.currency0 + outputAddress = swap.poolKey.currency1 + } else { + inputAddress = swap.poolKey.currency1 + outputAddress = swap.poolKey.currency0 + } + } + } + } + break + + case 'SWAP_EXACT_OUT_SINGLE': + { + const parsed = V4SwapExactOutSingleParamSchema.safeParse(param) + if (!parsed.success) { + break + } + + for (const p of parsed.data.value) { + // oxlint-disable-next-line typescript/no-unnecessary-condition + if (p.name === 'swap') { + const swap = p.value + + outputValue = swap.amountOut + inputValue = swap.amountInMaximum + + if (swap.zeroForOne) { + inputAddress = swap.poolKey.currency0 + outputAddress = swap.poolKey.currency1 + } else { + inputAddress = swap.poolKey.currency1 + outputAddress = swap.poolKey.currency0 + } + } + } + } + break + + default: + break + } + } + + return { inputAddress, outputAddress, inputValue, outputValue } +} + +// Helper function to get fallback output value from sweep or unwrapWeth commands +function getFallbackOutputValue(allCommands?: UniversalRouterCommand[]): string { + if (!allCommands) { + return '0' + } + + const sweepCommand = allCommands.find(isUrCommandSweep) + const unwrapWethCommand = allCommands.find(isUrCommandUnwrapWeth) + + const sweepAmountOutParam = sweepCommand?.params.find(isAmountMinParam) + const unwrapWethAmountOutParam = unwrapWethCommand?.params.find(isAmountMinParam) + + return sweepAmountOutParam?.value || unwrapWethAmountOutParam?.value || '0' +} + +// Helper function to get fallback input value from SETTLE parameter +function getFallbackInputValue(command: UniversalRouterCommand): string { + const potentialSettleParam = command.params.find(isSettleParam) + const settleParam = potentialSettleParam && isSettleParam(potentialSettleParam) ? potentialSettleParam : undefined + const settleAmountValue = settleParam?.value.find((item) => item.name === 'amount') + return settleAmountValue?.value || '0' +} + +export function isNonZeroBigNumber(value: string | undefined): boolean { + if (!value) { + return false + } + + try { + const valueBN = BigNumber.from(value) + return !valueBN.isZero() + } catch { + return false + } +} + +interface BigNumberParam { + type: string + hex: string +} + +const isBigNumberParam = (obj: unknown): obj is BigNumberParam => + typeof obj === 'object' && !!obj && 'hex' in obj && typeof (obj as BigNumberParam).hex === 'string' + +// We have to type this as unknown because BigNumberSchema is any (as defined in apps/extension/src/app/features/dappRequests/types/EthersTypes.ts) +function isZeroBigNumberParam(bigNumberObj: unknown): boolean { + // We treat an undefined or badly formatted param as zero + if (!bigNumberObj || !isBigNumberParam(bigNumberObj)) { + return true + } + + return !isNonZeroBigNumber(bigNumberObj.hex) +} diff --git a/apps/extension/src/app/features/dappRequests/requestContent/EthSend/Wrap/WrapRequestContent.tsx b/apps/extension/src/app/features/dappRequests/requestContent/EthSend/Wrap/WrapRequestContent.tsx new file mode 100644 index 00000000..64730c7b --- /dev/null +++ b/apps/extension/src/app/features/dappRequests/requestContent/EthSend/Wrap/WrapRequestContent.tsx @@ -0,0 +1,109 @@ +import { GasFeeResult } from '@universe/api' +import { useDappLastChainId } from 'src/app/features/dapp/hooks' +import { useDappRequestQueueContext } from 'src/app/features/dappRequests/DappRequestQueueContext' +import { SwapDisplay } from 'src/app/features/dappRequests/requestContent/EthSend/Swap/SwapDisplay' +import { formatUnits } from 'src/app/features/dappRequests/requestContent/EthSend/Swap/utils' +import { WrapSendTransactionRequest } from 'src/app/features/dappRequests/types/DappRequestTypes' +import { useEnabledChains } from 'uniswap/src/features/chains/hooks/useEnabledChains' +import { CurrencyInfo } from 'uniswap/src/features/dataApi/types' +import { useNativeCurrencyInfo, useWrappedNativeCurrencyInfo } from 'uniswap/src/features/tokens/useCurrencyInfo' +import { TransactionType, TransactionTypeInfo } from 'uniswap/src/features/transactions/types/transactionDetails' + +function getTransactionTypeInfo({ + inputCurrencyInfo, + outputCurrencyInfo, + inputAmountRaw, + outputAmountRaw, +}: { + inputCurrencyInfo: Maybe + outputCurrencyInfo: Maybe + inputAmountRaw: string + outputAmountRaw: string +}): TransactionTypeInfo | undefined { + return inputCurrencyInfo?.currencyId && outputCurrencyInfo?.currencyId + ? { + type: TransactionType.Swap, + tradeType: 0, // TradeType.EXACT_INPUT, but TradeType doesn't matter for the UI + inputCurrencyId: inputCurrencyInfo.currencyId, + outputCurrencyId: outputCurrencyInfo.currencyId, + inputCurrencyAmountRaw: inputAmountRaw, + expectedOutputCurrencyAmountRaw: outputAmountRaw, + minimumOutputCurrencyAmountRaw: outputAmountRaw, + } + : undefined +} + +interface WrapRequestContentProps { + transactionGasFeeResult: GasFeeResult + dappRequest: WrapSendTransactionRequest + onCancel: () => Promise + onConfirm: (transactionTypeInfo?: TransactionTypeInfo) => Promise +} + +export function WrapRequestContent({ + transactionGasFeeResult, + dappRequest, + onCancel, + onConfirm, +}: WrapRequestContentProps): JSX.Element { + const { dappUrl } = useDappRequestQueueContext() + const { defaultChainId } = useEnabledChains() + const activeChain = useDappLastChainId(dappUrl) || defaultChainId + const chainId = dappRequest.transaction.chainId || activeChain + + // Determine if this is a wrap (ETH -> WETH) or unwrap (WETH -> ETH) transaction + const isUnwrap = dappRequest.functionSignature === 'withdraw(uint256)' + + // Get native currency and wrapped currency info + const nativeCurrencyInfo = useNativeCurrencyInfo(chainId) + const wrappedCurrencyInfo = useWrappedNativeCurrencyInfo(chainId) + + // For a wrap, native is input and wrapped is output + // For an unwrap, wrapped is input and native is output + const inputCurrencyInfo = isUnwrap ? wrappedCurrencyInfo : nativeCurrencyInfo + const outputCurrencyInfo = isUnwrap ? nativeCurrencyInfo : wrappedCurrencyInfo + + // Extract the amount from the transaction + // For wrap: amount is in the value field + // For unwrap: amount is in the data field (following the function selector) + let amountValue = '0' + if (isUnwrap && dappRequest.transaction.data) { + // Parse the amount from the data field (remove the function selector - first 10 characters including 0x) + const data = dappRequest.transaction.data.toString() + if (data.length > 10) { + amountValue = parseInt(data.slice(10, 74), 16).toString() // for withdraw(uint256), calldata is 36 bytes (4-byte selector + 32-byte argument). 1 byte = 2 hex characters, and data includes the 0x prefix, so select 64 characters starting from 10th character + } + } else { + // For wrap, amount is in the value field + amountValue = dappRequest.transaction.value?.toString() || '0' + } + + const inputAmount = formatUnits(amountValue, inputCurrencyInfo?.currency.decimals || 18) + const outputAmount = inputAmount // 1:1 conversion between ETH and WETH + + // Convert hex string into a decimal string + const amountRaw = formatUnits(amountValue, 0) + const transactionTypeInfo = getTransactionTypeInfo({ + inputCurrencyInfo, + outputCurrencyInfo, + inputAmountRaw: amountRaw, + outputAmountRaw: amountRaw, + }) + + const onConfirmWithTransactionTypeInfo = (): Promise => onConfirm(transactionTypeInfo) + + return ( + + ) +} diff --git a/apps/extension/src/app/features/dappRequests/requestContent/PersonalSign/PersonalSignRequestContent.tsx b/apps/extension/src/app/features/dappRequests/requestContent/PersonalSign/PersonalSignRequestContent.tsx new file mode 100644 index 00000000..ff09badc --- /dev/null +++ b/apps/extension/src/app/features/dappRequests/requestContent/PersonalSign/PersonalSignRequestContent.tsx @@ -0,0 +1,79 @@ +import { toUtf8String } from '@ethersproject/strings' +import { useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useDappLastChainId } from 'src/app/features/dapp/hooks' +import { DappRequestContent } from 'src/app/features/dappRequests/DappRequestContent' +import { useDappRequestQueueContext } from 'src/app/features/dappRequests/DappRequestQueueContext' +import { SignMessageRequest } from 'src/app/features/dappRequests/types/DappRequestTypes' +import { EthMethod } from 'uniswap/src/features/dappRequests/types' +import { logger } from 'utilities/src/logger/logger' +import { containsNonPrintableChars } from 'utilities/src/primitives/string' +import { useBooleanState } from 'utilities/src/react/useBooleanState' +import { DappPersonalSignContent } from 'wallet/src/components/dappRequests/DappPersonalSignContent' +import { TransactionRiskLevel } from 'wallet/src/features/dappRequests/types' +import { shouldDisableConfirm } from 'wallet/src/features/dappRequests/utils/riskUtils' + +interface PersonalSignRequestProps { + dappRequest: SignMessageRequest +} + +export function PersonalSignRequestContent({ dappRequest }: PersonalSignRequestProps): JSX.Element | null { + const { t } = useTranslation() + const { dappUrl, currentAccount } = useDappRequestQueueContext() + const activeChain = useDappLastChainId(dappUrl) + const { value: confirmedRisk, setValue: setConfirmedRisk } = useBooleanState(false) + // Initialize with null to indicate scan hasn't completed yet + const [riskLevel, setRiskLevel] = useState(null) + + // Decode message to UTF-8 + const hexMessage = dappRequest.messageHex + const [utf8Message, setUtf8Message] = useState() + + useEffect(() => { + try { + const decodedMessage = toUtf8String(hexMessage) + setUtf8Message(decodedMessage) + } catch { + // If the message is not valid UTF-8, we'll show the hex message instead + setUtf8Message(undefined) + } + }, [hexMessage]) + + const isDecoded = Boolean(utf8Message && !containsNonPrintableChars(utf8Message)) + const message = (isDecoded ? utf8Message : hexMessage) || hexMessage + const hasLoggedError = useRef(false) + + if (!activeChain) { + if (!hasLoggedError.current) { + logger.error(new Error('No active chain found'), { + tags: { file: 'PersonalSignRequestContent', function: 'PersonalSignRequestContent' }, + }) + hasLoggedError.current = true + } + return null + } + + const disableConfirm = shouldDisableConfirm({ riskLevel, confirmedRisk }) + + return ( + + + + ) +} diff --git a/apps/extension/src/app/features/dappRequests/requestContent/SendCalls/SendCallsRequestContent.tsx b/apps/extension/src/app/features/dappRequests/requestContent/SendCalls/SendCallsRequestContent.tsx new file mode 100644 index 00000000..6fcd5ad8 --- /dev/null +++ b/apps/extension/src/app/features/dappRequests/requestContent/SendCalls/SendCallsRequestContent.tsx @@ -0,0 +1,187 @@ +import { type GasFeeResult } from '@universe/api' +import { useCallback, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useDappLastChainId } from 'src/app/features/dapp/hooks' +import { DappRequestContent } from 'src/app/features/dappRequests/DappRequestContent' +import { useDappRequestQueueContext } from 'src/app/features/dappRequests/DappRequestQueueContext' +import { usePrepareAndSignSendCallsTransaction } from 'src/app/features/dappRequests/hooks/usePrepareAndSignSendCallsTransaction' +import { SwapRequestContent } from 'src/app/features/dappRequests/requestContent/EthSend/Swap/SwapRequestContent' +import { type DappRequestStoreItemForSendCallsTxn } from 'src/app/features/dappRequests/slice' +import { + EthSendTransactionRPCActions, + isBatchedSwapRequest, + type ParsedCall, + type SendCallsRequest, +} from 'src/app/features/dappRequests/types/DappRequestTypes' +import { type UniverseChainId } from 'uniswap/src/features/chains/types' +import { TransactionType, type TransactionTypeInfo } from 'uniswap/src/features/transactions/types/transactionDetails' +import { useBooleanState } from 'utilities/src/react/useBooleanState' +import { BatchedRequestDetailsContent } from 'wallet/src/components/BatchedTransactions/BatchedTransactionDetails' +import { DappSendCallsScanningContent } from 'wallet/src/components/dappRequests/DappSendCallsScanningContent' +import { type TransactionRiskLevel } from 'wallet/src/features/dappRequests/types' +import { shouldDisableConfirm } from 'wallet/src/features/dappRequests/utils/riskUtils' + +interface SendCallsRequestContentProps { + dappRequest: SendCallsRequest + transactionGasFeeResult: GasFeeResult + showSmartWalletActivation?: boolean + onConfirm: (transactionTypeInfo?: TransactionTypeInfo) => Promise + onCancel: () => Promise +} + +/** + * Implementation with Blockaid scanning + */ +function SendCallsRequestContentWithScanning({ + dappRequest, + chainId, + transactionGasFeeResult, + showSmartWalletActivation, + onConfirm, + onCancel, +}: SendCallsRequestContentProps & { chainId: UniverseChainId }): JSX.Element { + const { t } = useTranslation() + const { dappUrl, currentAccount } = useDappRequestQueueContext() + const { value: confirmedRisk, setValue: setConfirmedRisk } = useBooleanState(false) + // Initialize with null to indicate scan hasn't completed yet + const [riskLevel, setRiskLevel] = useState(null) + + const disableConfirm = shouldDisableConfirm({ + riskLevel, + confirmedRisk, + hasGasFee: !!transactionGasFeeResult.value, + }) + + return ( + onConfirm()} + showAddressFooter={false} + > + + + ) +} + +/** + * Fallback for when chainId is not available (required for Blockaid scanning) + */ +function SendCallsRequestContentFallback({ + dappRequest, + transactionGasFeeResult, + showSmartWalletActivation, + onConfirm, + onCancel, +}: SendCallsRequestContentProps): JSX.Element { + const { t } = useTranslation() + const { dappUrl } = useDappRequestQueueContext() + const chainId = useDappLastChainId(dappUrl) + + return ( + onConfirm()} + contentHorizontalPadding="$none" + showSmartWalletActivation={showSmartWalletActivation} + > + + + ) +} + +export function SendCallsRequestHandler({ request }: { request: DappRequestStoreItemForSendCallsTxn }): JSX.Element { + const { dappUrl, currentAccount, onConfirm, onCancel } = useDappRequestQueueContext() + const chainId = useDappLastChainId(dappUrl) ?? request.dappInfo?.lastChainId + + const { dappRequest } = request + + const parsedSwapCalldata = useMemo(() => { + return isBatchedSwapRequest(dappRequest) + ? dappRequest.calls + .filter((call): call is ParsedCall => 'parsedCalldata' in call) + .find((call) => call.contractInteractions === EthSendTransactionRPCActions.Swap)?.parsedCalldata + : undefined + }, [dappRequest]) + + const { gasFeeResult, encodedTransactionRequest, encodedRequestId, showSmartWalletActivation, preSignedTransaction } = + usePrepareAndSignSendCallsTransaction({ + request, + account: currentAccount, + chainId, + }) + + const onConfirmRequest = useCallback(async () => { + const transactionTypeInfo: TransactionTypeInfo = { + type: TransactionType.SendCalls, + encodedTransaction: encodedTransactionRequest, + encodedRequestId, + } + + await onConfirm({ + request, + transactionTypeInfo, + preSignedTransaction, + }) + }, [encodedTransactionRequest, encodedRequestId, onConfirm, preSignedTransaction, request]) + + const onCancelRequest = useCallback(async () => { + await onCancel(request) + }, [onCancel, request]) + + if (chainId) { + return ( + + ) + } + + if (parsedSwapCalldata) { + return ( + + ) + } + + return ( + + ) +} diff --git a/apps/extension/src/app/features/dappRequests/requestContent/SignTypeData/NonStandardTypedDataRequestContent.tsx b/apps/extension/src/app/features/dappRequests/requestContent/SignTypeData/NonStandardTypedDataRequestContent.tsx new file mode 100644 index 00000000..37eff930 --- /dev/null +++ b/apps/extension/src/app/features/dappRequests/requestContent/SignTypeData/NonStandardTypedDataRequestContent.tsx @@ -0,0 +1,27 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { DappRequestContent } from 'src/app/features/dappRequests/DappRequestContent' +import { SignTypedDataRequest } from 'src/app/features/dappRequests/types/DappRequestTypes' +import { NonStandardTypedDataContent } from 'wallet/src/components/dappRequests/SignTypedData/NonStandardTypedDataContent' + +interface NonStandardTypedDataRequestContentProps { + dappRequest: SignTypedDataRequest +} + +export function NonStandardTypedDataRequestContent({ + dappRequest, +}: NonStandardTypedDataRequestContentProps): JSX.Element { + const { t } = useTranslation() + const [checked, setChecked] = useState(false) + + return ( + + + + ) +} diff --git a/apps/extension/src/app/features/dappRequests/requestContent/SignTypeData/SignTypedDataRequestContent.tsx b/apps/extension/src/app/features/dappRequests/requestContent/SignTypeData/SignTypedDataRequestContent.tsx new file mode 100644 index 00000000..02a3c39f --- /dev/null +++ b/apps/extension/src/app/features/dappRequests/requestContent/SignTypeData/SignTypedDataRequestContent.tsx @@ -0,0 +1,156 @@ +import { FeatureFlags, useFeatureFlag } from '@universe/gating' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { DappRequestContent } from 'src/app/features/dappRequests/DappRequestContent' +import { useDappRequestQueueContext } from 'src/app/features/dappRequests/DappRequestQueueContext' +import { ActionCanNotBeCompletedContent } from 'src/app/features/dappRequests/requestContent/ActionCanNotBeCompleted/ActionCanNotBeCompletedContent' +import { UniswapXSwapRequestContent } from 'src/app/features/dappRequests/requestContent/EthSend/Swap/SwapRequestContent' +import { NonStandardTypedDataRequestContent } from 'src/app/features/dappRequests/requestContent/SignTypeData/NonStandardTypedDataRequestContent' +import { SignTypedDataRequest } from 'src/app/features/dappRequests/types/DappRequestTypes' +import { Flex } from 'ui/src' +import { toSupportedChainId } from 'uniswap/src/features/chains/utils' +import { useHasAccountMismatchCallback } from 'uniswap/src/features/smartWallet/mismatch/hooks' +import { logger } from 'utilities/src/logger/logger' +import { useBooleanState } from 'utilities/src/react/useBooleanState' +import { DappSignTypedDataContent } from 'wallet/src/components/dappRequests/DappSignTypedDataContent' +import { Permit2Content } from 'wallet/src/components/dappRequests/SignTypedData/Permit2Content' +import { StandardTypedDataContent } from 'wallet/src/components/dappRequests/SignTypedData/StandardTypedDataContent' +import { isEIP712TypedData } from 'wallet/src/components/dappRequests/types/EIP712Types' +import { isPermit2, isUniswapXSwapRequest } from 'wallet/src/components/dappRequests/types/Permit2Types' +import { ErrorBoundary } from 'wallet/src/components/ErrorBoundary/ErrorBoundary' +import { TransactionRiskLevel } from 'wallet/src/features/dappRequests/types' +import { shouldDisableConfirm } from 'wallet/src/features/dappRequests/utils/riskUtils' + +interface SignTypedDataRequestProps { + dappRequest: SignTypedDataRequest +} + +export function SignTypedDataRequestContent({ dappRequest }: SignTypedDataRequestProps): JSX.Element | null { + return ( + } + onError={(error) => { + if (error) { + logger.error(error, { + tags: { file: 'SignTypedDataRequestContent', function: 'ErrorBoundary' }, + extra: { + typedData: dappRequest.typedData, + address: dappRequest.address, + }, + }) + } + }} + > + + + ) +} + +function SignTypedDataRequestContentInner({ dappRequest }: SignTypedDataRequestProps): JSX.Element | null { + const { t } = useTranslation() + const { dappUrl, currentAccount } = useDappRequestQueueContext() + const { value: confirmedRisk, setValue: setConfirmedRisk } = useBooleanState(false) + const enablePermitMismatchUx = useFeatureFlag(FeatureFlags.EnablePermitMismatchUX) + const getHasMismatch = useHasAccountMismatchCallback() + + // Initialize with null to indicate scan hasn't completed yet + const [riskLevel, setRiskLevel] = useState(null) + + const parsedTypedData = JSON.parse(dappRequest.typedData) + const { chainId: domainChainId } = parsedTypedData.domain || {} + const chainId = toSupportedChainId(domainChainId) + + const hasMismatch = chainId ? getHasMismatch(chainId) : false + if (enablePermitMismatchUx && hasMismatch) { + return + } + + if (!chainId) { + // chainId is required for Blockaid scanning, fall back to basic typed data UI + return + } + + // Extension SignTypedData requests default to v4 method (modern standard) + const method = 'eth_signTypedData_v4' + + // For eth_signTypedData_v4, params are [account, typedData] + const params = [currentAccount.address, dappRequest.typedData] + + const disableConfirm = shouldDisableConfirm({ riskLevel, confirmedRisk }) + + return ( + + + + ) +} + +/** + * Fallback for when chainId is not available (required for Blockaid scanning) + */ +function SignTypedDataRequestContentFallback({ dappRequest }: SignTypedDataRequestProps): JSX.Element | null { + const { t } = useTranslation() + const enablePermitMismatchUx = useFeatureFlag(FeatureFlags.EnablePermitMismatchUX) + const getHasMismatch = useHasAccountMismatchCallback() + + const parsedTypedData = JSON.parse(dappRequest.typedData) + + if (!isEIP712TypedData(parsedTypedData)) { + return + } + + const { chainId: domainChainId } = parsedTypedData.domain || {} + const chainId = toSupportedChainId(domainChainId) + + const hasMismatch = chainId ? getHasMismatch(chainId) : false + if (enablePermitMismatchUx && hasMismatch) { + return + } + + if (isUniswapXSwapRequest(parsedTypedData)) { + return + } + + const isPermit2Request = isPermit2(parsedTypedData) + + return ( + + + {isPermit2Request ? ( + + ) : ( + + )} + + + ) +} diff --git a/apps/extension/src/app/features/dappRequests/saga.ts b/apps/extension/src/app/features/dappRequests/saga.ts new file mode 100644 index 00000000..81e9727c --- /dev/null +++ b/apps/extension/src/app/features/dappRequests/saga.ts @@ -0,0 +1,723 @@ +/* oxlint-disable max-lines */ +import { type Provider } from '@ethersproject/providers' +import { providerErrors, rpcErrors, serializeError } from '@metamask/rpc-errors' +import { FeatureFlags, getFeatureFlag } from '@universe/gating' +import { createSearchParams } from 'react-router' +import { changeChain } from 'src/app/features/dapp/changeChain' +import { type DappInfo, dappStore } from 'src/app/features/dapp/store' +import { getActiveSignerConnectedAccount } from 'src/app/features/dapp/utils' +import { + addRequest, + confirmRequest, + confirmRequestNoDappInfo, + rejectRequest, +} from 'src/app/features/dappRequests/actions' +import type { + DappRequestNoDappInfo, + DappRequestRejectParams, + DappRequestWithDappInfo, + SenderTabInfo, +} from 'src/app/features/dappRequests/shared' +import { dappRequestActions, selectIsRequestConfirming } from 'src/app/features/dappRequests/slice' +import { + type BaseSendTransactionRequest, + type ChangeChainRequest, + type ErrorResponse, + type GetCallsStatusRequest, + type GetCallsStatusResponse, + type GetCapabilitiesRequest, + type ParsedCall, + type SendCallsRequest, + type SendCallsResponse, + type SendTransactionResponse, + type SignMessageRequest, + type SignMessageResponse, + type SignTypedDataRequest, + type SignTypedDataResponse, + type UniswapOpenSidebarRequest, + type UniswapOpenSidebarResponse, +} from 'src/app/features/dappRequests/types/DappRequestTypes' +import { HexadecimalNumberSchema } from 'src/app/features/dappRequests/types/utilityTypes' +import { isWalletUnlocked } from 'src/app/hooks/useIsWalletUnlocked' +import { AppRoutes, HomeQueryParams } from 'src/app/navigation/constants' +import { navigate } from 'src/app/navigation/state' +import { dappResponseMessageChannel } from 'src/background/messagePassing/messageChannels' +import getCalldataInfoFromTransaction from 'src/background/utils/getCalldataInfoFromTransaction' +import { call, put, select, take } from 'typed-redux-saga' +import { hexadecimalStringToInt, toSupportedChainId } from 'uniswap/src/features/chains/utils' +import { DappRequestType, DappResponseType } from 'uniswap/src/features/dappRequests/types' +import { pushNotification } from 'uniswap/src/features/notifications/slice/slice' +import { AppNotificationType } from 'uniswap/src/features/notifications/slice/types' +import { Platform } from 'uniswap/src/features/platforms/types/Platform' +import { getEnabledChainIdsSaga } from 'uniswap/src/features/settings/saga' +import { ExtensionEventName } from 'uniswap/src/features/telemetry/constants' +import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { + TransactionOriginType, + TransactionType, + type TransactionTypeInfo, +} from 'uniswap/src/features/transactions/types/transactionDetails' +import { extractBaseUrl } from 'utilities/src/format/urls' +import { logger } from 'utilities/src/logger/logger' +import { getCallsStatusHelper } from 'wallet/src/features/batchedTransactions/eip5792Utils' +import { addBatchedTransaction } from 'wallet/src/features/batchedTransactions/slice' +import { generateBatchId, getCapabilitiesResponse } from 'wallet/src/features/batchedTransactions/utils' +import { type Call } from 'wallet/src/features/dappRequests/types' +import { + type ExecuteTransactionParams, + executeTransaction, +} from 'wallet/src/features/transactions/executeTransaction/executeTransactionSaga' +import { type SignedTransactionRequest } from 'wallet/src/features/transactions/executeTransaction/types' +import { getProvider, getSignerManager } from 'wallet/src/features/wallet/context' +import { selectActiveAccount, selectHasSmartWalletConsent } from 'wallet/src/features/wallet/selectors' +import { signMessage, signTypedDataMessage } from 'wallet/src/features/wallet/signing/signing' + +export function isDappRequestWithDappInfo( + request: DappRequestNoDappInfo | DappRequestWithDappInfo, +): request is DappRequestWithDappInfo { + return 'dappInfo' in request && Boolean(request.dappInfo) +} + +export function* dappRequestWatcher() { + while (true) { + const { payload, type } = yield* take(addRequest) + + if (payload.dappRequest.type === DappRequestType.GetCapabilities) { + const { senderTabInfo } = payload + const dappUrl = extractBaseUrl(senderTabInfo.url) + if (dappUrl) { + yield* put(dappRequestActions.setMostRecent5792DappUrl(dappUrl)) + } + } + + if (type === addRequest.type) { + yield* call(handleRequest, payload) + } + } +} + +const ACCOUNT_REQUEST_TYPES = [DappRequestType.RequestAccount, DappRequestType.RequestPermissions] +const ACCOUNT_INFO_TYPES = [DappRequestType.GetChainId, DappRequestType.GetAccount] + +/** + * Handles a request from a dApp. + * If it is account-specific, get the active account and add it to the request + * @param requestParams DappRequest and senderTabInfo (required for sending response) + * i think remove all the checks from here and push to later. + */ +// oxlint-disable-next-line complexity +function* handleRequest(requestParams: DappRequestNoDappInfo) { + if ( + requestParams.dappRequest.type === DappRequestType.SendCalls || + requestParams.dappRequest.type === DappRequestType.GetCallsStatus || + requestParams.dappRequest.type === DappRequestType.GetCapabilities + ) { + const eip5792MethodsEnabled = getFeatureFlag(FeatureFlags.Eip5792Methods) + if (!eip5792MethodsEnabled) { + const response: DappRequestRejectParams = { + errorResponse: { + type: DappResponseType.ErrorResponse, + error: serializeError(rpcErrors.methodNotSupported()), + requestId: requestParams.dappRequest.requestId, + }, + senderTabInfo: requestParams.senderTabInfo, + } + yield* put(rejectRequest(response)) + return + } + } + + if (requestParams.dappRequest.type === DappRequestType.UniswapOpenSidebar) { + // We can auto-confirm these requests since they are only for navigating to a certain tab + // At this point the sidebar is already open + yield* call(handleConfirmRequestNoDappInfo, requestParams) + return + } + const activeAccount = yield* select(selectActiveAccount) + if (!activeAccount) { + const response: DappRequestRejectParams = { + errorResponse: { + type: DappResponseType.ErrorResponse, + error: serializeError(providerErrors.unauthorized()), + requestId: requestParams.dappRequest.requestId, + }, + senderTabInfo: requestParams.senderTabInfo, + } + yield* put(rejectRequest(response)) + return + } + + const dappUrl = extractBaseUrl(requestParams.senderTabInfo.url) + const dappInfo = yield* call(dappStore.getDappInfo, dappUrl) + + const isConnectedToDapp = dappInfo && dappInfo.connectedAccounts.length > 0 + const isAccountRequestRequest = ACCOUNT_REQUEST_TYPES.includes(requestParams.dappRequest.type) + + if (!isConnectedToDapp) { + if (requestParams.dappRequest.type === DappRequestType.GetChainId) { + // Allows for eth_chainId for unconnected dapps to advance connection steps + yield* put(confirmRequestNoDappInfo(requestParams)) + } else if (!isAccountRequestRequest) { + // Otherwise, only allows for accounts requests to be handled until connection is confirmed + // TODO(EXT-359): show a warning when the active account is different. + const response: DappRequestRejectParams = { + errorResponse: { + type: DappResponseType.ErrorResponse, + error: serializeError(providerErrors.unauthorized()), + requestId: requestParams.dappRequest.requestId, + }, + senderTabInfo: requestParams.senderTabInfo, + } + yield* put(rejectRequest(response)) + return + } + } + + // Automatically confirm change chain requests if supported + if (requestParams.dappRequest.type === DappRequestType.ChangeChain) { + const chainId = toSupportedChainId(hexadecimalStringToInt(requestParams.dappRequest.chainId)) + if (chainId) { + if (dappInfo) { + yield* call(handleConfirmRequestWithDappInfo, { ...requestParams, dappInfo }) + } else { + yield* call(handleConfirmRequestNoDappInfo, requestParams) + } + if (isWalletUnlocked) { + yield* put( + pushNotification({ + type: AppNotificationType.NetworkChanged, + chainId, + }), + ) + } + } else { + const response: DappRequestRejectParams = { + errorResponse: { + type: DappResponseType.ErrorResponse, + error: serializeError( + providerErrors.custom({ + code: 4902, + message: 'Uniswap Wallet does not support switching to this chain.', + }), + ), + requestId: requestParams.dappRequest.requestId, + }, + senderTabInfo: requestParams.senderTabInfo, + } + if (isWalletUnlocked) { + yield* put( + pushNotification({ + type: AppNotificationType.NotSupportedNetwork, + }), + ) + } + yield* put(rejectRequest(response)) + return + } + } + + if (requestParams.dappRequest.type === DappRequestType.SignTypedData) { + try { + const typedData = requestParams.dappRequest.typedData + const parsedChainId = JSON.parse(typedData)?.domain?.chainId + const formattedChainId = HexadecimalNumberSchema.parse(parsedChainId) + const chainId = toSupportedChainId(formattedChainId) + + if (dappInfo?.lastChainId !== chainId) { + throw new Error('Chain ID on message does not match the chain ID set on the extension.') + } + } catch (error) { + logger.error(error, { tags: { file: 'saga.ts', function: 'handleRequest' } }) + const response: DappRequestRejectParams = { + errorResponse: { + type: DappResponseType.ErrorResponse, + error: serializeError( + providerErrors.custom({ + code: 4902, + message: + error instanceof Error + ? error.message + : 'Chain ID on message from dApp is missing or does not match the chain ID set on the extension.', + }), + ), + requestId: requestParams.dappRequest.requestId, + }, + senderTabInfo: requestParams.senderTabInfo, + } + yield* put(rejectRequest(response)) + } + } + + if (requestParams.dappRequest.type === DappRequestType.SendCalls) { + try { + const parsedChainId = requestParams.dappRequest.chainId + const formattedChainId = HexadecimalNumberSchema.parse(parsedChainId) + const chainId = toSupportedChainId(formattedChainId) + + if (dappInfo?.lastChainId !== chainId) { + throw new Error('Chain ID on message does not match the chain ID set on the extension.') + } + + const parsedCalls = requestParams.dappRequest.calls.map((call): Call | ParsedCall => ({ + ...call, + ...(call.data ? getCalldataInfoFromTransaction({ data: call.data, to: call.to, chainId }) : {}), + })) + + const parsedRequestParams = { + ...requestParams, + dappRequest: { ...requestParams.dappRequest, calls: parsedCalls }, + } + + yield* put( + dappRequestActions.add({ + ...parsedRequestParams, + dappInfo, + }), + ) + return + } catch (error) { + logger.error(error, { tags: { file: 'saga.ts', function: 'handleRequest' } }) + const response: DappRequestRejectParams = { + errorResponse: { + type: DappResponseType.ErrorResponse, + error: serializeError( + providerErrors.custom({ + code: 4902, + message: + error instanceof Error + ? error.message + : 'Chain ID on message from dApp is missing or does not match the chain ID set on the extension.', + }), + ), + requestId: requestParams.dappRequest.requestId, + }, + senderTabInfo: requestParams.senderTabInfo, + } + yield* put(rejectRequest(response)) + } + } + + // Track connection requests when they arrive, before approval + const connectRequestAnalyticsProperties = { + dappUrl, + chainId: dappInfo?.lastChainId, + activeConnectedAddress: dappInfo?.activeConnectedAddress, + connectedAddresses: dappInfo?.connectedAccounts.map((account) => account.address) ?? [], + } + if (isAccountRequestRequest) { + sendAnalyticsEvent(ExtensionEventName.DappConnectRequest, connectRequestAnalyticsProperties) + } + + const shouldAutoConfirmRequest = + dappInfo && + isConnectedToDapp && + (isAccountRequestRequest || + ACCOUNT_INFO_TYPES.includes(requestParams.dappRequest.type) || + requestParams.dappRequest.type === DappRequestType.RevokePermissions || + requestParams.dappRequest.type === DappRequestType.GetCallsStatus || + requestParams.dappRequest.type === DappRequestType.GetCapabilities) + + if (shouldAutoConfirmRequest) { + if (isAccountRequestRequest) { + // Track that a connection was established, even if it's auto-approved + sendAnalyticsEvent(ExtensionEventName.DappConnect, connectRequestAnalyticsProperties) + } + yield* call(handleConfirmRequestWithDappInfo, { ...requestParams, dappInfo }) + } else { + yield* put( + dappRequestActions.add({ + ...requestParams, + dappInfo, + }), + ) + } +} + +export function* handleSendTransaction({ + request, + senderTabInfo: { id }, + dappInfo, + transactionTypeInfo, + preSignedTransaction, +}: { + request: BaseSendTransactionRequest + senderTabInfo: SenderTabInfo + dappInfo: DappInfo + transactionTypeInfo?: TransactionTypeInfo + preSignedTransaction?: SignedTransactionRequest +}) { + const transactionRequest = request.transaction + const { lastChainId, activeConnectedAddress, connectedAccounts } = dappInfo + const account = getActiveSignerConnectedAccount(connectedAccounts, activeConnectedAddress) + const chainId = toSupportedChainId(request.transaction.chainId) + if (request.transaction.chainId && chainId) { + if (lastChainId !== chainId) { + throw new Error(`Mismatched chainId - expected active chain: ${lastChainId}, received: ${chainId}`) + } + } + + const provider = yield* call(getProvider, lastChainId) + + const sendTransactionParams: ExecuteTransactionParams = { + chainId: lastChainId, + account, + options: { request: transactionRequest }, + typeInfo: transactionTypeInfo ?? { + type: TransactionType.Unknown, + dappInfo: { + name: dappInfo.displayName, + address: request.transaction.to, + icon: dappInfo.iconUrl, + }, + }, + transactionOriginType: TransactionOriginType.External, + preSignedTransaction, + } + + const { transactionHash } = yield* call(executeTransaction, sendTransactionParams) + + // Trigger a pending transaction notification after we send the transaction to chain + yield* put( + pushNotification({ + type: AppNotificationType.TransactionPending, + chainId: lastChainId, + }), + ) + + // do not block on this function call since it should happen in parallel + // oxlint-disable-next-line typescript/no-floating-promises + onTransactionSentToChain(transactionHash, provider) + + const response: SendTransactionResponse = { + type: DappResponseType.SendTransactionResponse, + transactionHash, + requestId: request.requestId, + } + yield* call(dappResponseMessageChannel.sendMessageToTab, id, response) +} + +// TODO(EXT-976): Fix chrome notifications to work when the sidepanel is asleep. +async function onTransactionSentToChain(transactionHash: string, provider: Provider): Promise { + // Listen for transaction receipt + const receipt = await provider.waitForTransaction(transactionHash, 1) + + if (receipt.status === 100) { + // Send chrome notification that transaction was successful + chrome.notifications.create({ + type: 'basic', + iconUrl: '', + title: 'Transaction successful', + message: `Transaction ${transactionHash} was successful`, + }) + } +} + +export function* changeChainSaga(request: ChangeChainRequest, { id, url }: SenderTabInfo) { + const updatedChainId = toSupportedChainId(hexadecimalStringToInt(request.chainId)) + const provider = updatedChainId ? yield* call(getProvider, updatedChainId) : undefined + const dappUrl = extractBaseUrl(url) + const activeAccount = yield* select(selectActiveAccount) + const response = changeChain({ + provider, + dappUrl, + updatedChainId, + requestId: request.requestId, + activeConnectedAddress: activeAccount?.address, + }) + yield* call(dappResponseMessageChannel.sendMessageToTab, id, response) +} + +export function* handleSignMessage({ + request, + senderTabInfo: { id }, + dappInfo, +}: { + request: SignMessageRequest + senderTabInfo: SenderTabInfo + dappInfo: DappInfo +}) { + const { requestId, messageHex } = request + const { connectedAccounts, activeConnectedAddress } = dappInfo + const currentAccount = getActiveSignerConnectedAccount(connectedAccounts, activeConnectedAddress) + + const signerManager = yield* call(getSignerManager) + const provider = yield* call(getProvider, dappInfo.lastChainId) + + const signature = yield* call(signMessage, { message: messageHex, account: currentAccount, signerManager, provider }) + + const response: SignMessageResponse = { + type: DappResponseType.SignMessageResponse, + requestId, + signature, + } + + yield* call(dappResponseMessageChannel.sendMessageToTab, id, response) +} + +export function* handleSignTypedData({ + dappRequest, + senderTabInfo, + dappInfo, +}: { + dappRequest: SignTypedDataRequest + senderTabInfo: SenderTabInfo + dappInfo: DappInfo +}) { + try { + const requestId = dappRequest.requestId + const typedData = dappRequest.typedData + + // This should already be handled when request is received, but extra check here + const parsedChainId = JSON.parse(typedData)?.domain?.chainId + const formattedChainId = HexadecimalNumberSchema.parse(parsedChainId) + const chainId = toSupportedChainId(formattedChainId) + if (!chainId) { + throw new Error(!parsedChainId ? 'Missing domain chainId' : 'Unsupported chainId') + } + + const { lastChainId, connectedAccounts, activeConnectedAddress } = dappInfo + + if (lastChainId !== chainId) { + throw new Error(`Mismatched chainId - expected active chain: ${lastChainId}, received: ${chainId}`) + } + + const currentAccount = getActiveSignerConnectedAccount(connectedAccounts, activeConnectedAddress) + const signerManager = yield* call(getSignerManager) + const provider = yield* call(getProvider, lastChainId) + + const signature = yield* call(signTypedDataMessage, { + message: typedData, + account: currentAccount, + signerManager, + provider, + }) + + const response: SignTypedDataResponse = { + type: DappResponseType.SignTypedDataResponse, + requestId, + signature, + } + + yield* call(dappResponseMessageChannel.sendMessageToTab, senderTabInfo.id, response) + } catch (error) { + if (error instanceof Error) { + const errorParams: DappRequestRejectParams = { + errorResponse: { + type: DappResponseType.ErrorResponse, + error: serializeError(rpcErrors.invalidParams(error.message)), + requestId: dappRequest.requestId, + }, + senderTabInfo, + } + yield* put(rejectRequest(errorParams)) + } + logger.error(error, { + tags: { + file: 'saga.ts', + function: 'handleSignTypedData', + }, + extra: { + dappUrl: senderTabInfo.url, + }, + }) + } +} + +export function* handleUniswapOpenSidebarRequest(request: UniswapOpenSidebarRequest, senderTabInfo: SenderTabInfo) { + if (request.tab) { + yield* call(navigate, { + pathname: AppRoutes.Home, + search: createSearchParams({ + [HomeQueryParams.Tab]: request.tab, + }).toString(), + }) + } + const response: UniswapOpenSidebarResponse = { + type: DappResponseType.UniswapOpenSidebarResponse, + requestId: request.requestId, + } + yield* call(dappResponseMessageChannel.sendMessageToTab, senderTabInfo.id, response) +} + +/** + * Handle wallet_getCapabilities request + * This method returns the capabilities supported by the wallet for specific chains + */ +export function* handleGetCapabilities(request: GetCapabilitiesRequest, senderTabInfo: SenderTabInfo) { + const { chains: enabledChains } = yield* call(getEnabledChainIdsSaga, Platform.EVM) + const hasSmartWalletConsent = yield* select(selectHasSmartWalletConsent, request.address) + const chainIds = request.chainIds?.map(hexadecimalStringToInt) ?? enabledChains.map((chain) => chain.valueOf()) + + const response = yield* call(getCapabilitiesResponse, { + request, + chainIds, + hasSmartWalletConsent, + }) + + yield* call(dappResponseMessageChannel.sendMessageToTab, senderTabInfo.id, response) +} + +/** + * Handle wallet_sendCalls request + * This method allows dapps to send a batch of calls to the wallet + */ +export function* handleSendCalls({ + request, + senderTabInfo: { id }, + dappInfo, + transactionTypeInfo, + preSignedTransaction, +}: { + request: SendCallsRequest + senderTabInfo: SenderTabInfo + dappInfo: DappInfo + transactionTypeInfo?: TransactionTypeInfo + preSignedTransaction?: SignedTransactionRequest +}) { + const isSendCallTransaction = transactionTypeInfo?.type === TransactionType.SendCalls + if (!isSendCallTransaction || !transactionTypeInfo.encodedTransaction || !transactionTypeInfo.encodedRequestId) { + const errorResponse: ErrorResponse = { + type: DappResponseType.ErrorResponse, + error: serializeError(rpcErrors.invalidInput()), + requestId: request.requestId, + } + yield* call(dappResponseMessageChannel.sendMessageToTab, id, errorResponse) + return + } + + try { + const chainId = toSupportedChainId(hexadecimalStringToInt(request.chainId)) + if (!chainId) { + const errorResponse: ErrorResponse = { + type: DappResponseType.ErrorResponse, + error: serializeError(rpcErrors.invalidParams('Invalid chainId')), + requestId: request.requestId, + } + yield* call(dappResponseMessageChannel.sendMessageToTab, id, errorResponse) + return + } + + const activeAccount = getActiveSignerConnectedAccount(dappInfo.connectedAccounts, dappInfo.activeConnectedAddress) + + // Generate or use provided batch ID + const batchId = request.id || generateBatchId() + + const { encodedTransaction, encodedRequestId } = transactionTypeInfo + const sendTransactionParams: ExecuteTransactionParams = { + chainId, + account: activeAccount, + typeInfo: { + type: TransactionType.SendCalls, + encodedTransaction, + encodedRequestId, + dappInfo: { + name: dappInfo.displayName, + address: encodedTransaction.to, + icon: dappInfo.iconUrl, + }, + }, + options: { + request: encodedTransaction, + }, + transactionOriginType: TransactionOriginType.External, + preSignedTransaction, + } + + const { transactionHash } = yield* call(executeTransaction, sendTransactionParams) + + yield* put( + addBatchedTransaction({ + batchId, + txHashes: [transactionHash], // Assuming single tx for now, might need update if batching changes + requestId: encodedRequestId, + chainId, + }), + ) + + const response: SendCallsResponse = { + type: DappResponseType.SendCallsResponse, + requestId: request.requestId, + response: { + id: batchId, + capabilities: request.capabilities || {}, + }, + } + + yield* call(dappResponseMessageChannel.sendMessageToTab, id, response) + } catch (error) { + logger.error(error, { + tags: { file: 'dappRequestSaga', function: 'handleSendCalls' }, + extra: { request }, + }) + + const errorResponse: ErrorResponse = { + type: DappResponseType.ErrorResponse, + error: serializeError(rpcErrors.internal(error instanceof Error ? error.message : 'Failed to send calls')), + requestId: request.requestId, + } + yield* call(dappResponseMessageChannel.sendMessageToTab, id, errorResponse) + } +} + +/** + * Handle wallet_getCallsStatus request + * This method returns the status of a call batch that was sent via wallet_sendCalls + */ +export function* handleGetCallsStatus({ + request, + senderTabInfo: { id }, +}: { + request: GetCallsStatusRequest + senderTabInfo: SenderTabInfo + dappInfo: DappInfo +}) { + const activeAccount = yield* select(selectActiveAccount) + if (!activeAccount) { + const errorResponse: ErrorResponse = { + type: DappResponseType.ErrorResponse, + error: serializeError(rpcErrors.internal('No active account found')), + requestId: request.requestId, + } + yield* call(dappResponseMessageChannel.sendMessageToTab, id, errorResponse) + return + } + + const { data, error } = yield* call(getCallsStatusHelper, request.batchId, activeAccount.address) + + if (error || !data) { + const errorResponse: ErrorResponse = { + type: DappResponseType.ErrorResponse, + error: serializeError(rpcErrors.invalidParams(error)), + requestId: request.requestId, + } + yield* call(dappResponseMessageChannel.sendMessageToTab, id, errorResponse) + return + } + + const response: GetCallsStatusResponse = { + type: DappResponseType.GetCallsStatusResponse, + requestId: request.requestId, + response: data, + } + + yield* call(dappResponseMessageChannel.sendMessageToTab, id, response) +} + +function* isRequestConfirming(requestId: string) { + const isConfirming = yield* select(selectIsRequestConfirming, requestId) + return isConfirming +} + +function* handleConfirmRequestWithDappInfo(request: DappRequestWithDappInfo) { + if (yield* isRequestConfirming(request.dappRequest.requestId)) { + return + } + yield* put(confirmRequest(request)) +} + +function* handleConfirmRequestNoDappInfo(request: DappRequestNoDappInfo) { + if (yield* isRequestConfirming(request.dappRequest.requestId)) { + return + } + yield* put(confirmRequestNoDappInfo(request)) +} diff --git a/apps/extension/src/app/features/dappRequests/sagas/prepareAndSignDappTransactionSaga.ts b/apps/extension/src/app/features/dappRequests/sagas/prepareAndSignDappTransactionSaga.ts new file mode 100644 index 00000000..cb194403 --- /dev/null +++ b/apps/extension/src/app/features/dappRequests/sagas/prepareAndSignDappTransactionSaga.ts @@ -0,0 +1,59 @@ +import type { PrepareAndSignDappTransactionParams } from 'src/app/features/dappRequests/types/preSignedDappTransaction' +import { call } from 'typed-redux-saga' +import { + handleTransactionPreparationError, + prepareTransactionServices, + signSingleTransaction, +} from 'wallet/src/features/transactions/shared/baseTransactionPreparationSaga' +import { + DelegationType, + type TransactionSagaDependencies, +} from 'wallet/src/features/transactions/types/transactionSagaDependencies' + +/** + * Factory function that creates a prepare and sign dapp transaction saga with injected dependencies + */ +export function createPrepareAndSignDappTransactionSaga(dependencies: TransactionSagaDependencies) { + return function* prepareAndSignDappTransaction(params: PrepareAndSignDappTransactionParams) { + const { account, chainId, onSuccess, onFailure, request } = params + + try { + // Use shared service preparation utility + const { transactionService, calculatedNonce } = yield* call(prepareTransactionServices, dependencies, { + account, + chainId, + submitViaPrivateRpc: false, + delegationType: DelegationType.Auto, + request, + }) + + // Use shared transaction signing utility + const signingResult = yield* call(signSingleTransaction, transactionService, { + chainId, + account, + request, + nonce: calculatedNonce?.nonce, + submitViaPrivateRpc: false, + }) + + // Call success callback if provided + onSuccess?.(signingResult.signedTransaction) + + return signingResult.signedTransaction + } catch (error) { + // Handle error using shared error handling utility + const formattedError = handleTransactionPreparationError(dependencies, { + error, + chainId, + errorConfig: { + sagaName: 'prepareAndSignDappTransactionSaga', + functionName: 'prepareAndSignDappTransaction', + }, + onFailure, + }) + + // Re-throw the formatted error to maintain saga error propagation + throw formattedError + } + } +} diff --git a/apps/extension/src/app/features/dappRequests/shared.ts b/apps/extension/src/app/features/dappRequests/shared.ts new file mode 100644 index 00000000..c6c8249a --- /dev/null +++ b/apps/extension/src/app/features/dappRequests/shared.ts @@ -0,0 +1,32 @@ +import type { DappInfo } from 'src/app/features/dapp/store' +import type { DappRequest, ErrorResponse } from 'src/app/features/dappRequests/types/DappRequestTypes' +import type { DappRequestMessageSchema } from 'src/background/messagePassing/types/requests' +import type { TransactionTypeInfo } from 'uniswap/src/features/transactions/types/transactionDetails' +import type { SignedTransactionRequest } from 'wallet/src/features/transactions/executeTransaction/types' +import type { z } from 'zod' + +export type SenderTabInfo = z.infer['senderTabInfo'] + +export enum DappRequestStatus { + Pending = 'pending', + Confirming = 'confirming', +} + +export interface DappRequestStoreItem { + dappRequest: DappRequest + senderTabInfo: SenderTabInfo + dappInfo?: DappInfo + isSidebarClosed: boolean | undefined +} + +type OptionalTransactionFields = { + transactionTypeInfo?: TransactionTypeInfo + preSignedTransaction?: SignedTransactionRequest +} + +export type DappRequestNoDappInfo = Omit & OptionalTransactionFields +export type DappRequestWithDappInfo = Required & OptionalTransactionFields +export interface DappRequestRejectParams { + errorResponse: ErrorResponse + senderTabInfo: SenderTabInfo +} diff --git a/apps/extension/src/app/features/dappRequests/slice.ts b/apps/extension/src/app/features/dappRequests/slice.ts new file mode 100644 index 00000000..3b598430 --- /dev/null +++ b/apps/extension/src/app/features/dappRequests/slice.ts @@ -0,0 +1,117 @@ +import { createSlice, type PayloadAction } from '@reduxjs/toolkit' +import { confirmRequest, confirmRequestNoDappInfo } from 'src/app/features/dappRequests/actions' +import type { DappRequestStoreItem } from 'src/app/features/dappRequests/shared' +import { DappRequestStatus } from 'src/app/features/dappRequests/shared' +import { + isConnectionRequest, + isSendCallsRequest, + isSendTransactionRequest, + type SendCallsRequest, + type SendTransactionRequest, +} from 'src/app/features/dappRequests/types/DappRequestTypes' + +type RequestId = string +export type WithMetadata = T & { + createdAt: number + status: DappRequestStatus +} +export interface DappRequestState { + requests: Record> + mostRecent5792DappUrl: string | null +} + +const initialDappRequestState: DappRequestState = { + requests: {}, // record of requestId -> request + mostRecent5792DappUrl: null, +} + +// Enforces that a request object in state is for an eth send txn request +export interface DappRequestStoreItemForEthSendTxn extends WithMetadata { + dappRequest: SendTransactionRequest +} + +export function isDappRequestStoreItemForEthSendTxn( + request: WithMetadata, +): request is DappRequestStoreItemForEthSendTxn { + return isSendTransactionRequest(request.dappRequest) +} + +export interface DappRequestStoreItemForSendCallsTxn extends WithMetadata { + dappRequest: SendCallsRequest +} + +export function isDappRequestStoreItemForSendCallsTxn( + request: WithMetadata, +): request is DappRequestStoreItemForSendCallsTxn { + return isSendCallsRequest(request.dappRequest) +} + +const selectDappRequestsArray = (state: DappRequestState) => + // sort by createdAt ascending (oldest first) to preserve order of requests + Object.values(state.requests).sort((a, b) => a.createdAt - b.createdAt) + +const slice = createSlice({ + name: 'dappRequests', + initialState: initialDappRequestState, + reducers: { + add: (state, action: PayloadAction) => { + if (isConnectionRequest(action.payload.dappRequest)) { + const requests = selectDappRequestsArray(state) + for (const request of requests) { + // Remove existing connection requests from the same tab and of the same type + if ( + request.senderTabInfo.id === action.payload.senderTabInfo.id && + request.dappRequest.type === action.payload.dappRequest.type + ) { + delete state.requests[request.dappRequest.requestId] + } + } + } + state.requests[action.payload.dappRequest.requestId] = { + ...action.payload, + // set the status to pending state + status: DappRequestStatus.Pending, + // set the createdAt time so we can sort by time + createdAt: Date.now(), + } + }, + remove: (state, action: PayloadAction) => { + const requestId = action.payload + delete state.requests[requestId] + }, + removeAll: (state) => { + state.requests = {} + }, + setMostRecent5792DappUrl: (state, action: PayloadAction) => { + state.mostRecent5792DappUrl = action.payload + }, + reset: () => initialDappRequestState, + }, + extraReducers: (builder) => { + // update status of request to confirming + // on confirmRequest and confirmRequestNoDappInfo + builder.addMatcher( + (action) => action.type === confirmRequest.type || action.type === confirmRequestNoDappInfo.type, + (state, action) => { + const { dappRequest } = action['payload'] + const request = state.requests[dappRequest.requestId] + if (request) { + request.status = DappRequestStatus.Confirming + } + }, + ) + }, +}) + +export const selectAllDappRequests = (state: { + dappRequests: DappRequestState +}): WithMetadata[] => selectDappRequestsArray(state.dappRequests) + +export const selectIsRequestConfirming = (state: { dappRequests: DappRequestState }, requestId: string): boolean => + state.dappRequests.requests[requestId]?.status === DappRequestStatus.Confirming + +export const selectMostRecent5792DappUrl = (state: { dappRequests: DappRequestState }): string | null => + state.dappRequests.mostRecent5792DappUrl + +export const dappRequestActions = slice.actions +export const dappRequestReducer = slice.reducer diff --git a/apps/extension/src/app/features/dappRequests/types/DappRequestTypes.ts b/apps/extension/src/app/features/dappRequests/types/DappRequestTypes.ts new file mode 100644 index 00000000..50edb9a9 --- /dev/null +++ b/apps/extension/src/app/features/dappRequests/types/DappRequestTypes.ts @@ -0,0 +1,463 @@ +/* oxlint-disable import/no-unused-modules */ +import { EthereumRpcErrorSchema } from 'src/app/features/dappRequests/types/ErrorTypes' +import { EthersTransactionRequestSchema } from 'src/app/features/dappRequests/types/EthersTypes' +import { NonfungiblePositionManagerCallSchema } from 'src/app/features/dappRequests/types/NonfungiblePositionManagerTypes' +import { UniversalRouterCallSchema } from 'src/app/features/dappRequests/types/UniversalRouterTypes' +import { HomeTabs } from 'src/app/navigation/constants' +import { PermissionRequestSchema, PermissionSchema } from 'src/contentScript/WindowEthereumRequestTypes' +import { MessageSchema } from 'uniswap/src/extension/messagePassing/messageTypes' +import { DappRequestType, DappResponseType } from 'uniswap/src/features/dappRequests/types' +import { + BatchIdSchema, + CallSchema, + CapabilitySchema, + GetCallsStatusResultSchema, + SendCallsResultSchema, +} from 'wallet/src/features/dappRequests/types' +import { z } from 'zod' + +// SCHEMAS + TYPES + +const BaseDappRequestSchema = MessageSchema.extend({ + requestId: z.string(), + type: z.nativeEnum(DappRequestType), +}) + +const BaseDappResponseSchema = MessageSchema.extend({ + requestId: z.string(), + type: z.nativeEnum(DappResponseType), +}) + +export const SignMessageRequestSchema = BaseDappRequestSchema.extend({ + type: z.literal(DappRequestType.SignMessage), + messageHex: z.string(), + address: z.string(), +}) +export type SignMessageRequest = z.infer + +export const SignTypedDataRequestSchema = BaseDappRequestSchema.extend({ + type: z.literal(DappRequestType.SignTypedData), + typedData: z.string(), + address: z.string(), +}) +export type SignTypedDataRequest = z.infer + +export const SignTransactionRequestSchema = BaseDappRequestSchema.extend({ + type: z.literal(DappRequestType.SignTransaction), + transaction: EthersTransactionRequestSchema, +}) +export type SignTransactionRequest = z.infer + +export const UniswapOpenSidebarRequestSchema = BaseDappRequestSchema.extend({ + type: z.literal(DappRequestType.UniswapOpenSidebar), + tab: z.nativeEnum(HomeTabs).optional(), +}) +export type UniswapOpenSidebarRequest = z.infer + +// ENUMS +export enum EthSendTransactionRPCActions { + Approve = 'Approve', + Permit2Approve = 'Permit2Approve', + ContractInteraction = 'ContractInteraction', + Swap = 'Swap', + Wrap = 'Wrap', + LP = 'LP', + Unknown = 'Unknown', +} + +export const BaseSendTransactionRequestSchema = BaseDappRequestSchema.extend({ + type: z.literal(DappRequestType.SendTransaction), + transaction: EthersTransactionRequestSchema, + functionSignature: z.string().optional(), + contractInteractions: z.nativeEnum(EthSendTransactionRPCActions).optional(), +}) +export type BaseSendTransactionRequest = z.infer + +const ApproveSendTransactionRequestSchema = BaseSendTransactionRequestSchema.extend({ + contractInteractions: z.literal(EthSendTransactionRPCActions.Approve), +}) +export type ApproveSendTransactionRequest = z.infer + +const Permit2ApproveSendTransactionRequestSchema = BaseSendTransactionRequestSchema.extend({ + contractInteractions: z.literal(EthSendTransactionRPCActions.Permit2Approve), +}) +export type Permit2ApproveSendTransactionRequest = z.infer + +const ContractInteractionSendTransactionRequestSchema = BaseSendTransactionRequestSchema.extend({ + contractInteractions: z.literal(EthSendTransactionRPCActions.ContractInteraction), +}) + +const SwapSendTransactionRequestSchema = BaseSendTransactionRequestSchema.extend({ + contractInteractions: z.literal(EthSendTransactionRPCActions.Swap), + parsedCalldata: UniversalRouterCallSchema, +}) +export type SwapSendTransactionRequest = z.infer + +const WrapSendTransactionRequestSchema = BaseSendTransactionRequestSchema.extend({ + contractInteractions: z.literal(EthSendTransactionRPCActions.Wrap), +}) +export type WrapSendTransactionRequest = z.infer + +const LPSendTransactionRequestSchema = BaseSendTransactionRequestSchema.extend({ + contractInteractions: z.literal(EthSendTransactionRPCActions.LP), + parsedCalldata: NonfungiblePositionManagerCallSchema, +}) +export type LPSendTransactionRequest = z.infer + +const UnknownContractInteractionSendTransactionRequestSchema = BaseSendTransactionRequestSchema.extend({ + contractInteractions: z.literal(EthSendTransactionRPCActions.Unknown).optional(), +}) + +export const SendTransactionRequestSchema = z.union([ + ApproveSendTransactionRequestSchema, + Permit2ApproveSendTransactionRequestSchema, + ContractInteractionSendTransactionRequestSchema, + SwapSendTransactionRequestSchema, + WrapSendTransactionRequestSchema, + LPSendTransactionRequestSchema, + UnknownContractInteractionSendTransactionRequestSchema, +]) + +export type SendTransactionRequest = z.infer + +export const ChangeChainRequestSchema = BaseDappRequestSchema.extend({ + type: z.literal(DappRequestType.ChangeChain), + chainId: z.string(), +}) +export type ChangeChainRequest = z.infer + +export const GetAccountRequestSchema = BaseDappRequestSchema.extend({ + type: z.literal(DappRequestType.GetAccount), +}) +export type GetAccountRequest = z.infer + +export const RequestAccountRequestSchema = BaseDappRequestSchema.extend({ + type: z.literal(DappRequestType.RequestAccount), +}) +export type RequestAccountRequest = z.infer + +export const GetChainIdRequestSchema = BaseDappRequestSchema.extend({ + type: z.literal(DappRequestType.GetChainId), +}) +export type GetChainIdRequest = z.infer + +export const GetPermissionsRequestSchema = BaseDappRequestSchema.extend({ + type: z.literal(DappRequestType.GetPermissions), +}) + +export type GetPermissionsRequest = z.infer + +export const RequestPermissionsRequestSchema = BaseDappRequestSchema.extend({ + type: z.literal(DappRequestType.RequestPermissions), + permissions: PermissionRequestSchema, +}) + +export type RequestPermissionsRequest = z.infer + +export const RevokePermissionsRequestSchema = BaseDappRequestSchema.extend({ + type: z.literal(DappRequestType.RevokePermissions), + permissions: PermissionRequestSchema, +}) + +export type RevokePermissionsRequest = z.infer + +export const SignMessageResponseSchema = BaseDappResponseSchema.extend({ + type: z.literal(DappResponseType.SignMessageResponse), + signature: z.string().optional(), +}) +export type SignMessageResponse = z.infer + +export const SignTypedDataResponseSchema = BaseDappResponseSchema.extend({ + type: z.literal(DappResponseType.SignTypedDataResponse), + signature: z.string(), +}) +export type SignTypedDataResponse = z.infer + +export const SignTransactionResponseSchema = BaseDappResponseSchema.extend({ + type: z.literal(DappResponseType.SignTransactionResponse), + signedTransactionHash: z.string().optional(), +}) +export type SignTransactionResponse = z.infer + +export const SendTransactionResponseSchema = BaseDappResponseSchema.extend({ + type: z.literal(DappResponseType.SendTransactionResponse), + transactionHash: z.string(), +}) +export type SendTransactionResponse = z.infer + +export const ChangeChainResponseSchema = BaseDappResponseSchema.extend({ + type: z.literal(DappResponseType.ChainChangeResponse), + chainId: z.string(), + providerUrl: z.string(), +}) +export type ChangeChainResponse = z.infer + +export const AccountResponseSchema = BaseDappResponseSchema.extend({ + type: z.literal(DappResponseType.AccountResponse), + connectedAddresses: z.array(z.string()), + chainId: z.string(), + providerUrl: z.string(), +}) +export type AccountResponse = z.infer + +export const ChainIdResponseSchema = BaseDappResponseSchema.extend({ + type: z.literal(DappResponseType.ChainIdResponse), + chainId: z.string(), +}) +export type ChainIdResponse = z.infer + +export const GetPermissionsResponseSchema = BaseDappResponseSchema.extend({ + type: z.literal(DappResponseType.GetPermissionsResponse), + permissions: z.array(PermissionSchema), +}) +export type GetPermissionsResponse = z.infer + +export const RequestPermissionsResponseSchema = BaseDappResponseSchema.extend({ + type: z.literal(DappResponseType.RequestPermissionsResponse), + permissions: z.array(PermissionSchema), + accounts: z.optional(AccountResponseSchema.omit({ requestId: true, type: true })), +}) +export type RequestPermissionsResponse = z.infer + +export const RevokePermissionsResponseSchema = BaseDappResponseSchema.extend({ + type: z.literal(DappResponseType.RevokePermissionsResponse), +}) +export type RevokePermissionsResponse = z.infer + +export const UniswapOpenSidebarResponseSchema = BaseDappResponseSchema.extend({ + type: z.literal(DappResponseType.UniswapOpenSidebarResponse), +}) +export type UniswapOpenSidebarResponse = z.infer + +export const ErrorResponseSchema = BaseDappResponseSchema.extend({ + type: z.literal(DappResponseType.ErrorResponse), + error: EthereumRpcErrorSchema, +}) +export type ErrorResponse = z.infer + +const ParsedCallSchema = CallSchema.extend({ + functionSignature: z.string().optional(), + contractInteractions: z.nativeEnum(EthSendTransactionRPCActions).optional(), + parsedCalldata: UniversalRouterCallSchema.optional(), +}) +export type ParsedCall = z.infer + +export const SendCallsRequestSchema = BaseDappRequestSchema.extend({ + type: z.literal(DappRequestType.SendCalls), + version: z.string(), + id: BatchIdSchema.optional(), + from: z.string().optional(), + chainId: z.string(), + calls: z.array(z.union([CallSchema, ParsedCallSchema])), + capabilities: z.record(z.string(), CapabilitySchema).optional(), +}) +export type SendCallsRequest = z.infer + +export const GetCallsStatusRequestSchema = BaseDappRequestSchema.extend({ + type: z.literal(DappRequestType.GetCallsStatus), + batchId: z.string(), +}) +export type GetCallsStatusRequest = z.infer + +export const SendCallsResponseSchema = BaseDappResponseSchema.extend({ + type: z.literal(DappResponseType.SendCallsResponse), + response: SendCallsResultSchema, +}) +export type SendCallsResponse = z.infer + +export const GetCallsStatusResponseSchema = BaseDappResponseSchema.extend({ + type: z.literal(DappResponseType.GetCallsStatusResponse), + response: GetCallsStatusResultSchema, +}) +export type GetCallsStatusResponse = z.infer + +export const GetCapabilitiesRequestSchema = BaseDappRequestSchema.extend({ + type: z.literal(DappRequestType.GetCapabilities), + chainIds: z.array(z.string()).optional(), + address: z.string(), +}) +export type GetCapabilitiesRequest = z.infer + +export const GetCapabilitiesResponseSchema = BaseDappResponseSchema.extend({ + type: z.literal(DappResponseType.GetCapabilitiesResponse), + response: z.record(z.string(), CapabilitySchema), +}) +export type GetCapabilitiesResponse = z.infer + +const BatchedSwapSendTransactionRequestSchema = SendCallsRequestSchema.extend({ + calls: z.array(ParsedCallSchema).refine( + (calls) => + calls.filter((call) => { + const parsedCallResult = ParsedCallSchema.safeParse(call) + return ( + parsedCallResult.success && parsedCallResult.data.contractInteractions === EthSendTransactionRPCActions.Swap + ) + }).length === 1, + { + message: 'Exactly one call must have contractInteractions set to Swap', + }, + ), +}) +export type BatchedSwapSendTransactionRequest = z.infer + +export const DappRequestSchema = z.union([ + ChangeChainRequestSchema, + GetAccountRequestSchema, + GetChainIdRequestSchema, + GetPermissionsRequestSchema, + RequestAccountRequestSchema, + RequestPermissionsRequestSchema, + RevokePermissionsRequestSchema, + SendTransactionRequestSchema, + SignMessageRequestSchema, + SignTypedDataRequestSchema, + SignTransactionRequestSchema, + UniswapOpenSidebarRequestSchema, + SendCallsRequestSchema, + GetCallsStatusRequestSchema, + GetCapabilitiesRequestSchema, +]) + +const DappResponseSchema = z.union([ + AccountResponseSchema, + ChangeChainResponseSchema, + ChainIdResponseSchema, + ErrorResponseSchema, + GetPermissionsResponseSchema, + RequestPermissionsResponseSchema, + SignMessageResponseSchema, + SignTypedDataResponseSchema, + SignTransactionResponseSchema, + SendTransactionResponseSchema, + UniswapOpenSidebarResponseSchema, + SendCallsResponseSchema, + GetCallsStatusResponseSchema, + GetCapabilitiesResponseSchema, +]) + +export type DappRequest = z.infer +type DappResponse = z.infer + +// VALIDATORS / UTILS + +export function isValidDappRequest(message: unknown): message is DappRequest { + return DappRequestSchema.safeParse(message).success +} + +export function isValidDappResponse(message: unknown): message is DappResponse { + return DappResponseSchema.safeParse(message).success +} + +export function isErrorResponse(response: unknown): response is ErrorResponse { + return ErrorResponseSchema.safeParse(response).success +} + +export function isValidSendTransactionResponse(response: unknown): response is SendTransactionResponse { + return SendTransactionResponseSchema.safeParse(response).success +} + +export function isValidSignTransactionResponse(response: unknown): response is SignTransactionResponse { + return SignTransactionResponseSchema.safeParse(response).success +} + +export function isValidSignMessageResponse(response: unknown): response is SignMessageResponse { + return SignMessageResponseSchema.safeParse(response).success +} + +export function isValidSignTypedDataResponse(response: unknown): response is SignTypedDataResponse { + return SignTypedDataResponseSchema.safeParse(response).success +} + +export function isValidChangeChainResponse(response: unknown): response is ChangeChainResponse { + return ChangeChainResponseSchema.safeParse(response).success +} + +export function isValidChainIdResponse(response: unknown): response is ChainIdResponse { + return ChainIdResponseSchema.safeParse(response).success +} + +export function isValidAccountResponse(response: unknown): response is AccountResponse { + return AccountResponseSchema.safeParse(response).success +} + +export function isValidGetPermissionsResponse(response: unknown): response is GetPermissionsResponse { + return GetPermissionsResponseSchema.safeParse(response).success +} + +export function isValidRequestPermissionsResponse(response: unknown): response is RequestPermissionsResponse { + return RequestPermissionsResponseSchema.safeParse(response).success +} + +export function isApproveRequest(request: SendTransactionRequest): request is ApproveSendTransactionRequest { + return ApproveSendTransactionRequestSchema.safeParse(request).success +} + +export function isPermit2ApproveRequest( + request: SendTransactionRequest, +): request is Permit2ApproveSendTransactionRequest { + return Permit2ApproveSendTransactionRequestSchema.safeParse(request).success +} + +export function isSwapRequest(request: SendTransactionRequest): request is SwapSendTransactionRequest { + return SwapSendTransactionRequestSchema.safeParse(request).success +} + +export function isBatchedSwapRequest(request: SendCallsRequest): request is BatchedSwapSendTransactionRequest { + return BatchedSwapSendTransactionRequestSchema.safeParse(request).success +} + +export function isSignTypedDataRequest(request: DappRequest): request is SignTypedDataRequest { + return SignTypedDataRequestSchema.safeParse(request).success +} + +export function isChangeChainRequest(request: DappRequest): request is ChangeChainRequest { + return ChangeChainRequestSchema.safeParse(request).success +} + +export function isSignMessageRequest(request: DappRequest): request is SignMessageRequest { + return SignMessageRequestSchema.safeParse(request).success +} + +export function isLPRequest(request: SendTransactionRequest): request is LPSendTransactionRequest { + return LPSendTransactionRequestSchema.safeParse(request).success +} + +export function isSendTransactionRequest(request: DappRequest): request is SendTransactionRequest { + return SendTransactionRequestSchema.safeParse(request).success +} + +export function isGetAccountRequest(request: DappRequest): request is GetAccountRequest { + return GetAccountRequestSchema.safeParse(request).success +} + +export function isRequestAccountRequest(request: DappRequest): request is RequestAccountRequest { + return RequestAccountRequestSchema.safeParse(request).success +} + +export function isRequestPermissionsRequest(request: DappRequest): request is RequestPermissionsRequest { + return RequestPermissionsRequestSchema.safeParse(request).success +} + +export function isConnectionRequest(request: DappRequest): boolean { + return isGetAccountRequest(request) || isRequestAccountRequest(request) || isRequestPermissionsRequest(request) +} + +export function isWrapRequest(request: SendTransactionRequest): request is WrapSendTransactionRequest { + return WrapSendTransactionRequestSchema.safeParse(request).success +} + +export function isSendCallsRequest(request: DappRequest): request is SendCallsRequest { + return SendCallsRequestSchema.safeParse(request).success +} + +export function isGetCallsStatusRequest(request: DappRequest): request is GetCallsStatusRequest { + return GetCallsStatusRequestSchema.safeParse(request).success +} + +export function isValidSendCallsResponse(response: unknown): response is SendCallsResponse { + return SendCallsResponseSchema.safeParse(response).success +} + +export function isValidGetCallsStatusResponse(response: unknown): response is GetCallsStatusResponse { + return GetCallsStatusResponseSchema.safeParse(response).success +} diff --git a/apps/extension/src/app/features/dappRequests/types/ErrorTypes.ts b/apps/extension/src/app/features/dappRequests/types/ErrorTypes.ts new file mode 100644 index 00000000..62453cba --- /dev/null +++ b/apps/extension/src/app/features/dappRequests/types/ErrorTypes.ts @@ -0,0 +1,7 @@ +import { z } from 'zod' + +// Simplified version of the original EthereumRpcError class +export const EthereumRpcErrorSchema = z.object({ + code: z.number(), + message: z.string(), +}) diff --git a/apps/extension/src/app/features/dappRequests/types/EthersTypes.ts b/apps/extension/src/app/features/dappRequests/types/EthersTypes.ts new file mode 100644 index 00000000..0ca097b4 --- /dev/null +++ b/apps/extension/src/app/features/dappRequests/types/EthersTypes.ts @@ -0,0 +1,54 @@ +import { isHexString } from 'ethers/lib/utils' +import { HexadecimalNumberSchema } from 'src/app/features/dappRequests/types/utilityTypes' +import { z } from 'zod' + +/** + * Ethers types copied from `ethers` package + */ + +// oxlint-disable-next-line no-restricted-syntax +export const BigNumberSchema = z.any() // TODO (EXT-831): Add schema once stable + +const AccessListEntrySchema = z.object({ + address: z.string(), + storageKeys: z.array(z.string()), +}) + +const AccessListSchema = z.array(AccessListEntrySchema) + +// https://docs.ethers.org/v5/api/utils/bignumber/#BigNumberish +const BigNumberishSchema = z.union([ + z.string(), + z.instanceof(Uint8Array), // For Uint8Array, covering part of BytesLike. + z.array(z.number().min(0).max(255)), // For byte arrays (part of BytesLike), assuming bytes are represented as numbers 0-255. + BigNumberSchema, + z.number(), + z.bigint(), // For BigInt, in environments that support BigInt. +]) + +const BytesLikeSchema = z.string().refine((data) => isHexString(data)) + +// https://docs.ethers.org/v5/api/providers/types/#types--access-lists +const AccessListishSchema = z.union([ + AccessListSchema, + z.array(z.tuple([z.string(), z.array(z.string())])), // Array of 2-element Arrays format + z.record(z.string(), z.array(z.string())), // Object with addresses as keys and arrays of storage keys as values +]) + +export const EthersTransactionRequestSchema = z.object({ + to: z.string().optional(), + from: z.string().optional(), + nonce: BigNumberishSchema.optional(), + gasLimit: BigNumberishSchema.optional(), + gasPrice: BigNumberishSchema.optional(), + data: BytesLikeSchema.optional(), + value: BigNumberishSchema.optional(), + chainId: HexadecimalNumberSchema.optional(), + type: z.union([z.number(), HexadecimalNumberSchema]).optional(), + accessList: AccessListishSchema.optional(), + maxPriorityFeePerGas: BigNumberishSchema.optional(), + maxFeePerGas: BigNumberishSchema.optional(), + // oxlint-disable-next-line no-restricted-syntax + customData: z.record(z.string(), z.any()).optional(), + ccipReadEnabled: z.boolean().optional(), +}) diff --git a/apps/extension/src/app/features/dappRequests/types/NonfungiblePositionManager.ts b/apps/extension/src/app/features/dappRequests/types/NonfungiblePositionManager.ts new file mode 100644 index 00000000..59b79d1c --- /dev/null +++ b/apps/extension/src/app/features/dappRequests/types/NonfungiblePositionManager.ts @@ -0,0 +1,28 @@ +import { NonfungiblePositionManager } from '@uniswap/v3-sdk' +import { + NFPMCommand, + NfpmCommandSchema, + NonfungiblePositionManagerCall, + NonfungiblePositionManagerCallSchema, +} from 'src/app/features/dappRequests/types/NonfungiblePositionManagerTypes' + +const NFPMIface = NonfungiblePositionManager.INTERFACE + +function parseMulticallCommand(calldata: string): NFPMCommand { + const txDescription = NFPMIface.parseTransaction({ data: calldata }) + + return NfpmCommandSchema.parse({ + commandName: txDescription.name, + params: txDescription.args['params'], + }) +} + +export function parseCalldata(calldata: string): NonfungiblePositionManagerCall { + const txDescription = NFPMIface.parseTransaction({ data: calldata }) + const { data } = txDescription.args + if (txDescription.name === 'multicall') { + return NonfungiblePositionManagerCallSchema.parse({ commands: data.map(parseMulticallCommand) }) + } + + throw new Error('All NFPM calls from the Uniswap Labs interface are multicalls.') +} diff --git a/apps/extension/src/app/features/dappRequests/types/NonfungiblePositionManagerTypes.ts b/apps/extension/src/app/features/dappRequests/types/NonfungiblePositionManagerTypes.ts new file mode 100644 index 00000000..bd6c79c2 --- /dev/null +++ b/apps/extension/src/app/features/dappRequests/types/NonfungiblePositionManagerTypes.ts @@ -0,0 +1,70 @@ +import { BigNumberSchema } from 'src/app/features/dappRequests/types/EthersTypes' +import { z } from 'zod' + +const UnwrapNFPMCommandSchema = z.object({ + commandName: z.literal('unwrapWETH9'), + params: z.object({ + amountMinimum: BigNumberSchema, + recipient: z.string(), + }), +}) + +const SweepNFPMCommandSchema = z.object({ + commandName: z.literal('sweepToken'), + params: z.object({ + amountMinimum: BigNumberSchema, + recipient: z.string(), + token: z.string(), + }), +}) + +const CollectNFPMCommandSchema = z.object({ + commandName: z.literal('collect'), + params: z.object({ + amountMinimum: BigNumberSchema, + recipient: z.string(), + token: z.string(), + }), +}) + +const DecreaseLiquidityNFPMCommandSchema = z.object({ + commandName: z.literal('decreaseLiquidity'), + params: z.object({ + amount0Min: BigNumberSchema, + amount1Min: BigNumberSchema, + deadline: BigNumberSchema, + liquidity: BigNumberSchema, + tokenId: BigNumberSchema, + }), +}) + +const IncreaseLiquidityNFPMCommandSchema = z.object({ + commandName: z.literal('increaseLiquidity'), + params: z.object({ + amount0Desired: BigNumberSchema, + amount0Min: BigNumberSchema, + amount1Desired: BigNumberSchema, + amount1Min: BigNumberSchema, + deadline: BigNumberSchema, + tokenId: BigNumberSchema, + }), +}) + +const RefundETHNFPMCommandSchema = z.object({ + commandName: z.literal('refundETH'), +}) + +export const NfpmCommandSchema = z.union([ + UnwrapNFPMCommandSchema, + SweepNFPMCommandSchema, + CollectNFPMCommandSchema, + DecreaseLiquidityNFPMCommandSchema, + IncreaseLiquidityNFPMCommandSchema, + RefundETHNFPMCommandSchema, +]) +export type NFPMCommand = z.infer + +export const NonfungiblePositionManagerCallSchema = z.object({ + commands: z.array(NfpmCommandSchema), +}) +export type NonfungiblePositionManagerCall = z.infer diff --git a/apps/extension/src/app/features/dappRequests/types/UniversalRouterTypes.ts b/apps/extension/src/app/features/dappRequests/types/UniversalRouterTypes.ts new file mode 100644 index 00000000..91c12fb9 --- /dev/null +++ b/apps/extension/src/app/features/dappRequests/types/UniversalRouterTypes.ts @@ -0,0 +1,355 @@ +import { CommandType } from '@uniswap/universal-router-sdk' +import { FeeAmount as FeeAmountV3 } from '@uniswap/v3-sdk' +import { BigNumberSchema } from 'src/app/features/dappRequests/types/EthersTypes' +import { z } from 'zod' + +// SCHEMAS + TYPES +const CommandNameSchema = z.enum( + Object.keys(CommandType) as [keyof typeof CommandType, ...Array], +) + +// TODO: remove this fallback once params are fully typed or we are able to import them from the universal router sdk +const FallbackParamSchema = z.object({ + name: z.string(), + // oxlint-disable-next-line no-restricted-syntax + value: z.any(), +}) + +const AmountInParamSchema = z.object({ + name: z.literal('amountIn'), + value: BigNumberSchema, +}) +export type AmountInParam = z.infer + +const AmountInMaxParamSchema = z.object({ + name: z.literal('amountInMax'), + value: BigNumberSchema, +}) +export type AmountInMaxParam = z.infer + +const AmountOutMinParamSchema = z.object({ + name: z.literal('amountOutMin'), + value: BigNumberSchema, +}) +export type AmountOutMinParam = z.infer + +const AmountOutParamSchema = z.object({ + name: z.literal('amountOut'), + value: BigNumberSchema, +}) +export type AmountOutParam = z.infer + +const AmountMinParamSchema = z.object({ + name: z.literal('amountMin'), + value: BigNumberSchema, +}) +type AmountMinParam = z.infer + +const FeeAmountSchema = z.nativeEnum(FeeAmountV3) + +const V3PathParamSchema = z.object({ + name: z.literal('path'), + value: z.array( + z.object({ + tokenIn: z.string().refine((val) => val.startsWith('0x'), { + message: "tokenIn must start with '0x'", + }), + tokenOut: z.string().refine((val) => val.startsWith('0x'), { + message: "tokenOut must start with '0x'", + }), + fee: FeeAmountSchema, + }), + ), +}) + +// V4 PARAMS + +// Define PoolKey which is used for the exact single swaps +const PoolKeySchema = z.object({ + currency0: z.string().refine((val) => val.startsWith('0x'), { + message: "currency0 must start with '0x'", + }), + currency1: z.string().refine((val) => val.startsWith('0x'), { + message: "currency1 must start with '0x'", + }), + fee: z.number(), + tickSpacing: z.number(), + hooks: z.string(), +}) + +// V4 SWAP_EXACT_IN_SINGLE +const V4SwapExactInSingleSwapSchema = z.object({ + poolKey: PoolKeySchema, + zeroForOne: z.boolean(), + amountIn: BigNumberSchema, + amountOutMinimum: BigNumberSchema, + sqrtPriceLimitX96: BigNumberSchema, + hookData: z.string(), +}) + +export const V4SwapExactInSingleParamSchema = z.object({ + name: z.literal('SWAP_EXACT_IN_SINGLE'), + value: z.array( + z.object({ + name: z.literal('swap'), + value: V4SwapExactInSingleSwapSchema, + }), + ), +}) + +// V4 SWAP_EXACT_OUT_SINGLE +const SwapExactOutSingleSwapSchema = z.object({ + poolKey: PoolKeySchema, + zeroForOne: z.boolean(), + amountOut: BigNumberSchema, + amountInMaximum: BigNumberSchema, + sqrtPriceLimitX96: BigNumberSchema, + hookData: z.string(), +}) + +export const V4SwapExactOutSingleParamSchema = z.object({ + name: z.literal('SWAP_EXACT_OUT_SINGLE'), + value: z.array( + z.object({ + name: z.literal('swap'), + value: SwapExactOutSingleSwapSchema, + }), + ), +}) + +// Define PathKey which is used for exact swaps with multiple hops +const PathKeySchema = z.object({ + intermediateCurrency: z.string().refine((val) => val.startsWith('0x'), { + message: "intermediateCurrency must start with '0x'", + }), + fee: z.number(), + tickSpacing: z.number(), + hooks: z.string().refine((val) => val.startsWith('0x'), { + message: "hooks must start with '0x'", + }), + hookData: z.string(), +}) + +// V4 SWAP_EXACT_IN +const V4SwapExactInSchema = z.object({ + currencyIn: z.string().refine((val) => val.startsWith('0x'), { + message: "currencyIn must start with '0x'", + }), + path: z.array(PathKeySchema), + amountIn: BigNumberSchema, + amountOutMinimum: BigNumberSchema, +}) + +export const V4SwapExactInParamSchema = z.object({ + name: z.literal('SWAP_EXACT_IN'), + value: z.array( + z.object({ + name: z.literal('swap'), + value: V4SwapExactInSchema, + }), + ), +}) + +// V4 SWAP_EXACT_OUT +const V4SwapExactOutSchema = z.object({ + currencyOut: z.string().refine((val) => val.startsWith('0x'), { + message: "currencyOut must start with '0x'", + }), + path: z.array(PathKeySchema), + amountOut: BigNumberSchema, + amountInMaximum: BigNumberSchema, +}) + +export const V4SwapExactOutParamSchema = z.object({ + name: z.literal('SWAP_EXACT_OUT'), + value: z.array( + z.object({ + name: z.literal('swap'), + value: V4SwapExactOutSchema, + }), + ), +}) + +// END V4 PARAMS + +const PayerIsUserParamSchema = z.object({ + name: z.literal('payerIsUser'), + value: z.boolean(), +}) + +const SettleParamSchema = z.object({ + name: z.literal('SETTLE'), + value: z.array( + z.union([ + z.object({ + name: z.literal('currency'), + value: z.string(), + }), + z.object({ + name: z.literal('amount'), + value: BigNumberSchema, + }), + z.object({ + name: z.literal('payerIsUser'), + value: z.boolean(), + }), + ]), + ), +}) +type SettleParam = z.infer + +const TakeParamSchema = z.object({ + name: z.literal('TAKE'), + value: z.array( + z.union([ + z.object({ + name: z.literal('currency'), + value: z.string(), + }), + z.object({ + name: z.literal('recipient'), + value: z.string(), + }), + z.object({ + name: z.literal('amount'), + value: BigNumberSchema, + }), + ]), + ), +}) + +const ParamSchema = z.union([ + AmountInParamSchema, + AmountInMaxParamSchema, + AmountOutParamSchema, + AmountOutMinParamSchema, + AmountMinParamSchema, + V3PathParamSchema, + PayerIsUserParamSchema, + SettleParamSchema, + TakeParamSchema, + FallbackParamSchema, +]) +export type Param = z.infer + +const FallbackCommandSchema = z.object({ + commandName: CommandNameSchema, + commandType: z.nativeEnum(CommandType), + params: z.array(ParamSchema), +}) + +const V2SwapExactInCommandSchema = z.object({ + commandName: z.literal('V2_SWAP_EXACT_IN'), + commandType: z.literal(CommandType.V2_SWAP_EXACT_IN), + params: z.array(ParamSchema), +}) + +const V2SwapExactOutCommandSchema = z.object({ + commandName: z.literal('V2_SWAP_EXACT_OUT'), + commandType: z.literal(CommandType.V2_SWAP_EXACT_OUT), + params: z.array(ParamSchema), +}) + +const V3SwapExactInCommandSchema = z.object({ + commandName: z.literal('V3_SWAP_EXACT_IN'), + commandType: z.literal(CommandType.V3_SWAP_EXACT_IN), + params: z.array(ParamSchema), +}) + +const V3SwapExactOutCommandSchema = z.object({ + commandName: z.literal('V3_SWAP_EXACT_OUT'), + commandType: z.literal(CommandType.V3_SWAP_EXACT_OUT), + params: z.array(ParamSchema), +}) + +const SweepCommandSchema = z.object({ + commandName: z.literal('SWEEP'), + commandType: z.literal(CommandType.SWEEP), + params: z.array(ParamSchema), +}) +type SweepCommand = z.infer + +const UnwrapWethCommandSchema = z.object({ + commandName: z.literal('UNWRAP_WETH'), + commandType: z.literal(CommandType.UNWRAP_WETH), + params: z.array(ParamSchema), +}) +type UnwrapWethCommand = z.infer + +const V4SwapCommandSchema = z.object({ + commandName: z.literal('V4_SWAP'), + commandType: z.literal(CommandType.V4_SWAP), + params: z.array( + z.union([ + V4SwapExactInParamSchema, + V4SwapExactOutParamSchema, + V4SwapExactInSingleParamSchema, + V4SwapExactOutSingleParamSchema, + SettleParamSchema, + TakeParamSchema, + ]), + ), +}) + +const UniversalRouterSwapCommandSchema = z.union([ + V2SwapExactInCommandSchema, + V2SwapExactOutCommandSchema, + V3SwapExactInCommandSchema, + V3SwapExactOutCommandSchema, + V4SwapCommandSchema, +]) +type UniversalRouterSwapCommand = z.infer + +const UniversalRouterCommandSchema = z.union([ + FallbackCommandSchema, + V2SwapExactInCommandSchema, + V2SwapExactOutCommandSchema, + V3SwapExactInCommandSchema, + V3SwapExactOutCommandSchema, + V4SwapCommandSchema, + SweepCommandSchema, + UnwrapWethCommandSchema, +]) +export type UniversalRouterCommand = z.infer + +export const UniversalRouterCallSchema = z.object({ + commands: z.array(UniversalRouterCommandSchema), +}) +export type UniversalRouterCall = z.infer + +// VALIDATORS + UTILS +export function isURCommandASwap(command: UniversalRouterCommand): command is UniversalRouterSwapCommand { + return UniversalRouterSwapCommandSchema.safeParse(command).success +} + +export function isUrCommandSweep(command: UniversalRouterCommand): command is SweepCommand { + return SweepCommandSchema.safeParse(command).success +} + +export function isUrCommandUnwrapWeth(command: UniversalRouterCommand): command is UnwrapWethCommand { + return UnwrapWethCommandSchema.safeParse(command).success +} + +export function isAmountInParam(param: Param): param is AmountInParam { + return AmountInParamSchema.safeParse(param).success +} + +export function isAmountInMaxParam(param: Param): param is AmountInMaxParam { + return AmountInMaxParamSchema.safeParse(param).success +} + +export function isAmountOutMinParam(param: Param): param is AmountOutMinParam { + return AmountOutMinParamSchema.safeParse(param).success +} + +export function isAmountOutParam(param: Param): param is AmountOutParam { + return AmountOutParamSchema.safeParse(param).success +} + +export function isAmountMinParam(param: Param): param is AmountMinParam { + return AmountMinParamSchema.safeParse(param).success +} + +export function isSettleParam(param: Param): param is SettleParam { + return SettleParamSchema.safeParse(param).success +} diff --git a/apps/extension/src/app/features/dappRequests/types/preSignedDappTransaction.ts b/apps/extension/src/app/features/dappRequests/types/preSignedDappTransaction.ts new file mode 100644 index 00000000..d652735e --- /dev/null +++ b/apps/extension/src/app/features/dappRequests/types/preSignedDappTransaction.ts @@ -0,0 +1,21 @@ +import { SignerMnemonicAccountMeta } from 'uniswap/src/features/accounts/types' +import { UniverseChainId } from 'uniswap/src/features/chains/types' +import { ValidatedTransactionRequest } from 'uniswap/src/features/transactions/types/transactionRequests' +import { SignedTransactionRequest } from 'wallet/src/features/transactions/executeTransaction/types' + +export interface PrepareAndSignDappTransactionParams { + /** The dapp request with gas estimates already applied */ + request: ValidatedTransactionRequest + + /** The account to use for signing */ + account: SignerMnemonicAccountMeta + + /** The chain ID for the transaction */ + chainId: UniverseChainId + + /** Callback for successful preparation */ + onSuccess?: (result: SignedTransactionRequest) => void + + /** Callback for failed preparation */ + onFailure?: (error: Error) => void +} diff --git a/apps/extension/src/app/features/dappRequests/types/utilityTypes.tsx b/apps/extension/src/app/features/dappRequests/types/utilityTypes.tsx new file mode 100644 index 00000000..03ece495 --- /dev/null +++ b/apps/extension/src/app/features/dappRequests/types/utilityTypes.tsx @@ -0,0 +1,13 @@ +import { z } from 'zod' + +export const HexadecimalNumberSchema = z.union([z.number(), z.string()]).transform((value, ctx): number => { + if (typeof value === 'number') { + return value + } + const possibleNumber = Number(value) + if (!isNaN(possibleNumber)) { + return possibleNumber + } + ctx.addIssue({ code: 'custom', message: 'Not a hexadecimal number' }) + return z.NEVER +}) diff --git a/apps/extension/src/app/features/for/utils.ts b/apps/extension/src/app/features/for/utils.ts new file mode 100644 index 00000000..a03271e2 --- /dev/null +++ b/apps/extension/src/app/features/for/utils.ts @@ -0,0 +1,38 @@ +import { SharedEventName } from '@uniswap/analytics-events' +import { useDappContext } from 'src/app/features/dapp/DappContext' +import { useDappLastChainId } from 'src/app/features/dapp/hooks' +import { focusOrCreateUniswapInterfaceTab } from 'src/app/navigation/utils' +import { uniswapUrls } from 'uniswap/src/constants/urls' +import { getChainInfo } from 'uniswap/src/features/chains/chainInfo' +import { UniverseChainId } from 'uniswap/src/features/chains/types' +import { ElementName } from 'uniswap/src/features/telemetry/constants' +import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { ExtensionScreens } from 'uniswap/src/types/screens/extension' +import { logger } from 'utilities/src/logger/logger' + +export function useInterfaceBuyNavigator(element?: ElementName): () => void { + const { dappUrl } = useDappContext() + const dappChain = useDappLastChainId(dappUrl) + + return () => { + sendAnalyticsEvent(SharedEventName.ELEMENT_CLICKED, { + screen: ExtensionScreens.Home, + element, + }) + navigateToInterfaceFiatOnRamp(dappChain) + } +} + +export function navigateToInterfaceFiatOnRamp(chainId?: UniverseChainId): void { + const chainParam = chainId ? `?chain=${getChainInfo(chainId).urlParam}` : '' + focusOrCreateUniswapInterfaceTab({ + url: `${uniswapUrls.webInterfaceBuyUrl}${chainParam}`, + }).catch((err) => + logger.error(err, { + tags: { + file: 'utils', + function: 'redirectToInterfaceFiatOnRamp', + }, + }), + ) +} diff --git a/apps/extension/src/app/features/forceUpgrade/ForceUpgradeModal.tsx b/apps/extension/src/app/features/forceUpgrade/ForceUpgradeModal.tsx new file mode 100644 index 00000000..d1362243 --- /dev/null +++ b/apps/extension/src/app/features/forceUpgrade/ForceUpgradeModal.tsx @@ -0,0 +1,10 @@ +import { ViewRecoveryPhraseScreen } from 'src/app/features/settings/SettingsRecoveryPhraseScreen/ViewRecoveryPhraseScreen' +import { ForceUpgrade } from 'wallet/src/features/forceUpgrade/ForceUpgrade' + +function SeedPhraseModalContent({ mnemonicId, onDismiss }: { mnemonicId: string; onDismiss: () => void }): JSX.Element { + return +} + +export function ForceUpgradeModal(): JSX.Element { + return +} diff --git a/apps/extension/src/app/features/home/HomeScreen.tsx b/apps/extension/src/app/features/home/HomeScreen.tsx new file mode 100644 index 00000000..ee87afb4 --- /dev/null +++ b/apps/extension/src/app/features/home/HomeScreen.tsx @@ -0,0 +1,395 @@ +import { useApolloClient } from '@apollo/client' +import { SharedEventName } from '@uniswap/analytics-events' +import { FeatureFlags, useFeatureFlag } from '@universe/gating' +import { getIsNotificationServiceLocalOverrideEnabled } from '@universe/notifications' +import React, { memo, useCallback, useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useDispatch, useSelector } from 'react-redux' +import { ActivityTab } from 'src/app/components/tabs/ActivityTab' +import { NftsTab } from 'src/app/components/tabs/NftsTab' +import { useSmartWalletNudges } from 'src/app/context/SmartWalletNudgesContext' +import { HomeIntroCardStack } from 'src/app/features/home/introCards/HomeIntroCardStack' +import { PortfolioActionButtons } from 'src/app/features/home/PortfolioActionButtons' +import { PortfolioHeader } from 'src/app/features/home/PortfolioHeader' +import { ExtensionTokenBalanceList } from 'src/app/features/home/TokenBalanceList' +import { selectAlertsState } from 'src/app/features/onboarding/alerts/selectors' +import { AlertName, closeAlert } from 'src/app/features/onboarding/alerts/slice' +import { PinReminder } from 'src/app/features/onboarding/PinReminder' +import { useOptimizedSearchParams } from 'src/app/hooks/useOptimizedSearchParams' +import { HomeQueryParams, HomeTabs } from 'src/app/navigation/constants' +import { navigate } from 'src/app/navigation/state' +import { ExtensionNotificationServiceManager } from 'src/notification-service/ExtensionNotificationServiceManager' +import { Flex, Loader, styled, Text, TouchableArea } from 'ui/src' +import { SMART_WALLET_UPGRADE_VIDEO } from 'ui/src/assets' +import { buildWrappedUrl } from 'uniswap/src/components/banners/shared/utils' +import { UniswapWrapped2025Banner } from 'uniswap/src/components/banners/UniswapWrapped2025Banner/UniswapWrapped2025Banner' +import { NFTS_TAB_DATA_DEPENDENCIES } from 'uniswap/src/components/nfts/constants' +import { UNISWAP_WEB_URL } from 'uniswap/src/constants/urls' +import { selectHasDismissedUniswapWrapped2025Banner } from 'uniswap/src/features/behaviorHistory/selectors' +import { setHasDismissedUniswapWrapped2025Banner } from 'uniswap/src/features/behaviorHistory/slice' +import { useSelectAddressHasNotifications } from 'uniswap/src/features/notifications/slice/hooks' +import { setNotificationStatus } from 'uniswap/src/features/notifications/slice/slice' +import { PortfolioBalance } from 'uniswap/src/features/portfolio/PortfolioBalance/PortfolioBalance' +import { ModalName } from 'uniswap/src/features/telemetry/constants' +import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { logger } from 'utilities/src/logger/logger' +import { useEvent } from 'utilities/src/react/hooks' +import { ONE_MINUTE_MS, ONE_SECOND_MS } from 'utilities/src/time/time' +import { useTimeout } from 'utilities/src/time/timing' +import { SmartWalletEnabledModal } from 'wallet/src/components/smartWallet/modals/SmartWalletEnabledModal' +import { SmartWalletUpgradeModals } from 'wallet/src/components/smartWallet/modals/SmartWalletUpgradeModal' +import { useOpenSmartWalletNudgeOnCompletedSwap } from 'wallet/src/components/smartWallet/smartAccounts/hooks' +import { setIncrementNumPostSwapNudge } from 'wallet/src/features/behaviorHistory/slice' +import { PendingNotificationBadge } from 'wallet/src/features/notifications/components/PendingNotificationBadge' +import { useActiveAccountAddressWithThrow, useActiveAccountWithThrow } from 'wallet/src/features/wallet/hooks' +import { setSmartWalletConsent } from 'wallet/src/features/wallet/slice' + +const MemoizedVideo = memo(() => ( + + +)) + +MemoizedVideo.displayName = 'MemoizedVideo' + +export const HomeScreen = memo(function HomeScreenInner(): JSX.Element { + const { t } = useTranslation() + const activeAccount = useActiveAccountWithThrow() + const [showTabs, setShowTabs] = useState(false) + + const apolloClient = useApolloClient() + + // The tabs are too slow to render on the first load, so we delay them to speed up the perceived loading time. + useTimeout(() => setShowTabs(true), 0) + + const address = useActiveAccountAddressWithThrow() + const [selectedTab, setSelectedTab] = useSelectedTabState() + const isSmartWalletEnabled = useFeatureFlag(FeatureFlags.SmartWallet) + const [isSmartWalletEnabledModalOpen, setIsSmartWalletEnabledModalOpen] = useState(false) + const dispatch = useDispatch() + + // UniswapWrapped2025 banner state + const isWrappedBannerEnabled = useFeatureFlag(FeatureFlags.UniswapWrapped2025) + const hasDismissedWrappedBanner = useSelector(selectHasDismissedUniswapWrapped2025Banner) + const shouldShowWrappedBanner = isWrappedBannerEnabled && !hasDismissedWrappedBanner + + // Notification service feature flag + const isNotificationServiceEnabledFlag = useFeatureFlag(FeatureFlags.NotificationService) + const isNotificationServiceEnabled = + getIsNotificationServiceLocalOverrideEnabled() || isNotificationServiceEnabledFlag + + const handleDismissWrappedBanner = useCallback(() => { + dispatch(setHasDismissedUniswapWrapped2025Banner(true)) + }, [dispatch]) + + const handlePressWrappedBanner = useCallback(() => { + try { + const url = buildWrappedUrl(UNISWAP_WEB_URL, address) + window.open(url, '_blank') + dispatch(setHasDismissedUniswapWrapped2025Banner(true)) + } catch (error) { + logger.error(error, { tags: { file: 'HomeScreen', function: 'handlePressWrappedBanner' } }) + } + }, [address, dispatch]) + + useEffect(() => { + if (selectedTab) { + sendAnalyticsEvent(SharedEventName.PAGE_VIEWED, { + screen: selectedTab, + }) + } + }, [selectedTab]) + + // defaults to true, but store state will persist to future loads once updated + const { isOpen: isPinRequestOpen } = useSelector(selectAlertsState(AlertName.PinToToolbar)) + const onClosePinRequest = useCallback(() => dispatch(closeAlert(AlertName.PinToToolbar)), [dispatch]) + + const handleSmartWalletEnable = useCallback( + (onComplete?: () => void): void => { + dispatch(setSmartWalletConsent({ address: activeAccount.address, smartWalletConsent: true })) + onComplete?.() + setIsSmartWalletEnabledModalOpen(true) + }, + [dispatch, activeAccount.address], + ) + + // Handle the smart wallet nudge when a swap transaction is completed + const { openModal, activeModal } = useSmartWalletNudges() + useOpenSmartWalletNudgeOnCompletedSwap( + useEvent(() => { + if (!activeAccount.address) { + return + } + dispatch(setIncrementNumPostSwapNudge({ walletAddress: address })) + openModal(ModalName.SmartWalletNudge) + }), + ) + + useEffect(() => { + let intervalId: number + const checkExtensionPinnedStatus = async (): Promise => { + const settings = await chrome.action.getUserSettings() + if (settings.isOnToolbar) { + onClosePinRequest() + // Clear interval once pinned + clearInterval(intervalId) + } + } + + // Only set up the interval if the pin request is open + if (isPinRequestOpen) { + // Check immediately on mount + checkExtensionPinnedStatus().catch((e) => logger.error('Error checking if extension is pinned in Chrome', e)) + + intervalId = window.setInterval(checkExtensionPinnedStatus, ONE_SECOND_MS) + } + return () => { + if (intervalId) { + clearInterval(intervalId) + } + } + }, [isPinRequestOpen, onClosePinRequest]) + + const hasNotifications = useSelectAddressHasNotifications(address) + useEffect(() => { + if (selectedTab === HomeTabs.Activity && hasNotifications) { + dispatch(setNotificationStatus({ address, hasNotifications: false })) + } + }, [dispatch, address, hasNotifications, selectedTab]) + + const [lastNftFetchTime, setLastNftFetchTime] = useState(0) + + useEffect(() => { + // NFTs tab is first fetched on mount, so we need to set the last fetch time here + setLastNftFetchTime(Date.now()) + }, []) + + const shouldRefetchNfts = useCallback(() => { + const now = Date.now() + return now - lastNftFetchTime >= ONE_MINUTE_MS + }, [lastNftFetchTime]) + + const maybeRefetchNfts = useCallback(() => { + if (shouldRefetchNfts()) { + setLastNftFetchTime(Date.now()) + apolloClient.refetchQueries({ include: NFTS_TAB_DATA_DEPENDENCIES }).catch((e) => { + logger.error('Error refetching NFTs tab data', e) + }) + } + }, [apolloClient, shouldRefetchNfts]) + + return ( + + {address ? ( + + {isPinRequestOpen && ( + + + + )} + {shouldShowWrappedBanner && ( + + + + + )} + + + + + + + + + + + + + {!isNotificationServiceEnabled && } + + + + setSelectedTab(HomeTabs.Tokens)}> + {t('home.tokens.title')} + + + { + setSelectedTab(HomeTabs.NFTs) + maybeRefetchNfts() + }} + > + {t('home.nfts.title')} + + + setSelectedTab(HomeTabs.Activity)} + > + {t('home.activity.title')} + + + + + {showTabs ? ( + <> + + + + + + + + + + + + + ) : ( + + + + )} + + + + + ) : ( + + {t('home.extension.error')} + + )} + {isSmartWalletEnabled && !activeModal && ( + } + onEnableSmartWallet={handleSmartWalletEnable} + /> + )} + + {isSmartWalletEnabledModalOpen && isSmartWalletEnabled ? ( + setIsSmartWalletEnabledModalOpen(false)} + /> + ) : undefined} + + ) +}) + +const TabButton = ({ + isActive, + onPress, + children, + showPendingNotificationBadge = false, +}: { + isActive: boolean + onPress: () => void + children: React.ReactNode + showPendingNotificationBadge?: boolean +}): React.JSX.Element => { + return ( + + + {children} + + {showPendingNotificationBadge && !isActive && } + + ) +} + +const AnimatedTab = styled(Flex, { + animation: 'quicker', + width: '100%', + mr: '-100%', + x: 0, + opacity: 1, + + variants: { + isActive: { + true: {}, + false: { + pointerEvents: 'none', + display: 'none', + }, + }, + + hideLeft: { + true: { + opacity: 0, + // if this number is larger than the horizontal padding of the screen, it + // will make a horizontal scroll bar appear when using a mouse on macOS + x: -10, + pointerEvents: 'none', + }, + }, + hideRight: { + true: { + opacity: 0, + x: 10, + pointerEvents: 'none', + }, + }, + } as const, +}) + +// useNavigate/useSearchParams re-renders on every page change, so we avoid them here: +// https://github.com/remix-run/react-router/issues/7634#issuecomment-1306650156 +function useSelectedTabState(): [HomeTabs | null, (tab: HomeTabs) => void] { + const searchParams = useOptimizedSearchParams() + const tab = searchParams.get(HomeQueryParams.Tab) + + const setNewTab = useCallback(async (newTab: HomeTabs) => { + navigate( + { + search: `?${HomeQueryParams.Tab}=${newTab}`, + }, + { + replace: true, + }, + ) + }, []) + + if (isValidHomeTab(tab)) { + return [tab, setNewTab] + } + + return [HomeTabs.Tokens, setNewTab] +} + +function isValidHomeTab(tab: unknown): tab is HomeTabs { + return tab === HomeTabs.Tokens || tab === HomeTabs.NFTs || tab === HomeTabs.Activity +} diff --git a/apps/extension/src/app/features/home/PortfolioActionButtons.tsx b/apps/extension/src/app/features/home/PortfolioActionButtons.tsx new file mode 100644 index 00000000..56220982 --- /dev/null +++ b/apps/extension/src/app/features/home/PortfolioActionButtons.tsx @@ -0,0 +1,127 @@ +import { SharedEventName } from '@uniswap/analytics-events' +import { cloneElement, memo, useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useInterfaceBuyNavigator } from 'src/app/features/for/utils' +import { AppRoutes } from 'src/app/navigation/constants' +import { navigate } from 'src/app/navigation/state' +import { Flex, getTokenValue, Text, TouchableArea, useMedia } from 'ui/src' +import { ArrowDownCircle, Bank, CoinConvert, SendAction } from 'ui/src/components/icons' +import { useEnabledChains } from 'uniswap/src/features/chains/hooks/useEnabledChains' +import { ElementName } from 'uniswap/src/features/telemetry/constants' +import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { TestnetModeModal } from 'uniswap/src/features/testnets/TestnetModeModal' +import { ExtensionScreens } from 'uniswap/src/types/screens/extension' + +const ICON_COLOR = '$accent1' + +type ActionButtonCommonProps = { + label: string + Icon: JSX.Element +} + +// accepts an `onClick` prop or a `url` prop, but not both or neither +type ActionButtonProps = + | (ActionButtonCommonProps & { + onClick: () => void + url?: never + }) + | (ActionButtonCommonProps & { + url: string + onClick?: never + }) + +function ActionButton({ label, Icon, onClick, url }: ActionButtonProps): JSX.Element { + const actionHandler = url + ? // if it has a url prop, open it in a new tab + (): void => { + // false positive because of .open + window.open(url, '_blank') + } + : // otherwise call the onClick function + onClick + + return ( + + {cloneElement(Icon, { color: ICON_COLOR, size: getTokenValue('$icon.24') })} + + {label} + + + ) +} + +export const PortfolioActionButtons = memo(function PortfolioActionButtonsInner(): JSX.Element { + const { t } = useTranslation() + const media = useMedia() + const { isTestnetModeEnabled } = useEnabledChains() + + const onSendClick = (): void => { + sendAnalyticsEvent(SharedEventName.ELEMENT_CLICKED, { + screen: ExtensionScreens.Home, + element: ElementName.Send, + }) + navigate(`/${AppRoutes.Send}`) + } + + const onSwapClick = (): void => { + sendAnalyticsEvent(SharedEventName.ELEMENT_CLICKED, { + screen: ExtensionScreens.Home, + element: ElementName.Swap, + }) + navigate(`/${AppRoutes.Swap}`) + } + + const onReceiveClick = (): void => { + sendAnalyticsEvent(SharedEventName.ELEMENT_CLICKED, { + screen: ExtensionScreens.Home, + element: ElementName.Receive, + }) + navigate(`/${AppRoutes.Receive}`) + } + + const [isTestnetWarningModalOpen, setIsTestnetWarningModalOpen] = useState(false) + const handleTestnetWarningModalClose = useCallback(() => { + setIsTestnetWarningModalOpen(false) + }, []) + + const onBuyNavigate = useInterfaceBuyNavigator(ElementName.Buy) + const onBuyClick = (): void => { + if (isTestnetModeEnabled) { + setIsTestnetWarningModalOpen(true) + } else { + onBuyNavigate() + } + } + + const isGrid = media.sm + + return ( + + + + } label={t('home.label.swap')} onClick={onSwapClick} /> + } label={t('home.label.for')} onClick={onBuyClick} /> + + + } label={t('home.label.send')} onClick={onSendClick} /> + } label={t('home.label.receive')} onClick={onReceiveClick} /> + + + ) +}) diff --git a/apps/extension/src/app/features/home/PortfolioHeader.tsx b/apps/extension/src/app/features/home/PortfolioHeader.tsx new file mode 100644 index 00000000..d8d72c7b --- /dev/null +++ b/apps/extension/src/app/features/home/PortfolioHeader.tsx @@ -0,0 +1,222 @@ +import { SharedEventName } from '@uniswap/analytics-events' +import { memo, useEffect, useState } from 'react' +import Animated, { useAnimatedStyle, useSharedValue, withDelay, withTiming } from 'react-native-reanimated' +import { useDispatch, useSelector } from 'react-redux' +import { useDappContext } from 'src/app/features/dapp/DappContext' +import { ConnectPopupContent } from 'src/app/features/popups/ConnectPopup' +import { selectPopupState } from 'src/app/features/popups/selectors' +import { closePopup, openPopup, PopupName } from 'src/app/features/popups/slice' +import { AppRoutes } from 'src/app/navigation/constants' +import { navigate } from 'src/app/navigation/state' +import { Circle, Flex, Popover, Text, TouchableArea, UniversalImage } from 'ui/src' +import { animationPresets } from 'ui/src/animations' +import { CopyAlt, Globe, RotatableChevron, Settings } from 'ui/src/components/icons' +import { borderRadii, iconSizes } from 'ui/src/theme' +import { DappIconPlaceholder } from 'uniswap/src/components/dapps/DappIconPlaceholder' +import { AccountIcon } from 'uniswap/src/features/accounts/AccountIcon' +import { DisplayNameType } from 'uniswap/src/features/accounts/types' +import { UniverseChainId } from 'uniswap/src/features/chains/types' +import { pushNotification } from 'uniswap/src/features/notifications/slice/slice' +import { AppNotificationType, CopyNotificationType } from 'uniswap/src/features/notifications/slice/types' +import { ElementName } from 'uniswap/src/features/telemetry/constants' +import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { TestID } from 'uniswap/src/test/fixtures/testIDs' +import { ExtensionScreens } from 'uniswap/src/types/screens/extension' +import { sanitizeAddressText } from 'uniswap/src/utils/addresses' +import { shortenAddress } from 'utilities/src/addresses' +import { setClipboard } from 'utilities/src/clipboard/clipboard' +import { extractNameFromUrl } from 'utilities/src/format/extractNameFromUrl' +import { AnimatedUnitagDisplayName } from 'wallet/src/components/accounts/AnimatedUnitagDisplayName' +import useIsFocused from 'wallet/src/features/focus/useIsFocused' +import { useDisplayName } from 'wallet/src/features/wallet/hooks' + +const POPUP_SHADOW_RADIUS = 4 + +type PortfolioHeaderProps = { + address: Address +} + +// this variable is used to flag when user go to settings screen from home touching the settings icon +// we can't use useState here because screen is mounted everytime screen is entered so all state would be lost +// it's not in any kind of Context because it's only animation variable and it shouldn't trigger rerenders +let shouldEnableAnimationNextEnter = false + +const RotatingSettingsIcon = ({ onPressSettings }: { onPressSettings(): void }): JSX.Element => { + const isScreenFocused = useIsFocused() + const pressProgress = useSharedValue(0) + + useEffect(() => { + if (isScreenFocused && shouldEnableAnimationNextEnter) { + pressProgress.value = 1 + pressProgress.value = withDelay( + 50, + withTiming(0, {}, () => { + shouldEnableAnimationNextEnter = false + }), + ) + } +export const PortfolioHeader = memo(function PortfolioHeaderInner({ address }: PortfolioHeaderProps): JSX.Element { + const dispatch = useDispatch() + + const displayName = useDisplayName(address) + const walletHasName = displayName && displayName.type !== DisplayNameType.Address + const formattedAddress = sanitizeAddressText(shortenAddress({ address })) + const { isOpen: isPopupOpen } = useSelector(selectPopupState(PopupName.Connect)) + + // Used to delay popup showing on initial render, which leads to improper anchoring + const [initialized, setInitialized] = useState(false) + useEffect(() => { + setTimeout(() => setInitialized(true), 100) + }, []) + + const onPressAccount = async (): Promise => { + dispatch(closePopup(PopupName.Connect)) + navigate(`/${AppRoutes.AccountSwitcher}`) + } + + const { isConnected, lastChainId, dappUrl, dappIconUrl } = useDappContext() + const showConnectionStatus = isConnected || dappUrl.startsWith('http://') || dappUrl.startsWith('https://') + + const toggleConnectPopup = (): void => { + if (isPopupOpen) { + dispatch(closePopup(PopupName.Connect)) + } else { + dispatch(openPopup(PopupName.Connect)) + } + } + + const onClosePopup = (): void => { + dispatch(closePopup(PopupName.Connect)) + } + + const onPressCopyAddress = async (): Promise => { + if (address) { + await setClipboard(address) + dispatch( + pushNotification({ + type: AppNotificationType.Copied, + copyType: CopyNotificationType.Address, + }), + ) + sendAnalyticsEvent(SharedEventName.ELEMENT_CLICKED, { + element: ElementName.CopyAddress, + screen: ExtensionScreens.Home, + }) + } + } + + const onPressSettings = (): void => { + navigate('/settings') + } + + return ( + + + + + + + + + + + + + + {showConnectionStatus && ( + { + if (!open) { + // Used to close the popup when the user clicks outside of it + onClosePopup() + } + }} + > + + + + + + + + + + + )} + + + + + {walletHasName ? ( + + ) : ( + + + + {formattedAddress} + + + + + )} + + + ) +}) + +function ConnectionStatusIcon({ + isConnected, + lastChainId, + dappIconUrl, + dappUrl, +}: { + isConnected: boolean + lastChainId?: UniverseChainId + dappIconUrl?: string + dappUrl?: string +}): JSX.Element { + const uppercaseDappName = extractNameFromUrl(dappUrl).toUpperCase() + const isConnectedToNetwork = isConnected && lastChainId + return isConnectedToNetwork ? ( + + } + size={{ height: iconSizes.icon20, width: iconSizes.icon20 }} + style={{ image: { borderRadius: borderRadii.rounded8 } }} + uri={dappIconUrl} + /> + + + + + ) : ( + + ) +} diff --git a/apps/extension/src/app/features/home/SwitchNetworksModal.tsx b/apps/extension/src/app/features/home/SwitchNetworksModal.tsx new file mode 100644 index 00000000..c13d2d62 --- /dev/null +++ b/apps/extension/src/app/features/home/SwitchNetworksModal.tsx @@ -0,0 +1,98 @@ +import { useTranslation } from 'react-i18next' +import { saveDappChain } from 'src/app/features/dapp/actions' +import { useDappContext } from 'src/app/features/dapp/DappContext' +import { useDappLastChainId } from 'src/app/features/dapp/hooks' +import { Flex, Popover, Text, TouchableArea } from 'ui/src' +import { CheckCircleFilled, RotatableChevron } from 'ui/src/components/icons' +import { usePreventOverflowBelowFold } from 'ui/src/hooks/usePreventOverflowBelowFold' +import { iconSizes } from 'ui/src/theme' +import { NetworkLogo } from 'uniswap/src/components/CurrencyLogo/NetworkLogo' +import { useEnabledChains } from 'uniswap/src/features/chains/hooks/useEnabledChains' +import { UniverseChainId } from 'uniswap/src/features/chains/types' +import { getChainLabel } from 'uniswap/src/features/chains/utils' +import { ExtensionEventName } from 'uniswap/src/features/telemetry/constants' +import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' + +const BUTTON_OFFSET = 20 + +interface SwitchNetworksModalProps { + onPress: () => void +} + +export function SwitchNetworksModal({ onPress }: SwitchNetworksModalProps): JSX.Element { + const { t } = useTranslation() + const { dappUrl } = useDappContext() + const activeChain = useDappLastChainId(dappUrl) + const { chains: enabledChains } = useEnabledChains() + + const onNetworkClicked = async (chainId: UniverseChainId): Promise => { + await saveDappChain(dappUrl, chainId) + sendAnalyticsEvent(ExtensionEventName.SidebarSwitchChain, { + previousChainId: activeChain, + newChainId: chainId, + }) + } + + const handlePress = (): void => { + onPress() + } + + const { ref, maxHeight } = usePreventOverflowBelowFold() + + return ( + + + + + + + + + {t('extension.connection.network')} + + + + + + + {enabledChains.map((chain: UniverseChainId) => { + return ( + + {/* TODO(WALL-5883): Use new component */} + => onNetworkClicked(chain)} + > + + + + + {getChainLabel(chain)} + + + {activeChain === chain ? ( + + + + ) : null} + + + + ) + })} + + + ) +} diff --git a/apps/extension/src/app/features/home/TokenBalanceList.tsx b/apps/extension/src/app/features/home/TokenBalanceList.tsx new file mode 100644 index 00000000..91c7473a --- /dev/null +++ b/apps/extension/src/app/features/home/TokenBalanceList.tsx @@ -0,0 +1,53 @@ +import { Currency } from '@uniswap/sdk-core' +import { memo, useState } from 'react' +import { useInterfaceBuyNavigator } from 'src/app/features/for/utils' +import { AppRoutes } from 'src/app/navigation/constants' +import { navigate } from 'src/app/navigation/state' +import { TokenBalanceListWeb } from 'uniswap/src/components/portfolio/TokenBalanceListWeb' +import { ReportTokenIssueModal } from 'uniswap/src/components/reporting/ReportTokenIssueModal' +import { ElementName } from 'uniswap/src/features/telemetry/constants' +import { useEvent } from 'utilities/src/react/hooks' +import { useBooleanState } from 'utilities/src/react/useBooleanState' +import { usePortfolioEmptyStateBackground } from 'wallet/src/components/portfolio/empty' + +export const ExtensionTokenBalanceList = memo(function ExtensionTokenBalanceListInner({ + owner, +}: { + owner: Address +}): JSX.Element { + const onPressReceive = (): void => { + navigate(`/${AppRoutes.Receive}`) + } + const onPressBuy = useInterfaceBuyNavigator(ElementName.EmptyStateBuy) + + const { value: isReportTokenModalOpen, setTrue: openModal, setFalse: closeReportTokenModal } = useBooleanState(false) + const [reportTokenCurrency, setReportTokenCurrency] = useState(undefined) + const [isMarkedSpam, setIsMarkedSpam] = useState>(undefined) + + const openReportTokenModal = useEvent((currency: Currency, isMarkedSpam: Maybe) => { + setReportTokenCurrency(currency) + setIsMarkedSpam(isMarkedSpam) + openModal() + }) + + const backgroundImageWrapperCallback = usePortfolioEmptyStateBackground() + return ( + <> + + {reportTokenCurrency && ( + + )} + + ) +}) diff --git a/apps/extension/src/app/features/home/introCards/HomeIntroCardStack.tsx b/apps/extension/src/app/features/home/introCards/HomeIntroCardStack.tsx new file mode 100644 index 00000000..9d969166 --- /dev/null +++ b/apps/extension/src/app/features/home/introCards/HomeIntroCardStack.tsx @@ -0,0 +1,38 @@ +import { useCallback } from 'react' +import { AppRoutes, SettingsRoutes, UnitagClaimRoutes } from 'src/app/navigation/constants' +import { focusOrCreateUnitagTab, useExtensionNavigation } from 'src/app/navigation/utils' +import { Flex } from 'ui/src' +import { AccountType } from 'uniswap/src/features/accounts/types' +import { IntroCardStack } from 'wallet/src/components/introCards/IntroCardStack' +import { useSharedIntroCards } from 'wallet/src/components/introCards/useSharedIntroCards' +import { useActiveAccountWithThrow } from 'wallet/src/features/wallet/hooks' + +export function HomeIntroCardStack(): JSX.Element | null { + const activeAccount = useActiveAccountWithThrow() + const isSignerAccount = activeAccount.type === AccountType.SignerMnemonic + const { navigateTo } = useExtensionNavigation() + + const navigateToUnitagClaim = useCallback(async () => { + await focusOrCreateUnitagTab(activeAccount.address, UnitagClaimRoutes.ClaimIntro) + }, [activeAccount.address]) + + const navigateToBackupFlow = useCallback((): void => { + navigateTo(`/${AppRoutes.Settings}/${SettingsRoutes.BackupRecoveryPhrase}`) + }, [navigateTo]) + + const { cards } = useSharedIntroCards({ + navigateToUnitagClaim, + navigateToUnitagIntro: navigateToUnitagClaim, // No need to differentiate for extension + navigateToBackupFlow, + }) + + if (!cards.length || !isSignerAccount) { + return null + } + + return ( + + + + ) +} diff --git a/apps/extension/src/app/features/lockScreen/Locked.tsx b/apps/extension/src/app/features/lockScreen/Locked.tsx new file mode 100644 index 00000000..a493da40 --- /dev/null +++ b/apps/extension/src/app/features/lockScreen/Locked.tsx @@ -0,0 +1,253 @@ +import { useCallback, useLayoutEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useDispatch } from 'react-redux' +import { Input } from 'src/app/components/Input' +import { InfoModal, ModalProps } from 'src/app/components/modal/InfoModal' +import { PasswordInputWithBiometrics } from 'src/app/components/PasswordInput' +import { BiometricUnlockStorage } from 'src/app/features/biometricUnlock/BiometricUnlockStorage' +import { useUnlockWithBiometricCredentialMutation } from 'src/app/features/biometricUnlock/useUnlockWithBiometricCredentialMutation' +import { useUnlockWithPassword } from 'src/app/features/lockScreen/useUnlockWithPassword' +import { OnboardingRoutes, TopLevelRoutes } from 'src/app/navigation/constants' +import { focusOrCreateOnboardingTab } from 'src/app/navigation/focusOrCreateOnboardingTab' +import { ExtensionState } from 'src/store/extensionReducer' +import { Button, Flex, InputProps, Text } from 'ui/src' +import { AlertTriangleFilled, Lock } from 'ui/src/components/icons' +import { spacing, zIndexes } from 'ui/src/theme' +import { uniswapUrls } from 'uniswap/src/constants/urls' +import { ModalName } from 'uniswap/src/features/telemetry/constants' +import { SagaStatus, useMonitoredSagaStatus } from 'uniswap/src/utils/saga' +import { useEvent } from 'utilities/src/react/hooks' +import { LandingBackground } from 'wallet/src/components/landing/LandingBackground' +import { authSagaName } from 'wallet/src/features/auth/saga' +import { AuthSagaError } from 'wallet/src/features/auth/types' +import { EditAccountAction, editAccountActions } from 'wallet/src/features/wallet/accounts/editAccountSaga' +import { useSignerAccounts } from 'wallet/src/features/wallet/hooks' +import { Keyring } from 'wallet/src/features/wallet/Keyring/Keyring' + +function usePasswordInput(defaultValue = ''): Pick & { value: string } { + const [value, setValue] = useState(defaultValue) + + const onChangeText: InputProps['onChangeText'] = (newValue): void => { + setValue(newValue) + } + + return { + value, + disabled: !value, + onChangeText, + } +} + +enum ForgotPasswordModalStep { + Initial = 0, + Speedbump = 1, +} + +const CONTAINER_PADDING_TOP_MIN = 50 +const CONTAINER_PADDING_TOP_MAX = 220 +const BACKGROUND_CIRCLE_INNER_SIZE = 140 +const BACKGROUND_CIRCLE_OUTER_SIZE = 250 + +export function Locked(): JSX.Element { + const dispatch = useDispatch() + const { t } = useTranslation() + const { value: enteredPassword, onChangeText: onChangePasswordText } = usePasswordInput() + const associatedAccounts = useSignerAccounts() + + const onChangeText = useCallback( + (text: string) => { + if (onChangePasswordText) { + onChangePasswordText(text) + } + }, + [onChangePasswordText], + ) + + const { status, error } = useMonitoredSagaStatus(authSagaName) + + const unlockWithPassword = useUnlockWithPassword() + const onPressUnlockWithPassword = useEvent(() => unlockWithPassword({ password: enteredPassword })) + + const [forgotPasswordModalOpen, setForgotPasswordModalOpen] = useState(false) + const [modalStep, setModalStep] = useState(ForgotPasswordModalStep.Initial) + + const openRecoveryTab = (): Promise => + focusOrCreateOnboardingTab(`${TopLevelRoutes.Onboarding}/${OnboardingRoutes.Reset}`) + + const onStartResettingWallet = async (): Promise => { + const currAccount = associatedAccounts[0] + + if (currAccount?.mnemonicId) { + await Keyring.removeMnemonic(currAccount.mnemonicId) + } + await Promise.all([Keyring.removePassword(), BiometricUnlockStorage.remove()]) + + // We open the recovery tab before removing the accounts so that the proper reset route is loaded. + // Otherwise, the main onboarding route is automatically loaded when accounts are all removed, and then a duplicate recovery tab is opened. + // The standard onboarding open logic triggers but doesn't update the path because the generic one doesn't have a path specified. + await openRecoveryTab() + + dispatch( + editAccountActions.trigger({ + type: EditAccountAction.Remove, + accounts: associatedAccounts, + }), + ) + } + + const isIncorrectPassword = status === SagaStatus.Failure && error === AuthSagaError.InvalidPassword + + const inputRef = useRef(null) + const [hideInput, setHideInput] = useState(true) + const toggleHideInput = (): void => setHideInput(!hideInput) + + useLayoutEffect(() => { + if (isIncorrectPassword) { + inputRef.current?.focus() + } + }, [isIncorrectPassword]) + + const modalProps: Record = { + [ForgotPasswordModalStep.Initial]: { + buttonText: t('extension.lock.button.reset'), + description: t('extension.lock.password.reset.initial.description'), + linkText: t('extension.lock.password.reset.initial.help'), + linkUrl: uniswapUrls.helpArticleUrls.recoveryPhraseHowToFind, + icon: ( + + + + ), + isOpen: forgotPasswordModalOpen, + name: ModalName.ForgotPassword, + onButtonPress: (): void => setModalStep(ForgotPasswordModalStep.Speedbump), + title: t('extension.lock.password.reset.initial.title'), + }, + [ForgotPasswordModalStep.Speedbump]: { + buttonText: t('common.button.continue'), + description: t('extension.lock.password.reset.speedbump.description'), + linkText: t('extension.lock.password.reset.speedbump.help'), + linkUrl: uniswapUrls.helpArticleUrls.recoveryPhraseForgotten, + icon: ( + + + + ), + isOpen: forgotPasswordModalOpen, + name: ModalName.ForgotPassword, + onButtonPress: onStartResettingWallet, + title: t('extension.lock.password.reset.speedbump.title'), + }, + } + + const [inputHeight, setInputHeight] = useState(0) + const [containerPaddingTop, setContainerPaddingTop] = useState(CONTAINER_PADDING_TOP_MAX) + const [availableHeight, setAvailableHeight] = useState(0) + + useLayoutEffect(() => { + if (availableHeight && inputHeight) { + const containerHeight = inputHeight + spacing.spacing32 + const newPaddingTop = Math.min( + Math.max(CONTAINER_PADDING_TOP_MIN, availableHeight - containerHeight), + CONTAINER_PADDING_TOP_MAX, + ) + + setContainerPaddingTop(newPaddingTop) + } + }, [availableHeight, inputHeight]) + + const { mutate: unlockWithBiometricCredential } = useUnlockWithBiometricCredentialMutation() + + return ( + <> + + setAvailableHeight(e.nativeEvent.layout.height)}> + + + + setInputHeight(e.nativeEvent.layout.height)} + > + + + {t('extension.lock.title')} + + + + {t('extension.lock.subtitle')} + + + + + + + + + {t('extension.lock.password.error')} + + + + + + + + + + + + + + + + + + { + setModalStep(ForgotPasswordModalStep.Initial) + setForgotPasswordModalOpen(false) + }} + /> + + ) +} diff --git a/apps/extension/src/app/features/lockScreen/useUnlockWithPassword.ts b/apps/extension/src/app/features/lockScreen/useUnlockWithPassword.ts new file mode 100644 index 00000000..b0b90295 --- /dev/null +++ b/apps/extension/src/app/features/lockScreen/useUnlockWithPassword.ts @@ -0,0 +1,17 @@ +import { useDispatch } from 'react-redux' +import { useEvent } from 'utilities/src/react/hooks' +import { authActions } from 'wallet/src/features/auth/saga' +import { AuthActionType } from 'wallet/src/features/auth/types' + +export function useUnlockWithPassword(): (params: { password: string }) => void { + const dispatch = useDispatch() + + return useEvent(({ password }: { password: string }) => { + dispatch( + authActions.trigger({ + type: AuthActionType.Unlock, + password, + }), + ) + }) +} diff --git a/apps/extension/src/app/features/notifications/NotificationToastWrapper.tsx b/apps/extension/src/app/features/notifications/NotificationToastWrapper.tsx new file mode 100644 index 00000000..cdad81a6 --- /dev/null +++ b/apps/extension/src/app/features/notifications/NotificationToastWrapper.tsx @@ -0,0 +1,37 @@ +import { useSelectAddressNotifications } from 'uniswap/src/features/notifications/slice/hooks' +import { AppNotification, AppNotificationType } from 'uniswap/src/features/notifications/slice/types' +import { DappConnectedNotification } from 'wallet/src/features/notifications/components/DappConnectedNotification' +import { DappDisconnectedNotification } from 'wallet/src/features/notifications/components/DappDisconnectedNotification' +import { NotSupportedNetworkNotification } from 'wallet/src/features/notifications/components/NotSupportedNetworkNotification' +import { PasswordChangedNotification } from 'wallet/src/features/notifications/components/PasswordChangedNotification' +import { WalletNotificationToastRouter } from 'wallet/src/features/notifications/components/SharedNotificationToastRouter' +import { useActiveAccountAddress } from 'wallet/src/features/wallet/hooks' + +export function NotificationToastWrapper(): JSX.Element | null { + const activeAccountAddress = useActiveAccountAddress() + const notifications = useSelectAddressNotifications(activeAccountAddress) + const notification = notifications?.[0] + + if (!notification) { + return null + } + + return +} + +function NotificationToastRouter({ notification }: { notification: AppNotification }): JSX.Element | null { + // Insert Extension-only notifications here. + // Shared wallet notifications should go in SharedNotificationToastRouter. + switch (notification.type) { + case AppNotificationType.DappConnected: + return + case AppNotificationType.NotSupportedNetwork: + return + case AppNotificationType.DappDisconnected: + return + case AppNotificationType.PasswordChanged: + return + } + + return +} diff --git a/apps/extension/src/app/features/onboarding/BiometricUnlockSetUp.tsx b/apps/extension/src/app/features/onboarding/BiometricUnlockSetUp.tsx new file mode 100644 index 00000000..ed4691c4 --- /dev/null +++ b/apps/extension/src/app/features/onboarding/BiometricUnlockSetUp.tsx @@ -0,0 +1,96 @@ +import { useQuery } from '@tanstack/react-query' +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useBiometricUnlockSetupMutation } from 'src/app/features/biometricUnlock/useBiometricUnlockSetupMutation' +import { OnboardingScreen } from 'src/app/features/onboarding/OnboardingScreen' +import { SettingsToggleRow } from 'src/app/features/settings/components/SettingsToggleRow' +import { builtInBiometricCapabilitiesQuery } from 'src/app/utils/device/builtInBiometricCapabilitiesQuery' +import { Flex, Loader, Square } from 'ui/src' +import { iconSizes } from 'ui/src/theme' +import Trace from 'uniswap/src/features/telemetry/Trace' +import { ExtensionOnboardingFlow, ExtensionOnboardingScreens } from 'uniswap/src/types/screens/extension' +import { logger } from 'utilities/src/logger/logger' +import { useEvent } from 'utilities/src/react/hooks' + +export function BiometricUnlockSetUp({ + flow, + password, + onBack, + onNext, +}: { + flow: ExtensionOnboardingFlow + password: string + onBack?: () => void + onNext: () => void +}): JSX.Element | null { + const { t } = useTranslation() + const [isToggleEnabled, setIsToggleEnabled] = useState(true) + + const { data: biometricCapabilities, isLoading: isLoadingBiometricCapabilities } = useQuery( + builtInBiometricCapabilitiesQuery({ t }), + ) + + useEffect(() => { + if (!isLoadingBiometricCapabilities && !biometricCapabilities?.hasBuiltInBiometricSensor) { + // The user should never end up in this screen when the device doesn't have a built-in biometric sensor, + // but if they do, we automatically advance to the next step. + logger.error(new Error('Invalid render of `BiometricUnlockSetUp` when no built in biometric sensor found'), { + tags: { file: 'BiometricUnlockSetUp.tsx', function: 'BiometricUnlockSetUp' }, + }) + onNext() + } + }, [isLoadingBiometricCapabilities, biometricCapabilities?.hasBuiltInBiometricSensor, onNext]) + + const { mutate: setupBiometricUnlock } = useBiometricUnlockSetupMutation({ + onSuccess: onNext, + // Automatically disable biometrics if there's an error to avoid the user getting stuck. + onError: () => setIsToggleEnabled(false), + }) + + const onContinue = useEvent(() => { + if (!isToggleEnabled) { + onNext() + return + } + setupBiometricUnlock(password) + }) + + const icon = biometricCapabilities?.hasBuiltInBiometricSensor ? ( + + ) : null + + return ( + + + {icon} + + } + nextButtonEnabled + nextButtonText={t('common.button.continue')} + subtitle={t('onboarding.extension.biometrics.subtitle.fingerprint')} + title={ + biometricCapabilities + ? t('onboarding.extension.biometrics.title', { biometricsMethod: biometricCapabilities.name }) + : undefined + } + onBack={onBack} + onSubmit={onContinue} + > + {!biometricCapabilities ? ( + + ) : ( + + + + )} + + + ) +} diff --git a/apps/extension/src/app/features/onboarding/ClaimUnitagScreen.tsx b/apps/extension/src/app/features/onboarding/ClaimUnitagScreen.tsx new file mode 100644 index 00000000..181e6a7d --- /dev/null +++ b/apps/extension/src/app/features/onboarding/ClaimUnitagScreen.tsx @@ -0,0 +1,64 @@ +import { useCallback } from 'react' +import { useTranslation } from 'react-i18next' +import { OnboardingScreen } from 'src/app/features/onboarding/OnboardingScreen' +import { useOnboardingSteps } from 'src/app/features/onboarding/OnboardingStepsContext' +import { TopLevelRoutes } from 'src/app/navigation/constants' +import { navigate } from 'src/app/navigation/state' +import { Flex, Square } from 'ui/src' +import { Person } from 'ui/src/components/icons' +import { iconSizes } from 'ui/src/theme' +import Trace from 'uniswap/src/features/telemetry/Trace' +import { ClaimUnitagContent } from 'uniswap/src/features/unitags/ClaimUnitagContent' +import { ExtensionOnboardingFlow, ExtensionOnboardingScreens } from 'uniswap/src/types/screens/extension' +import { useOnboardingContext } from 'wallet/src/features/onboarding/OnboardingContext' + +export function ClaimUnitagScreen(): JSX.Element { + const { t } = useTranslation() + const { goToNextStep } = useOnboardingSteps() + const { resetOnboardingContextData, getOnboardingAccountAddress, addUnitagClaim } = useOnboardingContext() + const onboardingAccountAddress = getOnboardingAccountAddress() + + const onComplete = useCallback( + (unitag: string) => { + addUnitagClaim({ username: unitag }) + goToNextStep() + }, + [goToNextStep, addUnitagClaim], + ) + + const handleBack = useCallback(() => { + // reset the pending mnemonic when going back from password screen + // to avoid having them in the context when coming back to either screen + resetOnboardingContextData() + navigate(`/${TopLevelRoutes.Onboarding}`, { replace: true }) + }, [resetOnboardingContextData]) + + return ( + + + + + } + subtitle={t('unitags.onboarding.claim.subtitle')} + title={t('unitags.onboarding.claim.title.choose')} + onBack={handleBack} + onSkip={goToNextStep} + > + + + + + + ) +} diff --git a/apps/extension/src/app/features/onboarding/Complete.tsx b/apps/extension/src/app/features/onboarding/Complete.tsx new file mode 100644 index 00000000..de68630f --- /dev/null +++ b/apps/extension/src/app/features/onboarding/Complete.tsx @@ -0,0 +1,77 @@ +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { OpenSidebarButton } from 'src/app/components/buttons/OpenSidebarButton' +import { useFinishExtensionOnboarding } from 'src/app/features/onboarding/hooks/useFinishExtensionOnboarding' +import { useOpenSidebar } from 'src/app/features/onboarding/hooks/useOpenSidebar' +import { MainContentWrapper } from 'src/app/features/onboarding/intro/MainContentWrapper' +import { KeyboardKey } from 'src/app/features/onboarding/KeyboardKey' +import { useOpeningKeyboardShortCut } from 'src/app/hooks/useOpeningKeyboardShortCut' +import { terminateStoreSynchronization } from 'src/store/storeSynchronization' +import { Flex, Image, Text } from 'ui/src' +import { UNISWAP_LOGO } from 'ui/src/assets' +import { iconSizes } from 'ui/src/theme' +import { ExtensionOnboardingFlow } from 'uniswap/src/types/screens/extension' +import { useOnboardingContext } from 'wallet/src/features/onboarding/OnboardingContext' + +export function Complete({ + flow, + tryToClaimUnitag, +}: { + flow?: ExtensionOnboardingFlow + tryToClaimUnitag?: boolean +}): JSX.Element { + const { t } = useTranslation() + const { getOnboardingAccountAddress, addUnitagClaim, getUnitagClaim } = useOnboardingContext() + const address = getOnboardingAccountAddress() + const existingClaim = getUnitagClaim() + const [unitagClaimAttempted, setUnitagClaimAttempted] = useState(false) + const { openedSideBar, handleOpenSidebar, handleOpenWebApp } = useOpenSidebar() + + useEffect(() => { + if (!tryToClaimUnitag || !address || unitagClaimAttempted) { + return + } + + setUnitagClaimAttempted(true) + if (existingClaim?.username) { + addUnitagClaim({ address, username: existingClaim.username }) + } + }, [existingClaim, address, tryToClaimUnitag, unitagClaimAttempted, addUnitagClaim]) + + // Activates onboarding accounts on component mount + useFinishExtensionOnboarding({ + callback: terminateStoreSynchronization, + extensionOnboardingFlow: flow, + skip: tryToClaimUnitag && !unitagClaimAttempted, + }) + + const keys = useOpeningKeyboardShortCut(openedSideBar) + + return ( + + + + + + + {t('onboarding.complete.title')} + + + {t('onboarding.complete.description')} + + + + {keys.map((key) => ( + + ))} + + + + + + ) +} diff --git a/apps/extension/src/app/features/onboarding/KeyboardKey.test.tsx b/apps/extension/src/app/features/onboarding/KeyboardKey.test.tsx new file mode 100644 index 00000000..04692bde --- /dev/null +++ b/apps/extension/src/app/features/onboarding/KeyboardKey.test.tsx @@ -0,0 +1,33 @@ +import { KeyboardKey } from 'src/app/features/onboarding/KeyboardKey' +import { State } from 'src/app/hooks/useOpeningKeyboardShortCut' +import { cleanup, render, screen } from 'src/test/test-utils' + +describe('KeyboardKey Component', () => { + it('renders correctly with state KeyUp', () => { + const { container } = render() + expect(container).toMatchSnapshot() + }) + + it('renders correctly with state KeyDown', () => { + const { container } = render() + expect(container).toMatchSnapshot() + }) + + it('renders correctly with state Highlighted', () => { + const { container } = render() + expect(container).toMatchSnapshot() + cleanup() + }) + + it('displays the command symbol for Meta key on macOS', () => { + render() + expect(screen.getByText('⌘')).toBeDefined() + cleanup() + }) + + it('displays the correct title for other keys', () => { + render() + expect(screen.getByText('U')).toBeDefined() + cleanup() + }) +}) diff --git a/apps/extension/src/app/features/onboarding/KeyboardKey.tsx b/apps/extension/src/app/features/onboarding/KeyboardKey.tsx new file mode 100644 index 00000000..7e62bbc9 --- /dev/null +++ b/apps/extension/src/app/features/onboarding/KeyboardKey.tsx @@ -0,0 +1,41 @@ +import { Flex, Text } from 'ui/src' + +const SHADOW_OFFSET = { width: 0, height: 7 } +const MAC_OS_COMMAND_SYMBOL = '⌘' +const KEY_HEIGHT = 70 + +enum State { + KeyUp = 0, + KeyDown = 1, + Highlighted = 2, +} + +export interface KeyboardKeyProps { + title: string + px: React.ComponentProps['px'] + fontSize: React.ComponentProps['fontSize'] + state: State +} + +export function KeyboardKey({ title, px, fontSize, state }: KeyboardKeyProps): JSX.Element { + return ( + + + {title === 'Meta' ? MAC_OS_COMMAND_SYMBOL : title} + + + ) +} diff --git a/apps/extension/src/app/features/onboarding/OnboardingPaneAnimatedContents.tsx b/apps/extension/src/app/features/onboarding/OnboardingPaneAnimatedContents.tsx new file mode 100644 index 00000000..9456e2e9 --- /dev/null +++ b/apps/extension/src/app/features/onboarding/OnboardingPaneAnimatedContents.tsx @@ -0,0 +1,20 @@ +import { Flex, styled } from 'ui/src' + +const SINGLE_PANE_DURATION = 200 + +// TODO: EXT-1164 - Move Keyring methods to workers to not block main thread during onboarding +// if exitBeforeEnter is set in the AnimatePresence we are +// running two 200ms animations sequentially - first to exit, then enter so we +// double this constant. if we change that, needs to change here +const ONBOARDING_PANE_TRANSITION_DURATION = SINGLE_PANE_DURATION * 2 +export const ONBOARDING_PANE_TRANSITION_DURATION_WITH_LEEWAY = ONBOARDING_PANE_TRANSITION_DURATION + 200 + +export const OnboardingPaneAnimatedContents = styled(Flex, { + animation: `${SINGLE_PANE_DURATION}ms`, + width: '100%', + + zIndex: 1, + x: 0, + opacity: 1, + mx: 'auto', +}) diff --git a/apps/extension/src/app/features/onboarding/OnboardingScreen.tsx b/apps/extension/src/app/features/onboarding/OnboardingScreen.tsx new file mode 100644 index 00000000..e36702d8 --- /dev/null +++ b/apps/extension/src/app/features/onboarding/OnboardingScreen.tsx @@ -0,0 +1,21 @@ +import { useContext, useLayoutEffect } from 'react' +import { OnboardingScreenProps } from 'src/app/features/onboarding/OnboardingScreenProps' +import { OnboardingStepsContext } from 'src/app/features/onboarding/OnboardingStepsContext' + +export function OnboardingScreen(props: OnboardingScreenProps): null { + const context = useContext(OnboardingStepsContext) + + useLayoutEffect(() => { + if (!context) { + return undefined + } + + context.setOnboardingScreen(props) + return () => { + context.clearOnboardingScreen(props) + } + }, [context, props]) + + // we hoist it up, see OnboardingSteps + OnboardingScreenFrame + return null +} diff --git a/apps/extension/src/app/features/onboarding/OnboardingScreenFrame.tsx b/apps/extension/src/app/features/onboarding/OnboardingScreenFrame.tsx new file mode 100644 index 00000000..c8076664 --- /dev/null +++ b/apps/extension/src/app/features/onboarding/OnboardingScreenFrame.tsx @@ -0,0 +1,104 @@ +import { useTranslation } from 'react-i18next' +import { OnboardingScreenProps } from 'src/app/features/onboarding/OnboardingScreenProps' +import { Button, Flex, Text, TouchableArea } from 'ui/src' +import { BackArrow } from 'ui/src/components/icons' +import i18n from 'uniswap/src/i18n' +import { TestID } from 'uniswap/src/test/fixtures/testIDs' + +export function OnboardingScreenFrame({ + Icon, + children, + nextButtonEnabled, + nextButtonIcon, + nextButtonText = i18n.t('common.button.next'), + nextButtonVariant = 'branded', + nextButtonEmphasis = 'primary', + onBack, + onSubmit, + onSkip, + subtitle, + title, + warningSubtitle, + endAdornment, + noTopPadding, +}: Partial): JSX.Element { + const { t } = useTranslation() + + if (!title) { + return <>{children} + } + + return ( + <> + + {onBack && ( + + + + )} + {onSkip && ( + + + {t('common.button.skip')} + + + )} + {endAdornment && ( + + {endAdornment} + + )} + {Icon} + + + {title} + + + + {subtitle} + + {warningSubtitle && ( + + {warningSubtitle} + + )} + + + + + {children} + + + {Boolean(onSubmit) && nextButtonText && ( + + )} + + + ) +} diff --git a/apps/extension/src/app/features/onboarding/OnboardingScreenProps.tsx b/apps/extension/src/app/features/onboarding/OnboardingScreenProps.tsx new file mode 100644 index 00000000..e312a7e6 --- /dev/null +++ b/apps/extension/src/app/features/onboarding/OnboardingScreenProps.tsx @@ -0,0 +1,21 @@ +import { ButtonEmphasis, ButtonVariant } from 'ui/src' + +export type OnboardingScreenProps = { + Icon?: JSX.Element + children?: JSX.Element + nextButtonEnabled?: boolean + nextButtonIcon?: JSX.Element + nextButtonText?: string + nextButtonVariant?: ButtonVariant + nextButtonEmphasis?: ButtonEmphasis + onBack?: () => void + onSubmit?: () => void + onSkip?: () => void + subtitle?: string + title?: string | JSX.Element + warningSubtitle?: string + outsideContent?: JSX.Element + belowFrameContent?: JSX.Element + endAdornment?: JSX.Element + noTopPadding?: boolean +} diff --git a/apps/extension/src/app/features/onboarding/OnboardingSteps.tsx b/apps/extension/src/app/features/onboarding/OnboardingSteps.tsx new file mode 100644 index 00000000..c031b329 --- /dev/null +++ b/apps/extension/src/app/features/onboarding/OnboardingSteps.tsx @@ -0,0 +1,339 @@ +import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useSelector } from 'react-redux' +import { OnboardingPaneAnimatedContents } from 'src/app/features/onboarding/OnboardingPaneAnimatedContents' +import { OnboardingScreenFrame } from 'src/app/features/onboarding/OnboardingScreenFrame' +import { OnboardingScreenProps } from 'src/app/features/onboarding/OnboardingScreenProps' +import { + OnboardingStepsContext, + OnboardingStepsContextState, + Step, +} from 'src/app/features/onboarding/OnboardingStepsContext' +import { ONBOARDING_CONTENT_WIDTH, ONBOARDING_INITIAL_FRAME_HEIGHT } from 'src/app/features/onboarding/utils' +import { TopLevelRoutes } from 'src/app/navigation/constants' +import { navigate } from 'src/app/navigation/state' +import { isOnboardedSelector } from 'src/app/utils/isOnboardedSelector' +import { AnimatePresence, Flex, styled, useWindowDimensions } from 'ui/src' + +export * from './OnboardingStepsContext' + +type ComponentByStep = { [key in Step]?: JSX.Element } +type MaybeOnboardingProps = OnboardingScreenProps | null + +/** + * In this file we're doing some weird stuff because we want to keep a nice API + * for onboarding screens but also allow animating them, while still working + * with react router. + * + * AnimatePresence wants to be able to swap out old for new, but react router + * wants to handle that as well + * + * So we have to hoist the props of up to here. + * + * But doing that could cause a re-render loop if the child component isn't + * careful to memoize things. So, we've implemented a little pattern here to + * avoid that - instead of re-rendering the entire OnboardingStepsProvider + * whenever a child re-renders, we instead have a simple emitter/listener we + * trigger (onboardingScreenListen) and we the re-render the contents in a + * sub-component OnboardingScreenDisplay. This way OnboardingScreenDisplay can + * re-render as much as it wants and it doesn't cause the child to re-render, + * avoiding loops! + */ + +let currentOnboardingScreen: MaybeOnboardingProps = null +const onboardingScreenListen = new Set<(step: Step, val: MaybeOnboardingProps) => void>() + +let clearScreenTimeout: NodeJS.Timeout + +export function OnboardingStepsProvider({ + steps, + isResetting = false, + disableRedirect = false, + ContainerComponent = React.Fragment, +}: { + steps: ComponentByStep + isResetting?: boolean + disableRedirect?: boolean + ContainerComponent?: React.ComponentType +}): JSX.Element { + const isOnboarded = useSelector(isOnboardedSelector) + const wasAlreadyOnboardedWhenPageLoaded = useRef(isOnboarded) + + // oxlint-disable-next-line react/exhaustive-deps -- we also want to run this effect if isOnboarded changes + useEffect(() => { + if (!isResetting && wasAlreadyOnboardedWhenPageLoaded.current && !disableRedirect) { + // Redirect to the intro screen screen if user is already onboarded. + // We only want to redirect when the page is first loaded but not immediately after the user completes onboarding. + navigate(`/${TopLevelRoutes.Onboarding}`, { replace: true }) + } + }, [disableRedirect, isOnboarded, isResetting]) + + const initialStep = Object.keys(steps)[0] as Step | undefined + + if (!initialStep) { + throw new Error('`steps` must have at least one `step`') + } + + const [{ step, going, onboardingScreen }, setState] = useState<{ + onboardingScreen?: MaybeOnboardingProps + step: Step + going: 'forward' | 'backward' + }>({ + step: initialStep, + going: 'forward', + }) + + // This is needed to force the onboarding screen to re-render when the belowFrameContent or outsideContent changes + useEffect(() => { + const handler = (nextStep: Step, next: MaybeOnboardingProps): void => { + if ( + nextStep === step && + (next?.belowFrameContent !== onboardingScreen?.belowFrameContent || + next?.outsideContent !== onboardingScreen?.outsideContent) + ) { + setState((prev) => { + return { + ...prev, + onboardingScreen: { + ...prev.onboardingScreen, + belowFrameContent: next?.belowFrameContent, + outsideContent: next?.outsideContent, + }, + } + }) + } + } + + onboardingScreenListen.add(handler) + return () => { + onboardingScreenListen.delete(handler) + } + }, [onboardingScreen?.belowFrameContent, onboardingScreen?.outsideContent, step]) + + const getCurrentStep = useRef(step) + getCurrentStep.current = step + + const setStep = useCallback((nextStep: Step) => { + setState((prev) => ({ ...prev, step: nextStep })) + }, []) + + // oxlint-disable-next-line react/exhaustive-deps -- onboardingScreenKey is a helper function defined below that doesn't need to be a dependency + const setOnboardingScreen = useCallback((next: OnboardingScreenProps) => { + clearTimeout(clearScreenTimeout) + setState((prev) => { + // we are only updating onboardingScreen here once per unique title so + // the state in this component is accurate, but subsequent updates go + // through the emitter + if (onboardingScreenKey(prev.onboardingScreen) !== onboardingScreenKey(next)) { + return { + ...prev, + onboardingScreen: next, + } + } + return prev + }) + onboardingScreenListen.forEach((cb) => cb(getCurrentStep.current, next)) + currentOnboardingScreen = next + }, []) + + // oxlint-disable-next-line react/exhaustive-deps -- onboardingScreenKey is a helper function defined below that doesn't need to be a dependency + const clearOnboardingScreen = useCallback((next: OnboardingScreenProps) => { + // delay clear so the next screen can beat clearing the old one to avoid flickering + clearScreenTimeout = setTimeout(() => { + setState((prev) => { + if (prev.onboardingScreen && onboardingScreenKey(prev.onboardingScreen) === onboardingScreenKey(next)) { + return { + ...prev, + onboardingScreen: null, + } + } + return prev + }) + }) + }, []) + + const onboardingScreenKey = (props?: MaybeOnboardingProps): string => { + const keysString = Object.keys(props || {}).join('') + const { title, subtitle } = props ?? {} + return `${title}${subtitle}${keysString}` + } + + const goToNextStep = useCallback(() => { + const stepIndex = Object.keys(steps).indexOf(step) + const nextStep = Object.keys(steps)[stepIndex + 1] as Step | undefined + + if (!nextStep) { + throw new Error('No next step') + } + + setState((prev) => ({ + ...prev, + step: nextStep, + going: 'forward', + })) + }, [step, steps]) + + const goToPreviousStep = useCallback(() => { + const stepIndex = Object.keys(steps).indexOf(step) + const previousStep = Object.keys(steps)[stepIndex - 1] as Step | undefined + + if (!previousStep) { + throw new Error('No previous step') + } + + setState((prev) => ({ + ...prev, + step: previousStep, + going: 'backward', + })) + }, [step, steps]) + + const state = useMemo((): OnboardingStepsContextState => { + return { + step, + setStep, + goToNextStep, + setOnboardingScreen, + clearOnboardingScreen, + goToPreviousStep, + isResetting, + going, + } + }, [step, setStep, goToNextStep, setOnboardingScreen, clearOnboardingScreen, goToPreviousStep, isResetting, going]) + + const stepContents = steps[step] + const [frameHeight, setFrameHeight] = useState(ONBOARDING_INITIAL_FRAME_HEIGHT) + const windowDimensions = useWindowDimensions() + const modalY = windowDimensions.height / 2 - frameHeight / 2 + const hasBelowFrameContent = Boolean(onboardingScreen?.belowFrameContent) + const [belowFrameHeight, setBelowFrameHeight] = useState(-1) + const y = + modalY + + // ensure vertically centered when belowFrameContent exists + (hasBelowFrameContent + ? -(belowFrameHeight === -1 + ? // estimate the content height before measurement + 63 + : belowFrameHeight) + 30 + : 0) + + if (!stepContents) { + throw new Error(`Unknown step: ${step}`) + } + + return ( + + + {!onboardingScreen && <>{stepContents}} + + {/* render the contents from step here */} + {onboardingScreen && ( + <> + {/* render actual screen contents "offscreen", we use context and put it on onboardingScreen */} + {/* oxlint-disable-next-line react/forbid-elements -- probably we can replace it here */} +
{stepContents}
+ { + setFrameHeight(e.nativeEvent.layout.height) + }} + > + + + {/** + * animate the inner contents of the onboarding steps modal + * exitBeforeEnter because we are keeping things simpler and having the inner contents + * not be absolutely positioned, which would let us do overlapping animations but we'd have + * to measure dimensions and do some delicate state management around that. + */} + + {/* note: the exitBeforeEnter here affects the constant ONBOARDING_PANE_TRANSITION_DURATION in OnboardingPaneAnimatedContents.tsx */} + + + + + + + + {hasBelowFrameContent && ( + setBelowFrameHeight(e.nativeEvent.layout.height)} + > + {onboardingScreen.belowFrameContent} + + )} + + + )} + + {onboardingScreen?.outsideContent || null} +
+
+ ) +} + +const OnboardingScreenDisplay = memo(function OnboardingScreenDisplay(props: { step: Step }): JSX.Element { + const [state, setState] = useState(currentOnboardingScreen) + + useEffect(() => { + const handler = (step: Step, next: MaybeOnboardingProps): void => { + if (step === props.step) { + setState(next) + } + } + + onboardingScreenListen.add(handler) + return () => { + onboardingScreenListen.delete(handler) + } + }, [props.step]) + + return +}) + +// containing frame just for positioning +const Frame = styled(Flex, { + position: 'absolute', + top: 0, + left: '50%', + x: -ONBOARDING_CONTENT_WIDTH * 0.5, + alignItems: 'center', + justifyContent: 'center', + width: ONBOARDING_CONTENT_WIDTH, +}) + +// separate frame background so we can animate +const FrameBackground = styled(Flex, { + position: 'absolute', + top: 0, + left: 0, + width: '100%', + backgroundColor: '$surface1', + borderColor: '$surface3', + borderRadius: '$rounded32', + borderWidth: '$spacing1', + shadowRadius: 4, + shadowColor: '$shadowColor', + shadowOffset: { + height: 2, + width: 0, + }, + shadowOpacity: 0.25, +}) + +// inner frame to prevent overflow of outer frame +const FrameInner = styled(Flex, { + height: '100%', + overflow: 'hidden', + width: '100%', + borderRadius: '$rounded32', + gap: '$spacing12', + pb: '$spacing24', + pt: '$spacing24', + px: '$spacing24', +}) diff --git a/apps/extension/src/app/features/onboarding/OnboardingStepsContext.tsx b/apps/extension/src/app/features/onboarding/OnboardingStepsContext.tsx new file mode 100644 index 00000000..92d36faf --- /dev/null +++ b/apps/extension/src/app/features/onboarding/OnboardingStepsContext.tsx @@ -0,0 +1,79 @@ +import { createContext, useContext } from 'react' +import { OnboardingScreenProps } from 'src/app/features/onboarding/OnboardingScreenProps' + +export enum CreateOnboardingSteps { + ClaimUnitag = 'claimUnitag', + Password = 'password', + Complete = 'complete', +} + +export enum SelectImportMethodSteps { + SelectMethod = 'selectMethod', +} + +export enum ImportPasskeySteps { + InitiatePasskeyAuth = 'initiatePasskeyAuth', + PasskeyImport = 'passkeyImport', +} + +export enum ImportOnboardingSteps { + Mnemonic = 'mnemonic', + Password = 'password', + Select = 'select', + Backup = 'backup', + Complete = 'complete', +} + +export enum ResetSteps { + Mnemonic = 'mnemonic', + Password = 'password', + Complete = 'complete', + Select = 'select', +} + +export enum ScanOnboardingSteps { + Password = 'password', + Scan = 'scan', + OTP = 'otp', + Select = 'select', + Complete = 'complete', +} + +export enum ClaimUnitagSteps { + Intro = 'intro', + CreateUsername = 'createUsername', + ChooseProfilePic = 'chooseProfilePic', + EditProfile = 'editProfile', + Confirmation = 'confirmation', +} + +export type Step = + | CreateOnboardingSteps + | ImportOnboardingSteps + | ResetSteps + | ScanOnboardingSteps + | ClaimUnitagSteps + | SelectImportMethodSteps + | ImportPasskeySteps +export type OnboardingStepsContextState = { + step: Step + going?: 'forward' | 'backward' + setStep: (step: Step) => void + setOnboardingScreen: (screen: OnboardingScreenProps) => void + clearOnboardingScreen: (screen: OnboardingScreenProps) => void + goToNextStep: () => void + goToPreviousStep: () => void + isResetting: boolean +} + +export const OnboardingStepsContext = createContext(undefined) + +export function useOnboardingSteps(): OnboardingStepsContextState { + const onboardingStepsContext = useContext(OnboardingStepsContext) + + if (onboardingStepsContext === undefined) { + throw new Error('`useOnboardingSteps` must be used inside of `OnboardingStepsProvider`') + } + + return onboardingStepsContext +} diff --git a/apps/extension/src/app/features/onboarding/OnboardingWrapper.tsx b/apps/extension/src/app/features/onboarding/OnboardingWrapper.tsx new file mode 100644 index 00000000..351a0596 --- /dev/null +++ b/apps/extension/src/app/features/onboarding/OnboardingWrapper.tsx @@ -0,0 +1,54 @@ +import { useEffect, useState } from 'react' +import { Outlet } from 'react-router' +import { DevMenuModal } from 'src/app/core/DevMenuModal' +import { StorageWarningModal } from 'src/app/features/warnings/StorageWarningModal' +import { onboardingMessageChannel } from 'src/background/messagePassing/messageChannels' +import { OnboardingMessageType } from 'src/background/messagePassing/types/ExtensionMessages' +import { ONBOARDING_BACKGROUND_DARK, ONBOARDING_BACKGROUND_LIGHT } from 'src/public/assets' +import { Flex, Image, useIsDarkMode } from 'ui/src' +import { isProdEnv } from 'utilities/src/environment/env' +import { OnboardingContextProvider } from 'wallet/src/features/onboarding/OnboardingContext' +import { useTestnetModeForLoggingAndAnalytics } from 'wallet/src/features/testnetMode/hooks/useTestnetModeForLoggingAndAnalytics' + +export function OnboardingWrapper(): JSX.Element { + const isDarkMode = useIsDarkMode() + const [isHighlighted, setIsHighlighted] = useState(false) + + useTestnetModeForLoggingAndAnalytics() + + useEffect(() => { + return onboardingMessageChannel.addMessageListener(OnboardingMessageType.HighlightOnboardingTab, (_message) => { + // When the onboarding tab regains focus, we do a quick background change to bring attention to it. + // Otherwise, the user might not notice that the tab is now active, specially if the tab is on a different monitor. + setIsHighlighted(true) + setTimeout(() => setIsHighlighted(false), 200) + }) + }, []) + + return ( + + {!isProdEnv() && } + + + + {/* TODO: Update this to use the new background asset with varying blur level */} + {!isHighlighted && ( + + )} + + + + ) +} diff --git a/apps/extension/src/app/features/onboarding/Password.tsx b/apps/extension/src/app/features/onboarding/Password.tsx new file mode 100644 index 00000000..44a50184 --- /dev/null +++ b/apps/extension/src/app/features/onboarding/Password.tsx @@ -0,0 +1,160 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { PADDING_STRENGTH_INDICATOR, PasswordInput } from 'src/app/components/PasswordInput' +import { useShouldShowBiometricUnlockEnrollment } from 'src/app/features/biometricUnlock/useShouldShowBiometricUnlockEnrollment' +import { BiometricUnlockSetUp } from 'src/app/features/onboarding/BiometricUnlockSetUp' +import { OnboardingScreen } from 'src/app/features/onboarding/OnboardingScreen' +import { useOnboardingSteps } from 'src/app/features/onboarding/OnboardingSteps' +import { TopLevelRoutes } from 'src/app/navigation/constants' +import { navigate } from 'src/app/navigation/state' +import { Flex, Square, Text } from 'ui/src' +import { Lock } from 'ui/src/components/icons' +import { iconSizes } from 'ui/src/theme' +import Trace from 'uniswap/src/features/telemetry/Trace' +import { ExtensionOnboardingFlow, ExtensionOnboardingScreens } from 'uniswap/src/types/screens/extension' +import { useEvent } from 'utilities/src/react/hooks' +import { useOnboardingContext } from 'wallet/src/features/onboarding/OnboardingContext' +import { usePasswordForm } from 'wallet/src/utils/password' + +export function Password({ + flow, + onComplete, + onBack, +}: { + flow: ExtensionOnboardingFlow + onComplete: (password: string) => Promise + onBack?: () => void +}): JSX.Element { + const { resetOnboardingContextData } = useOnboardingContext() + + const [password, setPassword] = useState(null) + + const shouldShowBiometricUnlockEnrollment = useShouldShowBiometricUnlockEnrollment({ flow: 'onboarding' }) + + const onPasswordNext = useEvent(async (password: string) => { + if (shouldShowBiometricUnlockEnrollment) { + setPassword(password) + } else { + await onComplete(password) + } + }) + + const onPasswordBack = useEvent(() => { + // reset the pending mnemonic when going back from password screen + // to avoid having them in the context when coming back to either screen + resetOnboardingContextData() + if (onBack) { + onBack() + } else { + navigate(`/${TopLevelRoutes.Onboarding}`, { replace: true }) + } + }) + + const onBiometricsNext = useEvent(async () => { + if (!password) { + // This should never happen, and we can't recover from it. + throw new Error('Missing password when calling `onBiometricsNext`') + } + await onComplete(password) + }) + + const onBiometricsBack = useEvent(() => { + setPassword(null) + }) + + return password ? ( + + ) : ( + + ) +} + +function PasswordScreen({ + flow, + onNext, + onBack, +}: { + flow: ExtensionOnboardingFlow + onNext: (password: string) => Promise + onBack?: () => void +}): JSX.Element { + const { t } = useTranslation() + + const { isResetting } = useOnboardingSteps() + + const { + enableNext, + hideInput, + debouncedPasswordStrength, + password, + onPasswordBlur, + onChangePassword, + confirmPassword, + onChangeConfirmPassword, + setHideInput, + errorText, + checkSubmit, + } = usePasswordForm() + + const onSubmit = useEvent(async () => { + if (!enableNext) { + return + } + + if (checkSubmit()) { + onNext(password) + } + }) + + return ( + + + + + } + nextButtonEnabled={enableNext} + nextButtonText={t('common.button.continue')} + subtitle={t('onboarding.extension.password.subtitle')} + title={ + isResetting + ? t('onboarding.extension.password.title.reset') + : t('onboarding.extension.password.title.default') + } + onBack={onBack} + onSubmit={onSubmit} + > + + + + + {errorText || 'Placeholder text'} + + + + + ) +} diff --git a/apps/extension/src/app/features/onboarding/PasswordImport.tsx b/apps/extension/src/app/features/onboarding/PasswordImport.tsx new file mode 100644 index 00000000..53f4a5ed --- /dev/null +++ b/apps/extension/src/app/features/onboarding/PasswordImport.tsx @@ -0,0 +1,47 @@ +import { useCallback, useEffect } from 'react' +import { useOnboardingSteps } from 'src/app/features/onboarding/OnboardingSteps' +import { Password } from 'src/app/features/onboarding/Password' +import { ExtensionOnboardingFlow } from 'uniswap/src/types/screens/extension' +import { logger } from 'utilities/src/logger/logger' +import { useOnboardingContext } from 'wallet/src/features/onboarding/OnboardingContext' +import { validateMnemonic } from 'wallet/src/utils/mnemonics' + +export function PasswordImport({ + flow, + allowBack = true, +}: { + flow: ExtensionOnboardingFlow + allowBack?: boolean +}): JSX.Element { + const { goToNextStep, goToPreviousStep } = useOnboardingSteps() + + const { getOnboardingAccountMnemonicString, generateInitialAddresses, importMnemonicToKeychain } = + useOnboardingContext() + const mnemonicString = getOnboardingAccountMnemonicString() + + // oxlint-disable-next-line react/exhaustive-deps -- Only run once on component mount to generate addresses + useEffect(() => { + generateInitialAddresses().catch((error) => { + logger.error(error, { + tags: { file: 'PasswordImport.tsx', function: 'generateInitialAddresses' }, + }) + }) + // oxlint-disable-next-line react/exhaustive-deps -- biome-parity: oxlint is stricter here + }, []) + + const onSubmit = useCallback( + async (password: string) => { + const { validMnemonic } = validateMnemonic(mnemonicString) + + if (!validMnemonic) { + throw new Error('Mnemonic are invalid on PasswordImport screen') + } + + goToNextStep() + await importMnemonicToKeychain({ mnemonic: validMnemonic, password, allowOverwrite: true }) + }, + [mnemonicString, goToNextStep, importMnemonicToKeychain], + ) + + return +} diff --git a/apps/extension/src/app/features/onboarding/PinReminder.tsx b/apps/extension/src/app/features/onboarding/PinReminder.tsx new file mode 100644 index 00000000..7cf0763f --- /dev/null +++ b/apps/extension/src/app/features/onboarding/PinReminder.tsx @@ -0,0 +1,88 @@ +import { useTranslation } from 'react-i18next' +import { Flex, Text } from 'ui/src' +import { Pin, X } from 'ui/src/components/icons' +import { zIndexes } from 'ui/src/theme' + +const POPUP_WIDTH = 240 +const POPUP_OFFSET = 4 +const POPUP_ARROW_SIZE = 12 +const POPUP_SHADOW_RADIUS = 8 + +export function PinReminder({ + onClose, + style = 'popup', +}: { + onClose?: () => void + style?: 'inline' | 'popup' +}): JSX.Element { + const { t } = useTranslation() + + return ( + <> + + + + + {t('onboarding.complete.pin.title')} + + + {t('onboarding.complete.pin.description')} + + + {onClose && ( + + + + )} + + + + ) +} + +function PinReminderArrow(): JSX.Element { + return ( + + ) +} + +const styles = { + inline: { + position: 'relative' as const, + width: '100%', + }, + popup: { + position: 'absolute' as const, + right: POPUP_OFFSET, + top: POPUP_OFFSET, + width: POPUP_WIDTH, + zIndex: zIndexes.popover, + }, +} diff --git a/apps/extension/src/app/features/onboarding/SyncFromPhoneButton.tsx b/apps/extension/src/app/features/onboarding/SyncFromPhoneButton.tsx new file mode 100644 index 00000000..a3bdb179 --- /dev/null +++ b/apps/extension/src/app/features/onboarding/SyncFromPhoneButton.tsx @@ -0,0 +1,32 @@ +import { useTranslation } from 'react-i18next' +import { OnboardingRoutes, TopLevelRoutes } from 'src/app/navigation/constants' +import { navigate } from 'src/app/navigation/state' +import { Flex, Text, TouchableArea } from 'ui/src' +import { ScanQr } from 'ui/src/components/icons' + +export function SyncFromPhoneButton({ + isResetting, + fill, +}: { + isResetting?: boolean + fill?: boolean +}): JSX.Element | null { + const { t } = useTranslation() + + return ( + + navigate(`/${TopLevelRoutes.Onboarding}/${isResetting ? OnboardingRoutes.ResetScan : OnboardingRoutes.Scan}`) + } + > + + + + {t('onboarding.intro.mobileScan.button')} + + + + ) +} diff --git a/apps/extension/src/app/features/onboarding/Terms.tsx b/apps/extension/src/app/features/onboarding/Terms.tsx new file mode 100644 index 00000000..9113fd5d --- /dev/null +++ b/apps/extension/src/app/features/onboarding/Terms.tsx @@ -0,0 +1,30 @@ +import { PropsWithChildren } from 'react' +import { Trans } from 'react-i18next' +import { Link, LinkProps } from 'react-router' +import { Text } from 'ui/src' +import { uniswapUrls } from 'uniswap/src/constants/urls' + +export function Terms(): JSX.Element { + return ( + + , + highlightPrivacy: , + }} + i18nKey="onboarding.termsOfService" + /> + + ) +} + +function LinkWrapper(props: PropsWithChildren): JSX.Element { + const { children, ...rest } = props + return ( + + + {children} + + + ) +} diff --git a/apps/extension/src/app/features/onboarding/__snapshots__/KeyboardKey.test.tsx.snap b/apps/extension/src/app/features/onboarding/__snapshots__/KeyboardKey.test.tsx.snap new file mode 100644 index 00000000..7539f9ca --- /dev/null +++ b/apps/extension/src/app/features/onboarding/__snapshots__/KeyboardKey.test.tsx.snap @@ -0,0 +1,70 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`KeyboardKey Component renders correctly with state Highlighted 1`] = ` +
+ +
+ + Shift + +
+
+ +
+`; + +exports[`KeyboardKey Component renders correctly with state KeyDown 1`] = ` +
+ +
+ + Shift + +
+
+ +
+`; + +exports[`KeyboardKey Component renders correctly with state KeyUp 1`] = ` +
+ +
+ + Shift + +
+
+ +
+`; diff --git a/apps/extension/src/app/features/onboarding/alerts/selectors.ts b/apps/extension/src/app/features/onboarding/alerts/selectors.ts new file mode 100644 index 00000000..c5ed7e33 --- /dev/null +++ b/apps/extension/src/app/features/onboarding/alerts/selectors.ts @@ -0,0 +1,6 @@ +import { AlertsState } from 'src/app/features/onboarding/alerts/slice' +import { ExtensionState } from 'src/store/extensionReducer' + +export function selectAlertsState(name: T): (state: ExtensionState) => AlertsState[T] { + return (state) => state.alerts[name] +} diff --git a/apps/extension/src/app/features/onboarding/alerts/slice.ts b/apps/extension/src/app/features/onboarding/alerts/slice.ts new file mode 100644 index 00000000..73936812 --- /dev/null +++ b/apps/extension/src/app/features/onboarding/alerts/slice.ts @@ -0,0 +1,31 @@ +import { createSlice, type PayloadAction } from '@reduxjs/toolkit' + +export enum AlertName { + PinToToolbar = 'PinToToolbar', +} + +export interface AlertsState { + [AlertName.PinToToolbar]: { + isOpen: boolean + } +} + +const initialState: AlertsState = { + [AlertName.PinToToolbar]: { + isOpen: true, + }, +} + +const slice = createSlice({ + name: 'alerts', + initialState, + reducers: { + closeAlert: (state, action: PayloadAction) => { + state[action.payload].isOpen = false + }, + resetAlerts: () => initialState, + }, +}) + +export const { closeAlert, resetAlerts } = slice.actions +export const { reducer: alertsReducer } = slice diff --git a/apps/extension/src/app/features/onboarding/create/PasswordCreate.tsx b/apps/extension/src/app/features/onboarding/create/PasswordCreate.tsx new file mode 100644 index 00000000..990718d9 --- /dev/null +++ b/apps/extension/src/app/features/onboarding/create/PasswordCreate.tsx @@ -0,0 +1,23 @@ +import { ONBOARDING_PANE_TRANSITION_DURATION_WITH_LEEWAY } from 'src/app/features/onboarding/OnboardingPaneAnimatedContents' +import { useOnboardingSteps } from 'src/app/features/onboarding/OnboardingSteps' +import { Password } from 'src/app/features/onboarding/Password' +import { ExtensionOnboardingFlow } from 'uniswap/src/types/screens/extension' +import { sleep } from 'utilities/src/time/timing' +import { useOnboardingContext } from 'wallet/src/features/onboarding/OnboardingContext' + +export function PasswordCreate(): JSX.Element { + const { goToNextStep, goToPreviousStep } = useOnboardingSteps() + const { generateOnboardingAccount, resetOnboardingContextData } = useOnboardingContext() + + const onComplete = async (password: string): Promise => { + resetOnboardingContextData() + goToNextStep() + // TODO: EXT-1164 - Move Keyring methods to workers to not block main thread during onboarding + // start running the validation after going to next step since they clog the main thread with work + // plus just a bit of extra leeway since animations can take just a tad extra to finish + await sleep(ONBOARDING_PANE_TRANSITION_DURATION_WITH_LEEWAY) + await generateOnboardingAccount(password) + } + + return +} diff --git a/apps/extension/src/app/features/onboarding/hooks/useFinishExtensionOnboarding.ts b/apps/extension/src/app/features/onboarding/hooks/useFinishExtensionOnboarding.ts new file mode 100644 index 00000000..d3442a4a --- /dev/null +++ b/apps/extension/src/app/features/onboarding/hooks/useFinishExtensionOnboarding.ts @@ -0,0 +1,64 @@ +import { useEffect, useRef } from 'react' +import { saveDappConnection } from 'src/app/features/dapp/actions' +import { UNISWAP_WEB_URL } from 'uniswap/src/constants/urls' +import { ImportType } from 'uniswap/src/types/onboarding' +import { ExtensionOnboardingFlow } from 'uniswap/src/types/screens/extension' +import { logger } from 'utilities/src/logger/logger' +import { useOnboardingContext } from 'wallet/src/features/onboarding/OnboardingContext' + +/** + * Activates onboarding accounts on component mount, + * and auto-connects to app.uniswap.org. + */ +export function useFinishExtensionOnboarding({ + callback, + extensionOnboardingFlow, + skip, +}: { + callback?: () => void + extensionOnboardingFlow?: ExtensionOnboardingFlow + skip?: boolean +}): void { + const { finishOnboarding, getOnboardingOrImportedAccount, getOnboardingAccountAddress } = useOnboardingContext() + const importType = getImportType(getOnboardingAccountAddress(), extensionOnboardingFlow) + + const isFinishedRef = useRef(false) + + useEffect(() => { + if (skip || isFinishedRef.current) { + return + } + + const runFinishOnboarding = async () => { + isFinishedRef.current = true + + await finishOnboarding({ importType, extensionOnboardingFlow }) + + const account = getOnboardingOrImportedAccount() + if (account) { + await saveDappConnection({ dappUrl: UNISWAP_WEB_URL, account }) + } + + callback?.() + } + + runFinishOnboarding().catch((e) => { + logger.error(e, { + tags: { file: 'useFinishExtensionOnboarding.ts', function: 'finishOnboarding' }, + }) + }) + }, [finishOnboarding, importType, callback, extensionOnboardingFlow, skip, getOnboardingOrImportedAccount]) +} + +function getImportType( + onboardingAccountAddress: string | undefined, + extensionOnboardingFlow: ExtensionOnboardingFlow | undefined, +): ImportType { + if (extensionOnboardingFlow === ExtensionOnboardingFlow.Passkey) { + return ImportType.Passkey + } + if (onboardingAccountAddress) { + return ImportType.CreateNew + } + return ImportType.RestoreMnemonic +} diff --git a/apps/extension/src/app/features/onboarding/hooks/useOpenSidebar.ts b/apps/extension/src/app/features/onboarding/hooks/useOpenSidebar.ts new file mode 100644 index 00000000..ec389095 --- /dev/null +++ b/apps/extension/src/app/features/onboarding/hooks/useOpenSidebar.ts @@ -0,0 +1,41 @@ +import { useEffect } from 'react' +import { getCurrentTabAndWindowId } from 'src/app/navigation/utils' +import { onboardingMessageChannel } from 'src/background/messagePassing/messageChannels' +import { OnboardingMessageType } from 'src/background/messagePassing/types/ExtensionMessages' +import { openSidePanel } from 'src/background/utils/chromeSidePanelUtils' +import { uniswapUrls } from 'uniswap/src/constants/urls' +import { logger } from 'utilities/src/logger/logger' +import { useBooleanState } from 'utilities/src/react/useBooleanState' + +export function useOpenSidebar() { + const { value: openedSideBar, setTrue: openSideBar } = useBooleanState(false) + + useEffect(() => { + const onSidebarOpenedListener = onboardingMessageChannel.addMessageListener( + OnboardingMessageType.SidebarOpened, + () => { + openSideBar() + }, + ) + return () => { + onboardingMessageChannel.removeMessageListener(OnboardingMessageType.SidebarOpened, onSidebarOpenedListener) + } + }, [openSideBar]) + + const handleOpenSidebar = async (): Promise => { + try { + const { tabId, windowId } = await getCurrentTabAndWindowId() + await openSidePanel(tabId, windowId) + } catch (error) { + logger.error(error, { + tags: { file: 'useOpenSidebar.ts', function: 'handleOpenSidebar' }, + }) + } + } + + const handleOpenWebApp = async (): Promise => { + window.location.href = uniswapUrls.webInterfaceSwapUrl + } + + return { openedSideBar, handleOpenSidebar, handleOpenWebApp } +} diff --git a/apps/extension/src/app/features/onboarding/import/ImportMnemonic.tsx b/apps/extension/src/app/features/onboarding/import/ImportMnemonic.tsx new file mode 100644 index 00000000..d6ac2d3f --- /dev/null +++ b/apps/extension/src/app/features/onboarding/import/ImportMnemonic.tsx @@ -0,0 +1,347 @@ +import { wordlists } from '@ethersproject/wordlists' +import { forwardRef, useCallback, useEffect, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { + type NativeSyntheticEvent, + type TextInputChangeEventData, + type TextInputFocusEventData, + type TextInputKeyPressEventData, +} from 'react-native' +import { useDispatch } from 'react-redux' +import { OnboardingScreen } from 'src/app/features/onboarding/OnboardingScreen' +import { useOnboardingSteps } from 'src/app/features/onboarding/OnboardingSteps' +import { SyncFromPhoneButton } from 'src/app/features/onboarding/SyncFromPhoneButton' +import { TopLevelRoutes } from 'src/app/navigation/constants' +import { navigate } from 'src/app/navigation/state' +import { Button, Flex, Input, inputStyles, Square, Text } from 'ui/src' +import { FileListLock, RotatableChevron } from 'ui/src/components/icons' +import { fonts, iconSizes } from 'ui/src/theme' +import Trace from 'uniswap/src/features/telemetry/Trace' +import { ExtensionOnboardingFlow, ExtensionOnboardingScreens } from 'uniswap/src/types/screens/extension' +import { useDebounce } from 'utilities/src/time/timing' +import { useOnboardingContext } from 'wallet/src/features/onboarding/OnboardingContext' +import { EditAccountAction, editAccountActions } from 'wallet/src/features/wallet/accounts/editAccountSaga' +import { useSignerAccounts } from 'wallet/src/features/wallet/hooks' +import { isValidMnemonicWord, validateMnemonic } from 'wallet/src/utils/mnemonics' + +const inputRefs: Array = Array(24).fill(null) + +export function ImportMnemonic(): JSX.Element { + const { t } = useTranslation() + const dispatch = useDispatch() + const [mnemonic, setMnemonic] = useState(new Array(24).fill('')) + const { addOnboardingAccountMnemonic } = useOnboardingContext() + const [expanded, setExpanded] = useState(false) + const [errors, setErrors] = useState>({}) + const isEmptyMnemonic = useMemo(() => !mnemonic.join(' ').toLocaleLowerCase().trim(), [mnemonic]) + + const accounts = useSignerAccounts() + + const { isResetting, goToNextStep } = useOnboardingSteps() + + useEffect(() => { + const handlePaste = (event: ClipboardEvent): void | (() => void) => { + if (!event.clipboardData) { + return + } + const pastedText = event.clipboardData.getData('text').toLowerCase().trim() + if (!pastedText) { + return + } + const { validMnemonic, error } = validateMnemonic(pastedText) + if (error || !validMnemonic) { + return + } + // We conditionally prevent default here because we want paste to work as expected in all other cases. + event.preventDefault() + const words = validMnemonic.replaceAll(/\s+/g, ' ').split(' ') + setExpanded(words.length > 12) + + const newMnemonic = Array(24) + .fill('') + .map((_, i) => words[i] || '') + + setMnemonic(newMnemonic) + setErrors({}) + + // We focus the last input on the next tick after the state has been updated. + setTimeout(() => inputRefs[words.length - 1]?.focus(), 0) + + // Clear clipboard after paste + navigator.clipboard.writeText('').catch(() => {}) + } + + window.document.addEventListener('paste', handlePaste) + + return () => { + window.document.removeEventListener('paste', handlePaste) + } + }, []) + + const handleChange = useCallback( + (index: number) => + (event: NativeSyntheticEvent): void => { + const newMnemonic = [...mnemonic] + const word = event.nativeEvent.text + + // Focus next input when the space key is pressed. + if (word.length > 1 && word.endsWith(' ')) { + inputRefs[index + 1]?.focus() + } + + newMnemonic[index] = word.trim() + setMnemonic(newMnemonic) + }, + [mnemonic], + ) + + const handleKeyPress = useCallback( + (index: number) => + (event: NativeSyntheticEvent): void => { + // Focus previous input when the backspace key is pressed. + if (event.nativeEvent.key === 'Backspace' && !mnemonic[index] && index > 0) { + inputRefs[index - 1]?.focus() + } + }, + [mnemonic], + ) + + const handleBlur = useCallback( + (index: number) => + (event: NativeSyntheticEvent): void => { + const word = event.nativeEvent.text + + if (!word && errors[index] !== undefined) { + setErrors({ ...errors, [index]: undefined }) + } + if (!word) { + return + } + const wordInList = wordlists['en']?.getWordIndex(word) !== -1 + setErrors({ ...errors, [index]: !wordInList }) + }, + [errors], + ) + + const { error: mnemonicValidationError, invalidWordCount } = useMemo(() => { + const mnemonicString = mnemonic.join(' ').toLowerCase() + + if (!mnemonicString.trim()) { + return { error: undefined, invalidWordCount: undefined } + } + + return validateMnemonic(mnemonicString) + }, [mnemonic]) + + const errorMessageToDisplay = useMemo(() => { + // If all cells are filled, but there is an error, display the invalid phrase error + const trimmedMnemonic = expanded ? mnemonic : mnemonic.slice(0, 12) + const allCellsFilled = trimmedMnemonic.every((word) => word.length > 0) + + if (allCellsFilled && mnemonicValidationError) { + return t('onboarding.importMnemonic.error.invalidPhrase') + } + + if (mnemonicValidationError && invalidWordCount && invalidWordCount >= 1) { + return t('onboarding.import.error.invalidWords', { count: invalidWordCount }) + } + + return undefined + }, [expanded, mnemonic, mnemonicValidationError, t, invalidWordCount]) + + const debouncedErrorMessageToDisplay = useDebounce(errorMessageToDisplay, 500) + + const enableSubmit = !isEmptyMnemonic && !mnemonicValidationError && !errorMessageToDisplay + + const onSubmit = useCallback(async () => { + if (!enableSubmit) { + return + } + + if (isResetting) { + // Remove all accounts before importing mnemonic. + await dispatch( + editAccountActions.trigger({ + type: EditAccountAction.Remove, + accounts, + }), + ) + } + + addOnboardingAccountMnemonic(mnemonic) + goToNextStep() + }, [accounts, dispatch, goToNextStep, isResetting, mnemonic, addOnboardingAccountMnemonic, enableSubmit]) + + return ( + + + + + + } + belowFrameContent={ + isResetting ? ( + + + + ) : undefined + } + nextButtonEnabled={!isEmptyMnemonic && !mnemonicValidationError && !errorMessageToDisplay} + nextButtonText={t('common.button.continue')} + subtitle={t('onboarding.importMnemonic.subtitle')} + title={t('onboarding.importMnemonic.title')} + onBack={isResetting ? undefined : (): void => navigate(`/${TopLevelRoutes.Onboarding}`, { replace: true })} + onSubmit={onSubmit} + > + <> + + {debouncedErrorMessageToDisplay ?? DUMMY_TEXT} {/* To prevent layout shift */} + + + {mnemonic.map( + (word, index) => + Boolean(expanded || index < 12) && ( + + { + inputRefs[index] = ref + }} + handleBlur={handleBlur} + handleChange={handleChange} + handleKeyPress={handleKeyPress} + index={index} + word={word} + onSubmitEditing={onSubmit} + /> + + ), + )} + + + + + + + + + ) +} + +const RecoveryPhraseWord = forwardRef< + Input, + { + word: string + index: number + handleBlur: (index: number) => (event: NativeSyntheticEvent) => void + handleChange: (index: number) => (event: NativeSyntheticEvent) => void + handleKeyPress: (index: number) => (e: NativeSyntheticEvent) => void + onSubmitEditing: () => void + } +>(function RecoveryPhraseWordInner( + { word, index, handleBlur, handleChange, handleKeyPress, onSubmitEditing }, + ref, +): JSX.Element { + const debouncedWord = useDebounce(word, 500) + const showError = debouncedWord.length > 0 && !isValidMnemonicWord(debouncedWord) + + return ( + + + {(index + 1).toString()} + + + + + ) +}) + +const styles = { + inputFocus: { + backgroundColor: '$surface1', + borderWidth: 1, + borderColor: '$surface3', + outlineWidth: 0, + }, + recoveryPhraseWord: { + width: 'calc(calc(100% - 32px) / 3)', // 3 columns with 16px gap + }, +} as const + +const DUMMY_TEXT = 'DUMMY TEXT' diff --git a/apps/extension/src/app/features/onboarding/import/InitiatePasskeyAuth.tsx b/apps/extension/src/app/features/onboarding/import/InitiatePasskeyAuth.tsx new file mode 100644 index 00000000..e3d80619 --- /dev/null +++ b/apps/extension/src/app/features/onboarding/import/InitiatePasskeyAuth.tsx @@ -0,0 +1,297 @@ +import { useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Navigate, useLocation } from 'react-router' +import { usePasskeyImportContext } from 'src/app/features/onboarding/import/PasskeyImportContextProvider' +import { + InitiatePasskeyAuthLocationState, + SelectImportMethodLocationState, +} from 'src/app/features/onboarding/import/types' +import { useOnboardingSteps } from 'src/app/features/onboarding/OnboardingStepsContext' +import { OnboardingRoutes, TopLevelRoutes } from 'src/app/navigation/constants' +import { navigate } from 'src/app/navigation/state' +import { bringWindowToFront, closeWindow, openPopupWindow } from 'src/app/navigation/utils' +import { Button, Flex, IconButton, SpinningLoader, Text } from 'ui/src' +import { X } from 'ui/src/components/icons' +import { UniswapLogo } from 'ui/src/components/icons/UniswapLogo' +import { EmbeddedWalletApiClient } from 'uniswap/src/data/rest/embeddedWallet/requests' +import { parseMessage } from 'uniswap/src/extension/messagePassing/platform' +import { + ExtensionToInterfaceRequestType, + PasskeyCredentialRetrievedSchema, + PasskeyRequest, + PasskeySignInFlowOpenedSchema, +} from 'uniswap/src/extension/messagePassing/types/requests' +import { EXTENSION_PASSKEY_AUTH_PATH } from 'uniswap/src/features/passkey/constants' +import { getPrivyEnums } from 'uniswap/src/features/passkey/embeddedWallet' +import { useEmbeddedWalletBaseUrl } from 'uniswap/src/features/passkey/hooks/useEmbeddedWalletBaseUrl' +import Trace from 'uniswap/src/features/telemetry/Trace' +import { ExtensionOnboardingFlow, ExtensionOnboardingScreens } from 'uniswap/src/types/screens/extension' +import { logger } from 'utilities/src/logger/logger' +import { useEvent } from 'utilities/src/react/hooks' +import { useInterval } from 'utilities/src/time/timing' +import { v4 as uuid } from 'uuid' + +/************************************************************************************************************** + * + * PASSKEY AUTH FLOW: EXTENSION <> WEB APP + * + * +-------------+ +---------------+ +------------+ + * | Extension | | Web App | | User | + * +-------------+ +---------------+ +------------+ + * | | | + * |-- Opens popup via chrome.windows.create ----------->| | + * | /auth/passkey/extension?request_id=XXX | | + * | | | + * |<-- "Did the extension actually open this window?" --| | + * | chrome.runtime.sendMessage(EXTENSION_ID, | | + * | { type: 'PasskeySignInFlowOpened', requestId }) | | + * | | | + * |-- Ignores or responds with ------------------------>| | + * | sendResponse({ type: 'PasskeyRequest', | | + * | requestId, challengeJson }) | | + * | | | + * | |-- Receives `PasskeyRequest` message ------------>| + * | | and enables "Sign In" button | + * | | | + * | |<-- Clicks "Sign In" -----------------------------| + * | | and authenticates with their passkey | + * | | | + * |<----------------- Sends passkey credentials --------| | + * | chrome.runtime.sendMessage(EXTENSION_ID, | | + * | { type: 'PasskeyCredentialRetrieved', | | + * | requestId, credential }) | | + * | | | + * + * NOTES: + * + * For the Web App code, check `apps/web/src/pages/ExtensionPasskeyAuth/index.tsx`. + * + * We're not reusing all of the message passing utils that we use to communicate between the different + * parts of the Extension (Sidebar, Background, etc.) because there are some additional constraints around + * how a web app can communicate with an Extension. + * + * In order to test this flow, the web app URL must be declared in the `externally_connectable` attribute + * of the Extension's `manifest.json`. + * + **************************************************************************************************************/ + +const POPUP_WIDTH = 420 +const POPUP_HEIGHT = 335 + +export function InitiatePasskeyAuth(): JSX.Element { + const locationState = useLocation().state as InitiatePasskeyAuthLocationState | undefined + + if (!locationState?.importPasskey) { + // This prevents someone else linking directly to this page from a 3rd party website. + return + } + + return +} + +function InitiatePasskeyAuthContent(): JSX.Element { + const { t } = useTranslation() + + const webAppBaseUrl = useEmbeddedWalletBaseUrl() + + const { goToNextStep } = useOnboardingSteps() + const { importWithCredential } = usePasskeyImportContext() + + const initiated = useRef(false) + + const handleError = (error: unknown, sourceFunction: string): void => { + logger.error(error, { + tags: { + file: 'InitiatePasskeyAuth.tsx', + function: sourceFunction, + }, + }) + navigate(`/${TopLevelRoutes.Onboarding}/${OnboardingRoutes.SelectImportMethod}`, { + replace: true, + state: { showErrorMessage: true } satisfies SelectImportMethodLocationState, + }) + } + + const popupWindow = useRef(undefined) + + // oxlint-disable-next-line react/exhaustive-deps -- Only run once on mount to initiate auth flow, all handlers are created fresh each render + useEffect(() => { + let handleMessagePasskeySignInFlowOpened: Parameters[0] + let handleMessagePasskeyCredentialRetrieved: Parameters[0] + + const initiatePasskeyAuth = async (): Promise => { + if (initiated.current) { + return + } + + initiated.current = true + + try { + const requestId = uuid() + + const { Action, AuthenticationTypes } = await getPrivyEnums() + const challengeResponse = await EmbeddedWalletApiClient.fetchChallengeRequest({ + type: AuthenticationTypes.PASSKEY_AUTHENTICATION, + action: Action.EXPORT_SEED_PHRASE, + }) + + handleMessagePasskeyCredentialRetrieved = async (message: unknown) => { + const parsedMessage = parseMessage(message, PasskeyCredentialRetrievedSchema) + + if (!parsedMessage) { + return + } + + if (parsedMessage.requestId !== requestId) { + logger.debug( + 'InitiatePasskeyAuth.tsx', + 'handleMessagePasskeyCredentialRetrieved', + 'Mismatched request ID', + { requestId, message }, + ) + return + } + + closeWindow(popupWindow.current) + importWithCredential(parsedMessage.credential) + goToNextStep() + } + + chrome.runtime.onMessageExternal.addListener(handleMessagePasskeyCredentialRetrieved) + +>>>>>>> upstream/main + ) => { + try { + logger.debug('InitiatePasskeyAuth.tsx', 'handleMessagePasskeySignInFlowOpened', 'Message received', { + message, + }) + + const parsedMessage = parseMessage(message, PasskeySignInFlowOpenedSchema) + + if (!parsedMessage) { + return + } + + if (parsedMessage.requestId !== requestId) { + logger.debug('InitiatePasskeyAuth.tsx', 'handleMessagePasskeySignInFlowOpened', 'Mismatched request ID', { + requestId, + message, + }) + return + } + + logger.debug( + 'InitiatePasskeyAuth.tsx', + 'handleMessagePasskeySignInFlowOpened', + `Sending message: ${ExtensionToInterfaceRequestType.PasskeyRequest}`, + ) + + sendResponse({ + type: ExtensionToInterfaceRequestType.PasskeyRequest, + requestId, + challengeJson: challengeResponse.challengeOptions, + } satisfies PasskeyRequest) + } catch (e) { + handleError(e, 'handleMessagePasskeySignInFlowOpened') + } + } + + chrome.runtime.onMessageExternal.addListener(handleMessagePasskeySignInFlowOpened) + + popupWindow.current = await openPopupWindow({ + url: `${webAppBaseUrl}${EXTENSION_PASSKEY_AUTH_PATH}?request_id=${requestId}`, + width: POPUP_WIDTH, + height: POPUP_HEIGHT, + }) + } catch (e) { + handleError(e, 'initiatePasskeyAuth') + } + } + + initiatePasskeyAuth() + + return () => { + closeWindow(popupWindow.current) + chrome.runtime.onMessageExternal.removeListener(handleMessagePasskeySignInFlowOpened) + chrome.runtime.onMessageExternal.removeListener(handleMessagePasskeyCredentialRetrieved) + } +<<<<<<< HEAD +======= + // oxlint-disable-next-line react/exhaustive-deps -- biome-parity: oxlint is stricter here + }, []) + + const [showBringWindowToFrontButton, setShowBringWindowToFrontButton] = useState(false) + + // Checks if the popup window is still open. + // If it is not, then the user has closed the window and we simply navigate back to the select import method screen. + useInterval(async () => { + const windowId = popupWindow.current?.id ?? null + + if (windowId === null) { + return + } + + try { + // Will throw if window does not exist anymore. + await chrome.windows.get(windowId) + setShowBringWindowToFrontButton(true) + } catch { + // Window does not exist anymore. + navigate(`/${TopLevelRoutes.Onboarding}/${OnboardingRoutes.SelectImportMethod}`, { + replace: true, + }) + } + }, 1000) + + const onBringWindowToFront = useEvent(async () => { + const windowId = popupWindow.current?.id ?? null + + if (windowId === null) { + return + } + + try { + await bringWindowToFront(windowId, { centered: true }) + } catch (e) { + logger.error(e, { + tags: { + file: 'InitiatePasskeyAuth.tsx', + function: 'onBringWindowToFront', + }, + }) + } + }) + + return ( + + + } + onPress={() => navigate(`/${TopLevelRoutes.Onboarding}/${OnboardingRoutes.SelectImportMethod}`)} + /> + + + + + + {t('onboarding.importPasskey.continueInSecureWindow')} + + + {showBringWindowToFrontButton ? ( + + ) : ( + + )} + + + + ) +} diff --git a/apps/extension/src/app/features/onboarding/import/PasskeyImport.tsx b/apps/extension/src/app/features/onboarding/import/PasskeyImport.tsx new file mode 100644 index 00000000..81f4ce3c --- /dev/null +++ b/apps/extension/src/app/features/onboarding/import/PasskeyImport.tsx @@ -0,0 +1,46 @@ +import { useEffect } from 'react' +import { usePasskeyImportContext } from 'src/app/features/onboarding/import/PasskeyImportContextProvider' +import { SelectImportMethodLocationState } from 'src/app/features/onboarding/import/types' +import { OnboardingScreen } from 'src/app/features/onboarding/OnboardingScreen' +import { useOnboardingSteps } from 'src/app/features/onboarding/OnboardingStepsContext' +import { OnboardingRoutes, TopLevelRoutes } from 'src/app/navigation/constants' +import { navigate } from 'src/app/navigation/state' +import { Flex } from 'ui/src' +import Trace from 'uniswap/src/features/telemetry/Trace' +import { ExtensionOnboardingFlow, ExtensionOnboardingScreens } from 'uniswap/src/types/screens/extension' +import { PasskeyImportLoading } from 'wallet/src/features/onboarding/PasskeyImportLoading' +import { WelcomeSplash } from 'wallet/src/features/onboarding/WelcomeSplash' + +const SCREEN_HEIGHT = 281 + +export function PasskeyImport(): JSX.Element { + const { importedAddress, importError } = usePasskeyImportContext() + const { goToNextStep } = useOnboardingSteps() + + useEffect(() => { + if (importError) { + navigate(`/${TopLevelRoutes.Onboarding}/${OnboardingRoutes.SelectImportMethod}`, { + replace: true, + state: { showErrorMessage: true } satisfies SelectImportMethodLocationState, + }) + } + }, [importError]) + + return ( + + + + {importedAddress ? ( + + ) : ( + + )} + + + + ) +} diff --git a/apps/extension/src/app/features/onboarding/import/PasskeyImportContextProvider.tsx b/apps/extension/src/app/features/onboarding/import/PasskeyImportContextProvider.tsx new file mode 100644 index 00000000..22aada7f --- /dev/null +++ b/apps/extension/src/app/features/onboarding/import/PasskeyImportContextProvider.tsx @@ -0,0 +1,62 @@ +import { createContext, PropsWithChildren, useCallback, useContext, useState } from 'react' +import { logger } from 'utilities/src/logger/logger' +import { useOnboardingContext } from 'wallet/src/features/onboarding/OnboardingContext' +import { fetchSeedPhrase } from 'wallet/src/features/passkeys/passkeys' +import { Keyring } from 'wallet/src/features/wallet/Keyring/Keyring' + +type PasskeyImportContextState = { + importedAddress: Address | null + importError: Error | null + importWithCredential: (credential: string) => Promise +} + +const PasskeyImportContext = createContext(undefined) + +export function PasskeyImportContextProvider({ children }: PropsWithChildren): JSX.Element { + const [importedAddress, setImportedAddress] = useState
(null) + const [importError, setImportError] = useState(null) + + const { addOnboardingAccountMnemonic } = useOnboardingContext() + + const importWithCredential = useCallback( + async (credential: string): Promise => { + try { + const mnemonic = await fetchSeedPhrase(credential) + const account = await Keyring.importMnemonic(mnemonic) + addOnboardingAccountMnemonic(mnemonic.split(' ')) + setImportedAddress(account) + } catch (caughtError) { + logger.error(caughtError, { + tags: { + file: 'PasskeyImportContextProvider.tsx', + function: 'importWithCredential', + }, + }) + const error = + caughtError instanceof Error ? caughtError : new Error('Failed to import passkey', { cause: caughtError }) + setImportError(error) + } + }, + [addOnboardingAccountMnemonic], + ) + + return ( + + {children} + + ) +} + +export const usePasskeyImportContext = (): PasskeyImportContextState => { + const passkeyImportContext = useContext(PasskeyImportContext) + if (passkeyImportContext === undefined) { + throw new Error('usePasskeyImportContext must be inside a PasskeyImportContextProvider') + } + return passkeyImportContext +} diff --git a/apps/extension/src/app/features/onboarding/import/SelectImportMethod.tsx b/apps/extension/src/app/features/onboarding/import/SelectImportMethod.tsx new file mode 100644 index 00000000..987e483e --- /dev/null +++ b/apps/extension/src/app/features/onboarding/import/SelectImportMethod.tsx @@ -0,0 +1,86 @@ +import { useTranslation } from 'react-i18next' +import { useLocation } from 'react-router' +import { OptionCard } from 'src/app/components/buttons/OptionCard' +import { + InitiatePasskeyAuthLocationState, + SelectImportMethodLocationState, +} from 'src/app/features/onboarding/import/types' +import { OnboardingScreen } from 'src/app/features/onboarding/OnboardingScreen' +import { OnboardingRoutes, TopLevelRoutes } from 'src/app/navigation/constants' +import { navigate } from 'src/app/navigation/state' +import { Flex, Square, Text } from 'ui/src' +import { PapersText, Passkey, QrCode, WalletFilled } from 'ui/src/components/icons' +import { iconSizes } from 'ui/src/theme' +import Trace from 'uniswap/src/features/telemetry/Trace' +import { ExtensionOnboardingFlow, ExtensionOnboardingScreens } from 'uniswap/src/types/screens/extension' + +export function SelectImportMethod(): JSX.Element { + const { t } = useTranslation() + const locationState = useLocation().state as SelectImportMethodLocationState | undefined + + const showErrorMessage = locationState?.showErrorMessage + + return ( + + + + + + } + title={t('onboarding.import.selectMethod.title')} + onBack={(): void => navigate(`/${TopLevelRoutes.Onboarding}`, { replace: true })} + > + + {showErrorMessage && ( + + + {t('onboarding.import.selectMethod.errorMessage')} + + + )} + + navigate(`/${TopLevelRoutes.Onboarding}/${OnboardingRoutes.Import}`, { replace: true }) + } + /> + + + navigate(`/${TopLevelRoutes.Onboarding}/${OnboardingRoutes.Scan}`, { replace: true }) + } + /> + + + navigate(`/${TopLevelRoutes.Onboarding}/${OnboardingRoutes.ImportPasskey}`, { + replace: true, + state: { importPasskey: true } satisfies InitiatePasskeyAuthLocationState, + }) + } + /> + + + + + ) +} diff --git a/apps/extension/src/app/features/onboarding/import/SelectWallets.tsx b/apps/extension/src/app/features/onboarding/import/SelectWallets.tsx new file mode 100644 index 00000000..3f12f876 --- /dev/null +++ b/apps/extension/src/app/features/onboarding/import/SelectWallets.tsx @@ -0,0 +1,160 @@ +import { useQuery } from '@tanstack/react-query' +import { FeatureFlags, useFeatureFlag } from '@universe/gating' +import { ComponentProps, useMemo, useState } from 'react' +import { Trans, useTranslation } from 'react-i18next' +import { SelectWalletsSkeleton } from 'src/app/components/loading/SelectWalletSkeleton' +import { OnboardingScreen } from 'src/app/features/onboarding/OnboardingScreen' +import { useOnboardingSteps } from 'src/app/features/onboarding/OnboardingSteps' +import { useSubmitOnEnter } from 'src/app/features/onboarding/utils' +import { Flex, ScrollView, SpinningLoader, Square, Text, Tooltip, TouchableArea } from 'ui/src' +import { WalletFilled } from 'ui/src/components/icons' +import { iconSizes, zIndexes } from 'ui/src/theme' +import { uniswapUrls } from 'uniswap/src/constants/urls' +import Trace from 'uniswap/src/features/telemetry/Trace' +import { ExtensionOnboardingFlow, ExtensionOnboardingScreens } from 'uniswap/src/types/screens/extension' +import { openUri } from 'uniswap/src/utils/linking' +import { useEvent } from 'utilities/src/react/hooks' +import { ReactQueryCacheKey } from 'utilities/src/reactQuery/cache' +import { queryWithoutCache } from 'utilities/src/reactQuery/queryOptions' +import WalletPreviewCard from 'wallet/src/components/WalletPreviewCard/WalletPreviewCard' +import { useImportableAccounts } from 'wallet/src/features/onboarding/hooks/useImportableAccounts' +import { useSelectAccounts } from 'wallet/src/features/onboarding/hooks/useSelectAccounts' +import { useOnboardingContext } from 'wallet/src/features/onboarding/OnboardingContext' +import { BackupType } from 'wallet/src/features/wallet/accounts/types' + +export function SelectWallets({ flow }: { flow: ExtensionOnboardingFlow }): JSX.Element { + const { t } = useTranslation() + const [buttonClicked, setButtonClicked] = useState(false) + + const { goToNextStep, goToPreviousStep } = useOnboardingSteps() + const { generateAccountsAndImportAddresses, getGeneratedAddresses } = useOnboardingContext() + + const { data: generatedAddresses } = useQuery( + queryWithoutCache({ queryFn: getGeneratedAddresses, queryKey: [ReactQueryCacheKey.GeneratedAddresses] }), + ) + + const { importableAccounts, isLoading, showError, refetch } = useImportableAccounts(generatedAddresses) + + const { selectedAddresses, toggleAddressSelection } = useSelectAccounts(importableAccounts) + + const smartWalletEnabled = useFeatureFlag(FeatureFlags.SmartWallet) + + const enableSubmit = showError || (selectedAddresses.length > 0 && !isLoading) + + const onSubmit = useEvent(async () => { + if (!enableSubmit) { + return + } + + setButtonClicked(true) + await generateAccountsAndImportAddresses({ + selectedAddresses, + backupType: flow === ExtensionOnboardingFlow.Passkey ? BackupType.Passkey : BackupType.Manual, + }) + + goToNextStep() + setButtonClicked(false) + }) + + const title = showError ? t('onboarding.selectWallets.title.error') : t('onboarding.selectWallets.title.default') + + useSubmitOnEnter(showError ? refetch : onSubmit) + + const belowFrameContent = useMemo( + () => (smartWalletEnabled ? : undefined), + [smartWalletEnabled], + ) + + return ( + + + + + } + nextButtonEnabled={enableSubmit} + nextButtonIcon={buttonClicked ? : undefined} + nextButtonText={ + showError + ? t('common.button.retry') + : buttonClicked + ? t('onboarding.importMnemonic.button.importing') + : t('common.button.continue') + } + nextButtonVariant={showError ? 'default' : 'branded'} + nextButtonEmphasis={showError || buttonClicked ? 'secondary' : 'primary'} + title={title} + onBack={goToPreviousStep} + onSubmit={showError ? refetch : onSubmit} + > + + + {showError ? ( + + {t('onboarding.selectWallets.error')} + + ) : isLoading ? ( + + + + ) : ( + importableAccounts?.map((account) => { + const { address, balance } = account + return ( + + ) + }) + )} + + + + + ) +} + +const onPressLearnMore = (uri: string): Promise => openUri({ uri }) + +function SmartWalletTooltip(): JSX.Element | undefined { + const { t } = useTranslation() + const triggerComponent = + return ( + + + + + + + + + + + {`${t('smartWallet.modal.description.block1')} ${t('smartWallet.modal.description.block2')}`} + + onPressLearnMore(uniswapUrls.helpArticleUrls.smartWalletDelegation)}> + + {t('common.button.learn')} + + + + + + ) +} + +function CustomTrigger(props: ComponentProps): JSX.Element { + return ( + + + + + + ) +} diff --git a/apps/extension/src/app/features/onboarding/import/types.ts b/apps/extension/src/app/features/onboarding/import/types.ts new file mode 100644 index 00000000..6b5ed37f --- /dev/null +++ b/apps/extension/src/app/features/onboarding/import/types.ts @@ -0,0 +1,8 @@ +export interface SelectImportMethodLocationState { + showErrorMessage?: boolean +} + +export interface InitiatePasskeyAuthLocationState { + // This prevents someone else linking directly to this page from a 3rd party website + importPasskey: true +} diff --git a/apps/extension/src/app/features/onboarding/intro/IntroScreen.tsx b/apps/extension/src/app/features/onboarding/intro/IntroScreen.tsx new file mode 100644 index 00000000..464118dc --- /dev/null +++ b/apps/extension/src/app/features/onboarding/intro/IntroScreen.tsx @@ -0,0 +1,87 @@ +import { useTranslation } from 'react-i18next' +import { useSelector } from 'react-redux' +import { Complete } from 'src/app/features/onboarding/Complete' +import { MainIntroWrapper } from 'src/app/features/onboarding/intro/MainIntroWrapper' +import { SyncFromPhoneButton } from 'src/app/features/onboarding/SyncFromPhoneButton' +import { Terms } from 'src/app/features/onboarding/Terms' +import { useIsExtensionPasskeyImportEnabled } from 'src/app/hooks/useIsExtensionPasskeyImportEnabled' +import { OnboardingRoutes, TopLevelRoutes } from 'src/app/navigation/constants' +import { navigate } from 'src/app/navigation/state' +import { checksIfSupportsSidePanel } from 'src/app/utils/chrome' +import { isOnboardedSelector } from 'src/app/utils/isOnboardedSelector' +import { Button, Flex, Text } from 'ui/src' +import Trace from 'uniswap/src/features/telemetry/Trace' +import { ExtensionOnboardingScreens } from 'uniswap/src/types/screens/extension' +import { useTimeout } from 'utilities/src/time/timing' + +export function IntroScreen(): JSX.Element { + const { t } = useTranslation() + const isPasskeyImportEnabled = useIsExtensionPasskeyImportEnabled() + + const isOnboarded = useSelector(isOnboardedSelector) + // Detections for some unsupported browsers may not work until stylesheet is loaded + useTimeout(() => { + if (!checksIfSupportsSidePanel()) { + navigate(`/${TopLevelRoutes.Onboarding}/${OnboardingRoutes.UnsupportedBrowser}`) + } + }, 0) + + if (isOnboarded) { + return + } + + return ( + + + + + + } + > + + + + + + + + + + {isPasskeyImportEnabled ? null : ( + <> + + + + {t('onboarding.intro.mobileScan.title')} + + + + + + + )} + + + + ) +} diff --git a/apps/extension/src/app/features/onboarding/intro/MainContentWrapper.tsx b/apps/extension/src/app/features/onboarding/intro/MainContentWrapper.tsx new file mode 100644 index 00000000..0601066c --- /dev/null +++ b/apps/extension/src/app/features/onboarding/intro/MainContentWrapper.tsx @@ -0,0 +1,23 @@ +import { PropsWithChildren } from 'react' +import { ONBOARDING_CONTENT_WIDTH } from 'src/app/features/onboarding/utils' +import { Flex } from 'ui/src' + +export function MainContentWrapper({ children }: PropsWithChildren): JSX.Element { + return ( + + {children} + + ) +} diff --git a/apps/extension/src/app/features/onboarding/intro/MainIntroWrapper.tsx b/apps/extension/src/app/features/onboarding/intro/MainIntroWrapper.tsx new file mode 100644 index 00000000..35a644a9 --- /dev/null +++ b/apps/extension/src/app/features/onboarding/intro/MainIntroWrapper.tsx @@ -0,0 +1,42 @@ +import { PropsWithChildren, ReactNode } from 'react' +import { ONBOARDING_CONTENT_WIDTH } from 'src/app/features/onboarding/utils' +import { Flex } from 'ui/src' +import { LandingBackground } from 'wallet/src/components/landing/LandingBackground' + +// Fixed padding value to align content with a certain point on the background +const CONTAINER_PADDING_TOP = 340 +const LANDING_BACKGROUND_SIZE = 400 + +export function MainIntroWrapper({ + children, + belowFrameContent, +}: PropsWithChildren<{ belowFrameContent?: ReactNode }>): JSX.Element { + return ( + + + + + + + + + {children} + + + {belowFrameContent && ( + + {belowFrameContent} + + )} + + ) +} diff --git a/apps/extension/src/app/features/onboarding/intro/UnsupportedBrowserScreen.tsx b/apps/extension/src/app/features/onboarding/intro/UnsupportedBrowserScreen.tsx new file mode 100644 index 00000000..8a7bd3e8 --- /dev/null +++ b/apps/extension/src/app/features/onboarding/intro/UnsupportedBrowserScreen.tsx @@ -0,0 +1,50 @@ +import { useTranslation } from 'react-i18next' +import { MainIntroWrapper } from 'src/app/features/onboarding/intro/MainIntroWrapper' +import { isAndroid } from 'src/app/utils/chrome' +import { Flex, Text } from 'ui/src' +import { AlertTriangleFilled } from 'ui/src/components/icons' +import Trace from 'uniswap/src/features/telemetry/Trace' +import { ExtensionScreens } from 'uniswap/src/types/screens/extension' + +export function UnsupportedBrowserScreen(): JSX.Element { + const { t } = useTranslation() + + const title = isAndroid() + ? t('onboarding.extension.unsupported.android.title') + : t('onboarding.extension.unsupported.title') + + const description = isAndroid() + ? t('onboarding.extension.unsupported.android.description') + : t('onboarding.extension.unsupported.description') + + return ( + + + + + + + + + + + {title} + + + {description} + + + + + + + + ) +} diff --git a/apps/extension/src/app/features/onboarding/reset/ResetComplete.tsx b/apps/extension/src/app/features/onboarding/reset/ResetComplete.tsx new file mode 100644 index 00000000..b07e986e --- /dev/null +++ b/apps/extension/src/app/features/onboarding/reset/ResetComplete.tsx @@ -0,0 +1,51 @@ +import { useTranslation } from 'react-i18next' +import { Link } from 'react-router' +import { OpenSidebarButton } from 'src/app/components/buttons/OpenSidebarButton' +import { useFinishExtensionOnboarding } from 'src/app/features/onboarding/hooks/useFinishExtensionOnboarding' +import { useOpenSidebar } from 'src/app/features/onboarding/hooks/useOpenSidebar' +import { terminateStoreSynchronization } from 'src/store/storeSynchronization' +import { Flex, Text } from 'ui/src' +import { Check, GraduationCap } from 'ui/src/components/icons' +import { uniswapUrls } from 'uniswap/src/constants/urls' + +export function ResetComplete(): JSX.Element { + const { t } = useTranslation() + + // Activates onboarding accounts on component mount + useFinishExtensionOnboarding({ callback: terminateStoreSynchronization }) + + const { openedSideBar, handleOpenSidebar, handleOpenWebApp } = useOpenSidebar() + + return ( + <> + + + + + + {t('onboarding.resetPassword.complete.title')} + + {t('onboarding.resetPassword.complete.subtitle')} + + + + + + + + {t('onboarding.resetPassword.complete.safety')} + + + + + + ) +} diff --git a/apps/extension/src/app/features/onboarding/scan/OTPInput.tsx b/apps/extension/src/app/features/onboarding/scan/OTPInput.tsx new file mode 100644 index 00000000..9d3f1093 --- /dev/null +++ b/apps/extension/src/app/features/onboarding/scan/OTPInput.tsx @@ -0,0 +1,233 @@ +import { createRef, RefObject, useCallback, useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { NativeSyntheticEvent, TextInput, TextInputChangeEventData, TextInputKeyPressEventData } from 'react-native' +import { OnboardingScreen } from 'src/app/features/onboarding/OnboardingScreen' +import { useOnboardingSteps } from 'src/app/features/onboarding/OnboardingSteps' +import { useScantasticContext } from 'src/app/features/onboarding/scan/ScantasticContextProvider' +import { decryptMessage } from 'src/app/features/onboarding/scan/utils' +import { Flex, Input, inputStyles, Square, Text } from 'ui/src' +import { Mobile } from 'ui/src/components/icons' +import { fonts, iconSizes } from 'ui/src/theme' +import { uniswapUrls } from 'uniswap/src/constants/urls' +import Trace from 'uniswap/src/features/telemetry/Trace' +import { ExtensionOnboardingFlow, ExtensionOnboardingScreens } from 'uniswap/src/types/screens/extension' +import { logger } from 'utilities/src/logger/logger' +import { arraysAreEqual } from 'utilities/src/primitives/array' +import { ONE_SECOND_MS } from 'utilities/src/time/time' +import { useInterval, useTimeout } from 'utilities/src/time/timing' +import { useOnboardingContext } from 'wallet/src/features/onboarding/OnboardingContext' +import { getOtpDurationString } from 'wallet/src/utils/duration' + +const MAX_FAILED_OTP_ATTEMPTS = 3 + +type CharacterSequence = [string, string, string, string, string, string] +const INITIAL_CHARACTER_SEQUENCE: CharacterSequence = ['', '', '', '', '', ''] + +export function OTPInput(): JSX.Element { + const { t } = useTranslation() + const { goToNextStep, goToPreviousStep } = useOnboardingSteps() + + const { addOnboardingAccountMnemonic } = useOnboardingContext() + const { privateKey, resetScantastic, sessionUUID, expirationTimestamp } = useScantasticContext() + const resetFlowAndNavBack = useCallback((): void => { + resetScantastic() + goToPreviousStep() + }, [goToPreviousStep, resetScantastic]) + + const [expiryText, setExpiryText] = useState(getOtpDurationString(expirationTimestamp)) + + const setExpirationText = useCallback(() => { + const expirationString = getOtpDurationString(expirationTimestamp) + setExpiryText(expirationString) + }, [expirationTimestamp]) + useInterval(setExpirationText, ONE_SECOND_MS) + + if (!sessionUUID || !privateKey) { + resetFlowAndNavBack() + } + + const [loading, setLoading] = useState(false) + const [error, setError] = useState(false) + const [failedAttemptCount, setFailedAttemptCount] = useState(0) + const [characterSequence, setCharacterSequence] = useState(INITIAL_CHARACTER_SEQUENCE) + + const inputRefs = useRef[]>([]) + inputRefs.current = new Array(6).fill(null).map((_, i) => inputRefs.current[i] || createRef()) + + // Add all accounts from mnemonic. + const onSubmit = useCallback( + async (mnemonic: string[]) => { + addOnboardingAccountMnemonic(mnemonic) + goToNextStep() + }, + [goToNextStep, addOnboardingAccountMnemonic], + ) + + useEffect(() => { + if (error && !arraysAreEqual(characterSequence, INITIAL_CHARACTER_SEQUENCE)) { + setCharacterSequence(INITIAL_CHARACTER_SEQUENCE) + } + }, [error, characterSequence]) + + const submitOTP = useCallback(async (): Promise => { + if (!privateKey || !sessionUUID) { + return + } + setError(false) + setLoading(true) + // submit OTP to receive blob + const response = await fetch(`${uniswapUrls.scantasticApiUrl}/otp`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + uuid: sessionUUID, + otp: characterSequence.join(''), + }), + }) + + if (!response.ok) { + setCharacterSequence(INITIAL_CHARACTER_SEQUENCE) + throw new Error(`Failed to submit OTP: ${await response.text()}`) + } + + const data = (await response.json()) as { encryptedSeed?: string; OTPFailedAttempts?: number } + if (!data.encryptedSeed) { + if (data.OTPFailedAttempts) { + if (Number(data.OTPFailedAttempts) === MAX_FAILED_OTP_ATTEMPTS) { + resetFlowAndNavBack() + return + } else { + setFailedAttemptCount(data.OTPFailedAttempts) + return + } + } + throw new Error(`fetch(${uniswapUrls.scantasticApiUrl}/otp failed to include an encrypted seed`) + } + const preImage = await decryptMessage(privateKey, data.encryptedSeed) + const words = preImage.split(' ') + + const newMnemonic = Array(24) + .fill('') + .map((_, i) => (words[i] || '') as string) + .filter((word) => !!word) + + await onSubmit(newMnemonic) + }, [privateKey, sessionUUID, characterSequence, onSubmit, resetFlowAndNavBack]) + + const handleChange = useCallback( + (index: number) => + (event: NativeSyntheticEvent): void => { + setError(false) + const newCharacters: CharacterSequence = [...characterSequence] + newCharacters[index] = event.nativeEvent.text + setCharacterSequence(newCharacters) + + if (newCharacters[index]?.length === 1 && inputRefs.current[index + 1]?.current) { + inputRefs.current[index + 1]?.current?.focus() + } + }, + [characterSequence], + ) + + const handleKeyPress = useCallback( + (index: number) => + (event: NativeSyntheticEvent): void => { + if (index !== 0 && event.nativeEvent.key === 'Backspace') { + inputRefs.current[index - 1]?.current?.focus() + } + }, + [], + ) + + useEffect(() => { + const allCharactersFilled = characterSequence.every((element) => element !== '') + if (allCharactersFilled && !loading && !error) { + submitOTP() + .catch((e) => { + inputRefs.current[0]?.current?.focus() + logger.error(e, { + tags: { file: 'OTPInput.tsx', function: 'submitOTP' }, + extra: { uuid: sessionUUID }, + }) + setError(true) + }) + .finally(() => { + setLoading(false) + }) + } + }, [characterSequence, loading, error, sessionUUID, submitOTP]) + + useTimeout(resetFlowAndNavBack, expirationTimestamp - Date.now()) + + return ( + + + + + } + nextButtonEnabled={false} + nextButtonText={expiryText} + nextButtonVariant="default" + nextButtonEmphasis="secondary" + subtitle={t('onboarding.scan.otp.subtitle')} + title={t('onboarding.scan.otp.title')} + onBack={resetFlowAndNavBack} + onSubmit={(): void => undefined} + > + + + {characterSequence.map((character, index) => ( + + ))} + + + {error && ( + + {t('onboarding.scan.otp.error')} + + )} + {failedAttemptCount > 0 && ( + + {t('onboarding.scan.otp.failed', { number: failedAttemptCount })} + + )} + + + + ) +} diff --git a/apps/extension/src/app/features/onboarding/scan/ScanToOnboard.tsx b/apps/extension/src/app/features/onboarding/scan/ScanToOnboard.tsx new file mode 100644 index 00000000..234e3646 --- /dev/null +++ b/apps/extension/src/app/features/onboarding/scan/ScanToOnboard.tsx @@ -0,0 +1,325 @@ +import { useCallback, useEffect, useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { + cancelAnimation, + useAnimatedStyle, + useSharedValue, + withRepeat, + withSequence, + withSpring, +} from 'react-native-reanimated' +import { SpringConfig } from 'react-native-reanimated/lib/typescript/animation/springUtils' +import QRCode from 'react-qr-code' //TODO(EXT-476): Replace with custom QR code designs +import { OnboardingScreen } from 'src/app/features/onboarding/OnboardingScreen' +import { useOnboardingSteps } from 'src/app/features/onboarding/OnboardingSteps' +import { useScantasticContext } from 'src/app/features/onboarding/scan/ScantasticContextProvider' +import { getScantasticUrl } from 'src/app/features/onboarding/scan/utils' +import { OnboardingRoutes, TopLevelRoutes } from 'src/app/navigation/constants' +import { navigate } from 'src/app/navigation/state' +import UAParser from 'ua-parser-js' +import { Flex, Image, Square, Text, TouchableArea, useSporeColors } from 'ui/src' +import { DOT_GRID, UNISWAP_LOGO } from 'ui/src/assets' +import { FileListLock, Mobile, RotatableChevron, Wifi } from 'ui/src/components/icons' +import { AnimatedFlex } from 'ui/src/components/layout/AnimatedFlex' +import { iconSizes, zIndexes } from 'ui/src/theme' +import { uniswapUrls } from 'uniswap/src/constants/urls' +import Trace from 'uniswap/src/features/telemetry/Trace' +import { ExtensionOnboardingFlow, ExtensionOnboardingScreens } from 'uniswap/src/types/screens/extension' +import { logger } from 'utilities/src/logger/logger' +import { useIsWindowVisible } from 'utilities/src/react/useIsWindowVisible' +import { ONE_SECOND_MS } from 'utilities/src/time/time' +import { useTimeout } from 'utilities/src/time/timing' +import { ScantasticParamsSchema } from 'wallet/src/features/scantastic/types' + +const UNISWAP_LOGO_SIZE = 52 +const UNISWAP_LOGO_SCALE_LOADING = 1.2 +const UNISWAP_LOGO_SCALE_DEFAULT = 1 +const QR_CODE_SIZE = 212 + +export function ScanToOnboard(): JSX.Element { + const colors = useSporeColors() + const { t } = useTranslation() + + const { goToNextStep } = useOnboardingSteps() + const isWindowVisible = useIsWindowVisible() + + const { sessionUUID, isLoadingUUID, publicKey, resetScantastic, expirationTimestamp, setExpirationTimestamp } = + useScantasticContext() + + const scantasticValue = useMemo(() => { + const parser = new UAParser(window.navigator.userAgent) + const { + device: { vendor, model }, + browser: { name: browser }, + } = parser.getResult() + + if (!publicKey || !sessionUUID) { + return '' + } + + try { + const params = ScantasticParamsSchema.parse({ + uuid: sessionUUID, + publicKey, + vendor, + browser, + model, + }) + return getScantasticUrl(params) + } catch (e) { + const wrappedError = new Error('Failed to build scantastic params', { cause: e }) + logger.error(wrappedError, { + tags: { + file: 'ScanToOnboard.tsx', + function: 'useMemo', + }, + }) + return '' + } + }, [publicKey, sessionUUID]) + + const errorDerivingQR = Boolean(!isLoadingUUID && !scantasticValue) + + const checkOTPState = useCallback(async (): Promise => { + if (!sessionUUID) { + return + } + try { + // poll OTP state + const response = await fetch(`${uniswapUrls.scantasticApiUrl}/otp-state/${sessionUUID}`, { + method: 'POST', + headers: { + Accept: 'application/json', + }, + }) + + if (!response.ok) { + throw new Error(`Failed to check OTP state: ${await response.text()}`) + } + const data = (await response.json()) as { otp: string; expiresAtInSeconds: number } + const otpState = data.otp + if (!otpState) { + throw new Error(`Scantastic OTP check response did not include the requested OTP state`) + } + + setExpirationTimestamp(data.expiresAtInSeconds * ONE_SECOND_MS) + + // mobile app has received the OTP and the user should input it into this UI + if (otpState === 'ready') { + goToNextStep() + } + if (otpState === 'expired') { + resetScantastic() + } + } catch (e) { + logger.error(e, { + tags: { + file: 'ScanToOnboard.tsx', + function: 'checkOTPState', + }, + extra: { uuid: sessionUUID }, + }) + } + }, [sessionUUID, setExpirationTimestamp, goToNextStep, resetScantastic]) + + useEffect(() => { + let interval: NodeJS.Timeout | undefined + + if (isWindowVisible) { + interval = setInterval(checkOTPState, ONE_SECOND_MS) + } + + return () => clearInterval(interval) + }, [checkOTPState, isWindowVisible]) + + useTimeout(resetScantastic, expirationTimestamp - Date.now()) + + const qrScale = useSharedValue(UNISWAP_LOGO_SCALE_DEFAULT) + useEffect(() => { + if (!isLoadingUUID) { + qrScale.value = UNISWAP_LOGO_SCALE_DEFAULT + return undefined + } + + const springConfig: SpringConfig = { + mass: 1, + stiffness: 80, + damping: 20, + } + qrScale.value = withRepeat( + withSequence( + withSpring(UNISWAP_LOGO_SCALE_LOADING, springConfig), + withSpring(UNISWAP_LOGO_SCALE_DEFAULT, springConfig), + ), + 0, + true, + ) + + return () => cancelAnimation(qrScale) + // oxlint-disable-next-line react/exhaustive-deps -- biome-parity: oxlint is stricter here + }, [isLoadingUUID]) + // Using useAnimatedStyle and AnimatedFlex because tamagui scale animation not working + const qrAnimatedStyle = useAnimatedStyle(() => { + return { + transform: `scale(${qrScale.value})`, + } + }, [qrScale]) + + /* + * This needs to be memoized in order to avoid a rerender loop when navigating back to + * this screen automatically on expiration or on HTTP error from within the Scantastic context. + * This happens because of the way we animate these screens (see `OnboardingSteps.tsx`). + * See WALL-6908 for original issue. + */ + const onboardingScreen = useMemo(() => { + return ( + + navigate(`/${TopLevelRoutes.Onboarding}/${OnboardingRoutes.Import}`)} + > + + + + + + + {t('onboarding.scan.troubleScanning.title')} + + + {t('onboarding.scan.troubleScanning.message')} + + + + + + + + + ) : undefined + } + Icon={ + + + + } + subtitle={t('onboarding.scan.subtitle')} + title={t('onboarding.scan.title')} + onBack={(): void => navigate(`/${TopLevelRoutes.Onboarding}`, { replace: true })} + > + + + {errorDerivingQR ? ( + + + {t('onboarding.scan.error')} + + + ) : ( + <> + {/* + NOTE: if you modify the style or colors of the QR code, make sure to thoroughly test + how they perform when scanning them both on light and dark modes. + */} + + + + {isLoadingUUID ? ( + + ) : ( + + + + )} + + )} + + + + + {t('onboarding.scan.wifi')} + + + + + ) + }, [colors.black.val, colors.white.val, errorDerivingQR, isLoadingUUID, qrAnimatedStyle, scantasticValue, t]) + + return ( + + {onboardingScreen} + + ) +} diff --git a/apps/extension/src/app/features/onboarding/scan/ScantasticContextProvider.tsx b/apps/extension/src/app/features/onboarding/scan/ScantasticContextProvider.tsx new file mode 100644 index 00000000..798aab98 --- /dev/null +++ b/apps/extension/src/app/features/onboarding/scan/ScantasticContextProvider.tsx @@ -0,0 +1,142 @@ +import { + createContext, + Dispatch, + PropsWithChildren, + SetStateAction, + useCallback, + useContext, + useEffect, + useState, +} from 'react' +import { useOnboardingSteps } from 'src/app/features/onboarding/OnboardingSteps' +import { cryptoKeyToJWK, KEY_PARAMS } from 'src/app/features/onboarding/scan/utils' +import { OnboardingRoutes, TopLevelRoutes } from 'src/app/navigation/constants' +import { navigate } from 'src/app/navigation/state' +import { uniswapUrls } from 'uniswap/src/constants/urls' +import { logger } from 'utilities/src/logger/logger' +import { ONE_DAY_MS, ONE_MINUTE_MS, ONE_SECOND_MS } from 'utilities/src/time/time' +import { ScantasticParamsSchema } from 'wallet/src/features/scantastic/types' + +type ScantasticContextState = { + isLoadingUUID: boolean + privateKey: CryptoKey | null + publicKey: JsonWebKey | null + sessionUUID: string | null + resetScantastic: () => void + expirationTimestamp: number + setExpirationTimestamp: Dispatch> +} + +const uuidSchema = ScantasticParamsSchema.shape.uuid + +const ScantasticContext = createContext(undefined) + +export function ScantasticContextProvider({ children }: PropsWithChildren): JSX.Element { + const { isResetting } = useOnboardingSteps() + + const [isLoadingUUID, setIsLoadingUUID] = useState(true) + const [publicKey, setPublicKey] = useState(null) + const [privateKey, setPrivateKey] = useState(null) + const [sessionUUID, setSessionUUID] = useState(null) + // Users have 20 minutes to scan the QR code. This is reduced to 6 minutes for OTP input once the scan is completed. + const [expirationTimestamp, setExpirationTimestamp] = useState(Date.now() + 20 * ONE_MINUTE_MS) + + const reset = useCallback(() => { + setPublicKey(null) + setPrivateKey(null) + setSessionUUID(null) + setExpirationTimestamp(Date.now() + ONE_DAY_MS) + navigate(`/${TopLevelRoutes.Onboarding}/${isResetting ? OnboardingRoutes.ResetScan : OnboardingRoutes.Scan}`, { + replace: true, + }) + }, [isResetting]) + + useEffect(() => { + async function getSessionUUID(): Promise { + if (sessionUUID) { + return + } + + try { + const { publicKey: pub, privateKey: priv } = await window.crypto.subtle.generateKey(KEY_PARAMS, true, [ + 'encrypt', + 'decrypt', + ]) + const jwk = await cryptoKeyToJWK(pub) + setPublicKey(jwk) + setPrivateKey(priv) + } catch (e) { + logger.error(e, { + tags: { + file: 'OnboardingContextProvider.tsx', + function: 'getSessionUUID->generateKeyPair', + }, + }) + } + + // Initiate scantastic onboarding session + const response = await fetch(`${uniswapUrls.scantasticApiUrl}/uuid`, { + method: 'POST', + headers: { + Accept: 'application/json', + }, + }) + + if (!response.ok) { + throw new Error(`Failed to fetch uuid for mobile->ext onboarding: ${await response.text()}`) + } + + const data = await response.json() + + if (!data.uuid) { + throw new Error('Missing uuid from onboarding session initiation request.') + } + + try { + const uuid = uuidSchema.parse(data.uuid) + setSessionUUID(uuid) + } catch { + throw new Error('Invalid uuid from onboarding session initiation request.') + } + + if (data.expiresAtInSeconds) { + setExpirationTimestamp(data.expiresAtInSeconds * ONE_SECOND_MS) + } + } + + setIsLoadingUUID(true) + getSessionUUID() + .catch((e) => { + logger.error(e, { + tags: { file: 'OnboardingContextProvider.tsx', function: 'getSessionUUID' }, + }) + }) + .finally(() => { + setIsLoadingUUID(false) + }) + }, [sessionUUID]) + + return ( + + {children} + + ) +} + +export const useScantasticContext = (): ScantasticContextState => { + const scantasticContext = useContext(ScantasticContext) + if (scantasticContext === undefined) { + throw new Error('useScantasticContext must be inside a ScantasticContextProvider') + } + return scantasticContext +} diff --git a/apps/extension/src/app/features/onboarding/scan/utils.ts b/apps/extension/src/app/features/onboarding/scan/utils.ts new file mode 100644 index 00000000..e08cdb84 --- /dev/null +++ b/apps/extension/src/app/features/onboarding/scan/utils.ts @@ -0,0 +1,52 @@ +import { logger } from 'utilities/src/logger/logger' +import { ScantasticParams } from 'wallet/src/features/scantastic/types' + +export const KEY_PARAMS = { + name: 'RSA-OAEP', + modulusLength: 4096, + publicExponent: new Uint8Array([1, 0, 1]), + hash: 'SHA-256', +} + +export async function cryptoKeyToJWK(key: CryptoKey): Promise { + const exportedKeyData = await window.crypto.subtle.exportKey('jwk', key) + return exportedKeyData +} + +export function getScantasticUrl({ uuid, publicKey, vendor, model, browser }: ScantasticParams): string { + let qrURI = `uniswap://scantastic?pubKey=${JSON.stringify(publicKey)}&uuid=${encodeURIComponent(uuid)}` + if (vendor) { + qrURI = qrURI.concat(`&vendor=${encodeURIComponent(vendor)}`) + } + if (model) { + qrURI = qrURI.concat(`&model=${encodeURIComponent(model)}`) + } + if (browser) { + qrURI = qrURI.concat(`&browser=${encodeURIComponent(browser)}`) + } + return qrURI +} + +function base64ToArrayBuffer(base64Data: string): ArrayBuffer { + const binaryString = window.atob(base64Data) + const len = binaryString.length + const bytes = new Uint8Array(len) + for (let i = 0; i < len; i++) { + bytes[i] = binaryString.charCodeAt(i) + } + return bytes.buffer +} + +export async function decryptMessage(privateKey: CryptoKey, ciphertext: string): Promise { + const cipherTextBuffer = base64ToArrayBuffer(ciphertext) + + try { + const decryptedArrayBuffer = await window.crypto.subtle.decrypt({ name: 'RSA-OAEP' }, privateKey, cipherTextBuffer) + + const textDecoder = new TextDecoder() + return textDecoder.decode(decryptedArrayBuffer) + } catch (e) { + logger.error(e, { tags: { file: 'scan/utils.ts', function: 'decryptMessage' } }) + return '' + } +} diff --git a/apps/extension/src/app/features/onboarding/utils.ts b/apps/extension/src/app/features/onboarding/utils.ts new file mode 100644 index 00000000..c7c618c7 --- /dev/null +++ b/apps/extension/src/app/features/onboarding/utils.ts @@ -0,0 +1,22 @@ +import { useEffect } from 'react' + +export const ONBOARDING_CONTENT_WIDTH = 460 +export const ONBOARDING_INITIAL_FRAME_HEIGHT = 636 + +export function useSubmitOnEnter(onSubmit: () => void): void { + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent): void => { + if (event.key === 'Enter') { + onSubmit() + } + } + + // Add event listener for keydown + window.addEventListener('keydown', handleKeyDown) + + // Cleanup event listener on component unmount + return (): void => { + window.removeEventListener('keydown', handleKeyDown) + } + }, [onSubmit]) +} diff --git a/apps/extension/src/app/features/popups/ConnectPopup.tsx b/apps/extension/src/app/features/popups/ConnectPopup.tsx new file mode 100644 index 00000000..57b79bf6 --- /dev/null +++ b/apps/extension/src/app/features/popups/ConnectPopup.tsx @@ -0,0 +1,215 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useDispatch } from 'react-redux' +import { Link } from 'react-router' +import { removeDappConnection, saveDappConnection } from 'src/app/features/dapp/actions' +import { useDappContext } from 'src/app/features/dapp/DappContext' +import { SwitchNetworksModal } from 'src/app/features/home/SwitchNetworksModal' +import { closePopup, PopupName } from 'src/app/features/popups/slice' +import { AppRoutes, SettingsRoutes } from 'src/app/navigation/constants' +import { useExtensionNavigation } from 'src/app/navigation/utils' +import { + Anchor, + Button, + Circle, + Flex, + Popover, + Text, + TouchableArea, + UniversalImage, + UniversalImageResizeMode, +} from 'ui/src' +import { Power, RotatableChevron, X } from 'ui/src/components/icons' +import { borderRadii, iconSizes } from 'ui/src/theme' +import { NetworkLogo } from 'uniswap/src/components/CurrencyLogo/NetworkLogo' +import { DappIconPlaceholder } from 'uniswap/src/components/dapps/DappIconPlaceholder' +import { uniswapUrls } from 'uniswap/src/constants/urls' +import { getChainLabel } from 'uniswap/src/features/chains/utils' +import { pushNotification } from 'uniswap/src/features/notifications/slice/slice' +import { AppNotificationType } from 'uniswap/src/features/notifications/slice/types' +import { ExtensionEventName } from 'uniswap/src/features/telemetry/constants' +import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { extractNameFromUrl } from 'utilities/src/format/extractNameFromUrl' +import { extractUrlHost } from 'utilities/src/format/urls' +import { useActiveAccountWithThrow } from 'wallet/src/features/wallet/hooks' + +export function ConnectPopupContent({ + onClose, + asPopover = false, +}: { + onClose?: () => void + + asPopover?: boolean + showConnectButton?: boolean +}): JSX.Element { + const dispatch = useDispatch() + const { t } = useTranslation() + const { navigateTo } = useExtensionNavigation() + + const { dappUrl, dappIconUrl, isConnected, lastChainId } = useDappContext() + const uppercaseDappName = extractNameFromUrl(dappUrl) + const activeAccount = useActiveAccountWithThrow() + + const [isSwitchNetworksModalOpen, setSwitchNetworksModalOpen] = useState(false) + + const onConnect = async (): Promise => { + await saveDappConnection({ dappUrl, account: activeAccount, iconUrl: dappIconUrl }) + dispatch(pushNotification({ type: AppNotificationType.DappConnected, dappIconUrl })) + dispatch(closePopup(PopupName.Connect)) + sendAnalyticsEvent(ExtensionEventName.SidebarConnect, { dappUrl }) + } + + const onDisconnect = async (): Promise => { + await removeDappConnection(dappUrl, activeAccount) + dispatch(pushNotification({ type: AppNotificationType.DappDisconnected, dappIconUrl })) + dispatch(closePopup(PopupName.Connect)) + sendAnalyticsEvent(ExtensionEventName.SidebarDisconnect) + } + + const openSwitchNetworkModal = (): void => { + setSwitchNetworksModalOpen(true) + } + + const closeSwitchNetworkModal = (): void => { + setSwitchNetworksModalOpen(false) + } + + const openManageConnections = (): void => { + navigateTo(`/${AppRoutes.Settings}/${SettingsRoutes.ManageConnections}`) + } + + const fallbackIcon = + + return ( + + {isSwitchNetworksModalOpen ? ( + + ) : ( + + + + + + + {uppercaseDappName.charAt(0).toUpperCase() + uppercaseDappName.slice(1)} + + + + {extractUrlHost(dappUrl)} + + + + + + {!asPopover && ( + + + + )} + + + + + + + + + {isConnected ? t('extension.connection.titleConnected') : t('extension.connection.titleNotConnected')} + + + + {lastChainId && ( + + + + + + + + {getChainLabel(lastChainId)} + + + + + + )} + + + {!isConnected && ( + + + sendAnalyticsEvent(ExtensionEventName.DappTroubleConnecting, { + dappUrl, + }) + } + > + + {t('extension.connection.popup.trouble')} + + + + )} + + + + + + + + + {isConnected ? ( + + + + + + ) : ( + + + + + + )} + + + )} + + ) +} diff --git a/apps/extension/src/app/features/popups/selectors.ts b/apps/extension/src/app/features/popups/selectors.ts new file mode 100644 index 00000000..9c68f6b3 --- /dev/null +++ b/apps/extension/src/app/features/popups/selectors.ts @@ -0,0 +1,6 @@ +import { PopupsState } from 'src/app/features/popups/slice' +import { ExtensionState } from 'src/store/extensionReducer' + +export function selectPopupState(name: T): (state: ExtensionState) => PopupsState[T] { + return (state) => state.popups[name] +} diff --git a/apps/extension/src/app/features/popups/slice.ts b/apps/extension/src/app/features/popups/slice.ts new file mode 100644 index 00000000..7a4a2a1a --- /dev/null +++ b/apps/extension/src/app/features/popups/slice.ts @@ -0,0 +1,34 @@ +import { createSlice, type PayloadAction } from '@reduxjs/toolkit' + +export enum PopupName { + Connect = 'connect', +} + +export interface PopupsState { + [PopupName.Connect]: { + isOpen: boolean + } +} + +const initialState: PopupsState = { + [PopupName.Connect]: { + isOpen: false, + }, +} + +const slice = createSlice({ + name: 'popups', + initialState, + reducers: { + openPopup: (state, action: PayloadAction) => { + state[action.payload].isOpen = true + }, + closePopup: (state, action: PayloadAction) => { + state[action.payload].isOpen = false + }, + resetPopups: () => initialState, + }, +}) + +export const { openPopup, closePopup, resetPopups } = slice.actions +export const { reducer: popupsReducer } = slice diff --git a/apps/extension/src/app/features/receive/ReceiveScreen.test.tsx b/apps/extension/src/app/features/receive/ReceiveScreen.test.tsx new file mode 100644 index 00000000..05af8ea6 --- /dev/null +++ b/apps/extension/src/app/features/receive/ReceiveScreen.test.tsx @@ -0,0 +1,24 @@ +import { ReceiveScreen } from 'src/app/features/receive/ReceiveScreen' +import { cleanup, render, screen } from 'src/test/test-utils' +import { ACCOUNT, preloadedWalletPackageState } from 'wallet/src/test/fixtures' + +const preloadedState = preloadedWalletPackageState({ + account: ACCOUNT, +}) + +describe('ReceiveScreen', () => { + it('renders without error', () => { + const tree = render(, { preloadedState }) + + expect(tree).toMatchSnapshot() + cleanup() + }) + + it('renders a QR code', () => { + render(, { preloadedState }) + + const qrCode = screen.getByTestId('wallet-qr-code') + expect(qrCode).toBeDefined() + cleanup() + }) +}) diff --git a/apps/extension/src/app/features/receive/ReceiveScreen.tsx b/apps/extension/src/app/features/receive/ReceiveScreen.tsx new file mode 100644 index 00000000..1be25fc7 --- /dev/null +++ b/apps/extension/src/app/features/receive/ReceiveScreen.tsx @@ -0,0 +1,29 @@ +import { useTranslation } from 'react-i18next' +import { ScreenHeader } from 'src/app/components/layout/ScreenHeader' +import { SCREEN_ITEM_HORIZONTAL_PAD } from 'src/app/constants' +import { useExtensionNavigation } from 'src/app/navigation/utils' +import { Flex } from 'ui/src' +import { X } from 'ui/src/components/icons' +import { ReceiveQRCode } from 'uniswap/src/components/ReceiveQRCode/ReceiveQRCode' +import { ModalName } from 'uniswap/src/features/telemetry/constants' +import Trace from 'uniswap/src/features/telemetry/Trace' +import { useActiveAccountAddressWithThrow } from 'wallet/src/features/wallet/hooks' + +export function ReceiveScreen(): JSX.Element { + const { t } = useTranslation() + const { navigateBack } = useExtensionNavigation() + const activeAddress = useActiveAccountAddressWithThrow() + + return ( + + + + + + + + + + + ) +} diff --git a/apps/extension/src/app/features/receive/__snapshots__/ReceiveScreen.test.tsx.snap b/apps/extension/src/app/features/receive/__snapshots__/ReceiveScreen.test.tsx.snap new file mode 100644 index 00000000..1ad3731c --- /dev/null +++ b/apps/extension/src/app/features/receive/__snapshots__/ReceiveScreen.test.tsx.snap @@ -0,0 +1,12252 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ReceiveScreen renders without error 1`] = ` +{ + "asFragment": [Function], + "baseElement": +
+ +
+
+
+
+ + + +
+
+ + Receive + +
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + + + + + +
+
+
+
+
+
+
+
+ + 0x82D56A352367453f74FC0dC7B071b311da373Fa6 + +
+
+
+ + Wallet address + + + + +
+
+
+
+
+
+ + Use this address to receive tokens on + +
+
+
+
+
+ +
+
+ + 13 + + networks + + + + +
+
+
+ +

+ +

+
+
+ +
+ + + + + + , + "container":
+ +
+
+
+
+ + + +
+
+ + Receive + +
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + + + + + +
+
+
+
+
+
+
+
+ + 0x82D56A352367453f74FC0dC7B071b311da373Fa6 + +
+
+
+ + Wallet address + + + + +
+
+
+
+
+
+ + Use this address to receive tokens on + +
+
+
+
+
+ +
+
+ + 13 + + networks + + + + +
+
+
+ +

+ +

+
+
+ +
, + "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], +} +`; diff --git a/apps/extension/src/app/features/recoveryPhraseVerification/RecoveryPhraseVerification.tsx b/apps/extension/src/app/features/recoveryPhraseVerification/RecoveryPhraseVerification.tsx new file mode 100644 index 00000000..0d8a30d0 --- /dev/null +++ b/apps/extension/src/app/features/recoveryPhraseVerification/RecoveryPhraseVerification.tsx @@ -0,0 +1,245 @@ +import { useEffect, useMemo, useReducer, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { TextInput } from 'react-native' +import { Input } from 'src/app/components/Input' +import { Flex, Text } from 'ui/src' +import { Check } from 'ui/src/components/icons' +import { zIndexes } from 'ui/src/theme' +import { useDebounce } from 'utilities/src/time/timing' +import { PASSWORD_VALIDATION_DEBOUNCE_MS } from 'wallet/src/utils/password' + +type InputStackBaseProps = { + value?: string + onChangeText: (word: string) => void +} + +export function RecoveryPhraseVerification({ + mnemonic, + numberOfTests, + onWordVerified, + setHasError, + setSubtitle, + onComplete, +}: { + mnemonic: string[] + numberOfTests: number + onWordVerified: (numberOfWordsVerified: number) => void + setHasError: (hasError: boolean) => void + setSubtitle: (subtitle: string) => void + onComplete: () => void +}): JSX.Element { + const { t } = useTranslation() + + const [numberOfVerifiedWords, markCurrentWordVerified] = useReducer((v: number) => v + 1, 0) + const [userWordInput, setUserWordInput] = useState('') + + const isLastTest = numberOfVerifiedWords === numberOfTests - 1 + + // Pick `numberOfTests` random words + const testingWordIndexes = useMemo( + () => selectRandomNumbers(mnemonic.length, numberOfTests), + [mnemonic.length, numberOfTests], + ) + + const nextWordIndex = testingWordIndexes[numberOfVerifiedWords] ?? 0 + const nextWordNumber = nextWordIndex + 1 + const validWord = userWordInput === mnemonic[nextWordIndex] + + useEffect(() => { + setSubtitle(t('onboarding.backup.manual.subtitle', { count: nextWordNumber, ordinal: true })) + }, [nextWordNumber, setSubtitle, t]) + + // oxlint-disable-next-line react/exhaustive-deps -- Only want to run when verification state changes, not callbacks which are stable + useEffect(() => { + if (numberOfVerifiedWords === 0) { + return + } + + const isComplete = numberOfVerifiedWords === numberOfTests + + if (isComplete) { + onComplete() + return + } + + onWordVerified(numberOfVerifiedWords) + + // We only want this to run when the `numberOfTests` or `numberOfVerifiedWords` changes. + // oxlint-disable-next-line react/exhaustive-deps -- biome-parity: oxlint is stricter here + }, [numberOfTests, numberOfVerifiedWords]) + + // oxlint-disable-next-line react/exhaustive-deps -- markCurrentWordVerified and setUserWordInput are stable, others are needed for correct behavior timing + useEffect(() => { + let timeout: ReturnType | undefined + + if (validWord) { + timeout = setTimeout(() => { + markCurrentWordVerified() + setUserWordInput('') + }, 200) + } + + return () => clearTimeout(timeout) + }, [validWord, isLastTest, onComplete, onWordVerified, numberOfVerifiedWords]) + + const debouncedWord = useDebounce(userWordInput, PASSWORD_VALIDATION_DEBOUNCE_MS) + + useEffect(() => { + setHasError(!!debouncedWord && debouncedWord !== mnemonic[nextWordIndex]) + }, [debouncedWord, nextWordIndex, mnemonic, setHasError]) + + return ( + { + setUserWordInput(value) + setHasError(false) + }} + /> + ) +} + +function RecoveryPhraseInputStack({ + nextWordNumber, + numInputsBelow, + numTotalSteps, + isInputValid, + value, + onChangeText, +}: InputStackBaseProps & { + numInputsBelow: number + numTotalSteps: number + nextWordNumber: number + isInputValid: boolean +}): JSX.Element { + return ( + + + {isInputValid ? ( + + + + ) : null} + + + + ) +} + +type InputStackProps = InputStackBaseProps & { + total: number + current: number + prefixText: string +} + +function InputStack({ onChangeText, total, value, current, prefixText }: InputStackProps): JSX.Element { + const { t } = useTranslation() + const refs = useRef([]) + const prefixTexts = useRef([]) + + // this is weird because we only get the new word as it renders + // but avoiding a bit of a refactor before beta release, should be safe: + prefixTexts.current[current] ||= prefixText + + useEffect(() => { + // Wait until the next tick to focus the input, otherwise the state update interferes with the focus event. + setTimeout(() => { + refs.current[current]?.focus() + }, 1) + }, [current]) + + return ( + + {new Array(total).fill(0).map((_, i) => { + const isHidden = i < current + const isCurrentlyActive = i === current + const isBelow = i > current + const belowOffset = i - current + + return ( + + + {prefixTexts.current[i] || ''} + + { + if (inputNode) { + refs.current[i] = inputNode + } + }} + centered + large + borderColor="$surface3" + borderRadius="$rounded20" + flex={1} + placeholder={t('onboarding.backup.manual.placeholder')} + shadowColor="$shadowColor" + shadowOffset={{ width: 0, height: 4 }} + shadowOpacity={0.4} + shadowRadius={10} + value={value} + zIndex={zIndexes.sticky} + onChangeText={onChangeText} + /> + + ) + })} + + ) +} + +function selectRandomNumbers(maxNumber: number, numberOfNumbers: number): number[] { + const shuffledIndexes = [...Array(maxNumber).keys()].sort(() => 0.5 - Math.random()) + const selectedIndexes = shuffledIndexes.slice(0, numberOfNumbers) + selectedIndexes.sort((a, b) => a - b) + return selectedIndexes +} diff --git a/apps/extension/src/app/features/send/SendFlow.tsx b/apps/extension/src/app/features/send/SendFlow.tsx new file mode 100644 index 00000000..716c9cd8 --- /dev/null +++ b/apps/extension/src/app/features/send/SendFlow.tsx @@ -0,0 +1,36 @@ +import { useTranslation } from 'react-i18next' +import { ScreenHeader } from 'src/app/components/layout/ScreenHeader' +import { SCREEN_ITEM_HORIZONTAL_PAD } from 'src/app/constants' +import { SendFormScreen } from 'src/app/features/send/SendFormScreen/SendFormScreen' +import { useExtensionNavigation } from 'src/app/navigation/utils' +import { Flex } from 'ui/src' +import { X } from 'ui/src/components/icons' +import { ModalName } from 'uniswap/src/features/telemetry/constants' +import { TransactionSettingsStoreContextProvider } from 'uniswap/src/features/transactions/components/settings/stores/transactionSettingsStore/TransactionSettingsStoreContextProvider' +import { TransactionModal } from 'uniswap/src/features/transactions/components/TransactionModal/TransactionModal' +import { SwapFormStoreContextProvider } from 'uniswap/src/features/transactions/swap/stores/swapFormStore/SwapFormStoreContextProvider' +import { SendContextProvider } from 'wallet/src/features/transactions/contexts/SendContext' + +export function SendFlow(): JSX.Element { + const { t } = useTranslation() + const { navigateBack, locationState } = useExtensionNavigation() + + return ( + null}> + + + + + + + + + + + + + + + + ) +} diff --git a/apps/extension/src/app/features/send/SendFormScreen/RecipientPanel.tsx b/apps/extension/src/app/features/send/SendFormScreen/RecipientPanel.tsx new file mode 100644 index 00000000..138331ee --- /dev/null +++ b/apps/extension/src/app/features/send/SendFormScreen/RecipientPanel.tsx @@ -0,0 +1,126 @@ +import { useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Flex, Separator, Text, TouchableArea } from 'ui/src' +import { RotatableChevron, UserSearch } from 'ui/src/components/icons' +import { iconSizes, spacing } from 'ui/src/theme' +import { AddressDisplay } from 'uniswap/src/components/accounts/AddressDisplay' +import { UniverseChainId } from 'uniswap/src/features/chains/types' +import { SearchTextInput } from 'uniswap/src/features/search/SearchTextInput' +import { useFilteredRecipientSections } from 'wallet/src/components/RecipientSearch/hooks' +import { RecipientList } from 'wallet/src/components/RecipientSearch/RecipientList' +import { RecipientSelectSpeedBumps } from 'wallet/src/components/RecipientSearch/RecipientSelectSpeedBumps' +import { useSendContext } from 'wallet/src/features/transactions/contexts/SendContext' + +type RecipientPanelProps = { + chainId?: UniverseChainId +} + +export function RecipientPanel({ chainId }: RecipientPanelProps): JSX.Element { + const { t } = useTranslation() + + const [pattern, setPattern] = useState('') + const [selectedRecipient, setSelectedRecipient] = useState() + const [checkSpeedBumps, setCheckSpeedBumps] = useState(false) + + const { recipient, showRecipientSelector, updateSendForm } = useSendContext() + + const onSetShowRecipientSelector = useCallback( + (show: boolean) => { + updateSendForm({ showRecipientSelector: show }) + }, + [updateSendForm], + ) + + const onToggleShowRecipientSelector = useCallback(() => { + onSetShowRecipientSelector(!showRecipientSelector) + }, [onSetShowRecipientSelector, showRecipientSelector]) + + const { sections } = useFilteredRecipientSections(pattern) + + const onSelectRecipient = useCallback((newRecipient: string) => { + setSelectedRecipient(newRecipient) + setCheckSpeedBumps(true) + }, []) + + const onSpeedBumpConfirm = useCallback(() => { + if (!selectedRecipient) { + return + } + updateSendForm({ recipient: selectedRecipient }) + onSetShowRecipientSelector(false) + }, [selectedRecipient, updateSendForm, onSetShowRecipientSelector]) + + const onClose = (): void => { + updateSendForm({ showRecipientSelector: false }) + } + + const noPatternOrFavorites = !pattern && sections.length === 0 + + return showRecipientSelector || !recipient ? ( + + + + {t('common.text.recipient')} + + + + onSetShowRecipientSelector(true)} + borderColor="$transparent" + borderWidth="$none" + /> + + {showRecipientSelector && ( + + + + )} + + {showRecipientSelector && } + + {showRecipientSelector && + (noPatternOrFavorites ? ( + + + + {t('send.recipientSelect.search.empty')} + + + ) : !sections.length ? ( + + {t('send.search.empty.title')} + + {t('send.search.empty.subtitle')} + + + ) : ( + // Show either suggested recipients or filtered sections based on query + + ))} + + + ) : ( + + + + + + + ) +} diff --git a/apps/extension/src/app/features/send/SendFormScreen/ReviewButton.tsx b/apps/extension/src/app/features/send/SendFormScreen/ReviewButton.tsx new file mode 100644 index 00000000..f36f5dd1 --- /dev/null +++ b/apps/extension/src/app/features/send/SendFormScreen/ReviewButton.tsx @@ -0,0 +1,51 @@ +import { useTranslation } from 'react-i18next' +import { Button, Flex } from 'ui/src' +import { WarningLabel } from 'uniswap/src/components/modals/WarningModal/types' +import { nativeOnChain } from 'uniswap/src/constants/tokens' +import { ElementName } from 'uniswap/src/features/telemetry/constants' +import Trace from 'uniswap/src/features/telemetry/Trace' +import { TestID } from 'uniswap/src/test/fixtures/testIDs' +import { isWebPlatform } from 'utilities/src/platform' +import { useSendContext } from 'wallet/src/features/transactions/contexts/SendContext' + +type ReviewButtonProps = { + onPress: () => void + disabled?: boolean +} + +export function ReviewButton({ onPress, disabled }: ReviewButtonProps): JSX.Element { + const { t } = useTranslation() + + const { + warnings, + derivedSendInfo: { chainId }, + } = useSendContext() + + const nativeCurrencySymbol = nativeOnChain(chainId).symbol + + const insufficientGasFunds = warnings.warnings.some((warning) => warning.type === WarningLabel.InsufficientGasFunds) + + const disableReviewButton = !!warnings.blockingWarning || disabled + + const buttonText = insufficientGasFunds + ? t('send.warning.insufficientFunds.title', { + currencySymbol: nativeCurrencySymbol ?? '', + }) + : t('common.button.review') + + return ( + + + + + + ) +} diff --git a/apps/extension/src/app/features/send/SendFormScreen/SendFormScreen.tsx b/apps/extension/src/app/features/send/SendFormScreen/SendFormScreen.tsx new file mode 100644 index 00000000..1f9fdd9b --- /dev/null +++ b/apps/extension/src/app/features/send/SendFormScreen/SendFormScreen.tsx @@ -0,0 +1,244 @@ +import { useCallback, useMemo } from 'react' +import { useSelector } from 'react-redux' +import { RecipientPanel } from 'src/app/features/send/SendFormScreen/RecipientPanel' +import { ReviewButton } from 'src/app/features/send/SendFormScreen/ReviewButton' +import { Flex, Separator, useSporeColors } from 'ui/src' +import { Modal } from 'uniswap/src/components/modals/Modal' +import { selectHasDismissedLowNetworkTokenWarning } from 'uniswap/src/features/behaviorHistory/selectors' +import { UniverseChainId } from 'uniswap/src/features/chains/types' +import { ModalName, SectionName, UniswapEventName } from 'uniswap/src/features/telemetry/constants' +import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import Trace from 'uniswap/src/features/telemetry/Trace' +import { InsufficientNativeTokenWarning } from 'uniswap/src/features/transactions/components/InsufficientNativeTokenWarning/InsufficientNativeTokenWarning' +import { + TransactionScreen, + useTransactionModalContext, +} from 'uniswap/src/features/transactions/components/TransactionModal/TransactionModalContext' +import { useUSDCValue } from 'uniswap/src/features/transactions/hooks/useUSDCPriceWrapper' +import { useUSDTokenUpdater } from 'uniswap/src/features/transactions/hooks/useUSDTokenUpdater' +import { BlockedAddressWarning } from 'uniswap/src/features/transactions/modals/BlockedAddressWarning' +import { LowNativeBalanceModal } from 'uniswap/src/features/transactions/modals/LowNativeBalanceModal' +import { useIsBlocked } from 'uniswap/src/features/trm/hooks' +import { CurrencyField } from 'uniswap/src/types/currency' +import { createTransactionId } from 'uniswap/src/utils/createTransactionId' +import { isSafeNumber } from 'utilities/src/primitives/integer' +import { useBooleanState } from 'utilities/src/react/useBooleanState' +import { useSendContext } from 'wallet/src/features/transactions/contexts/SendContext' +import { GasFeeRow } from 'wallet/src/features/transactions/send/GasFeeRow' +import { useShowSendNetworkNotification } from 'wallet/src/features/transactions/send/hooks/useShowSendNetworkNotification' +import { SendAmountInput } from 'wallet/src/features/transactions/send/SendAmountInput' +import { SendReviewDetails } from 'wallet/src/features/transactions/send/SendReviewDetails' +import { TokenSelectorPanel } from 'wallet/src/features/transactions/send/TokenSelectorPanel' +import { isAmountGreaterThanZero } from 'wallet/src/features/transactions/utils' +import { useIsBlockedActiveAddress } from 'wallet/src/features/trm/hooks' + +export function SendFormScreen(): JSX.Element { + const colors = useSporeColors() + + const hasDismissedLowNetworkTokenWarning = useSelector(selectHasDismissedLowNetworkTokenWarning) + const { + value: showMaxTransferModal, + setTrue: handleShowMaxTransferModal, + setFalse: handleHideMaxTransferModal, + } = useBooleanState(false) + + const { + derivedSendInfo, + selectingCurrencyField, + exactAmountToken, + isFiatInput, + warnings, + gasFee, + showRecipientSelector, + recipient, + updateSendForm, + onSelectCurrency, + isMax, + } = useSendContext() + + const { screen, setScreen } = useTransactionModalContext() + + const { currencyInInfo, currencyBalances, currencyAmounts, chainId, exactAmountFiat } = derivedSendInfo + + // When a user changes networks or visits the send screen, show a network notification + useShowSendNetworkNotification({ chainId: currencyInInfo?.currency.chainId }) + + // Sync fiat and token amounts + const onFiatAmountUpdated = useCallback( + (amount: string): void => { + updateSendForm({ exactAmountFiat: amount }) + }, + [updateSendForm], + ) + + const onTokenAmountUpdated = useCallback( + (amount: string): void => { + updateSendForm({ exactAmountToken: amount }) + }, + [updateSendForm], + ) + + useUSDTokenUpdater({ + onFiatAmountUpdated, + onTokenAmountUpdated, + isFiatInput: Boolean(isFiatInput), + exactAmountToken, + exactAmountFiat, + currency: currencyInInfo?.currency, + }) + + const currencyUSDValue = useUSDCValue(currencyAmounts[CurrencyField.INPUT]) + + const exactValue = isFiatInput ? exactAmountFiat : exactAmountToken + const showTokenSelector = selectingCurrencyField === CurrencyField.INPUT + + const hasValueGreaterThanZero = useMemo(() => { + return isAmountGreaterThanZero({ + exactAmountToken, + exactAmountFiat, + currency: currencyInInfo?.currency, + }) + }, [exactAmountToken, exactAmountFiat, currencyInInfo?.currency]) + + // blocked addresses + const { isBlocked: isActiveBlocked, isBlockedLoading: isActiveBlockedLoading } = useIsBlockedActiveAddress() + const { isBlocked: isRecipientBlocked, isBlockedLoading: isRecipientBlockedLoading } = useIsBlocked(recipient) + const isSubjectBlocked = isActiveBlocked || isRecipientBlocked + const isSubjectBlockedLoading = isActiveBlockedLoading || isRecipientBlockedLoading + const isButtonBlocked = !hasValueGreaterThanZero || isSubjectBlocked || isSubjectBlockedLoading + + const goToReview = useCallback(() => { + const txId = createTransactionId() + updateSendForm({ txId }) + setScreen(TransactionScreen.Review) + }, [setScreen, updateSendForm]) + + const onPressReview = useCallback(() => { + if (!hasDismissedLowNetworkTokenWarning && isMax && currencyInInfo?.currency.isNative) { + sendAnalyticsEvent(UniswapEventName.LowNetworkTokenInfoModalOpened, { location: 'send' }) + handleShowMaxTransferModal() + return + } + goToReview() + }, [goToReview, isMax, hasDismissedLowNetworkTokenWarning, handleShowMaxTransferModal, currencyInInfo]) + + const onSetExactAmount = useCallback( + (amount: string) => { + // Omit parsing errors by checking if amount exceeds Number range limit + if (!isSafeNumber(amount)) { + return + } + + updateSendForm(isFiatInput ? { exactAmountFiat: amount } : { exactAmountToken: amount }) + }, + [isFiatInput, updateSendForm], + ) + + const onSetMax = useCallback( + (amount: string) => { + updateSendForm({ exactAmountToken: amount, isFiatInput: false, focusOnCurrencyField: null }) + }, + [updateSendForm], + ) + + const onAcknowledgeLowNativeBalanceWarning = useCallback(() => { + handleHideMaxTransferModal() + + goToReview() + }, [handleHideMaxTransferModal, goToReview]) + + const onHideTokenSelector = useCallback(() => { + updateSendForm({ selectingCurrencyField: undefined }) + }, [updateSendForm]) + + const onShowTokenSelector = useCallback(() => { + updateSendForm({ selectingCurrencyField: CurrencyField.INPUT }) + }, [updateSendForm]) + + const onToggleFiatInput = useCallback(() => { + updateSendForm({ isFiatInput: !isFiatInput }) + }, [isFiatInput, updateSendForm]) + + const inputShadowProps = { + shadowColor: colors.surface3.val, + shadowRadius: 10, + shadowOpacity: 0.04, + zIndex: 1, + } + + return ( + + + + + + + + + + + + + + + {!showRecipientSelector && ( + <> + {isSubjectBlocked && ( + + )} + + {!warnings.insufficientGasFundsWarning && } + + + )} + + + ) +} diff --git a/apps/extension/src/app/features/sessions/analytics.ts b/apps/extension/src/app/features/sessions/analytics.ts new file mode 100644 index 00000000..c35e560e --- /dev/null +++ b/apps/extension/src/app/features/sessions/analytics.ts @@ -0,0 +1,69 @@ +import type { HashcashSolveAnalytics, SessionInitAnalytics } from '@universe/sessions' +import { SessionsEventName } from 'uniswap/src/features/telemetry/constants' +import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' + +/** + * Sanitizes error messages before sending to analytics. + * Removes potentially sensitive information like file paths and stack traces. + */ +function sanitizeErrorMessage(message: string | undefined): string | undefined { + if (!message) { + return undefined + } + + let sanitized = message + // Remove file paths (Unix and Windows) + .replace(/\/[\w./-]+/g, '[path]') + .replace(/[A-Za-z]:\\[\w.-]+/g, '[path]') + // Remove stack traces (lines starting with "at ") + .replace(/\s+at\s+.+/g, '') + // Trim whitespace + .trim() + + // Truncate to reasonable length + if (sanitized.length > 200) { + sanitized = sanitized.slice(0, 200) + '...' + } + + return sanitized +} + +/** + * Analytics callbacks for session initialization lifecycle. + * Wires up the SessionInitAnalytics contract to Amplitude events. + */ +export const sessionInitAnalytics: SessionInitAnalytics = { + onInitStarted: () => sendAnalyticsEvent(SessionsEventName.SessionInitStarted), + onInitCompleted: (data) => + sendAnalyticsEvent(SessionsEventName.SessionInitCompleted, { + need_challenge: data.needChallenge, + duration_ms: data.durationMs, + }), + onChallengeReceived: (data) => + sendAnalyticsEvent(SessionsEventName.ChallengeReceived, { + challenge_type: data.challengeType, + // PII reviewed: challengeId is a server-generated random identifier not linked to user identity + challenge_id: data.challengeId, + }), + onVerifyCompleted: (data) => + sendAnalyticsEvent(SessionsEventName.VerifyCompleted, { + success: data.success, + attempt_number: data.attemptNumber, + total_duration_ms: data.totalDurationMs, + }), +} + +/** + * Analytics callback for Hashcash challenge solver. + */ +export const onHashcashSolveCompleted = (data: HashcashSolveAnalytics): void => { + sendAnalyticsEvent(SessionsEventName.HashcashSolveCompleted, { + duration_ms: data.durationMs, + success: data.success, + error_type: data.errorType, + error_message: sanitizeErrorMessage(data.errorMessage), + difficulty: data.difficulty, + iteration_count: data.iterationCount, + used_worker: data.usedWorker, + }) +} diff --git a/apps/extension/src/app/features/settings/BackupRecoveryPhrase/BackupRecoveryPhraseScreen.tsx b/apps/extension/src/app/features/settings/BackupRecoveryPhrase/BackupRecoveryPhraseScreen.tsx new file mode 100644 index 00000000..16b0c486 --- /dev/null +++ b/apps/extension/src/app/features/settings/BackupRecoveryPhrase/BackupRecoveryPhraseScreen.tsx @@ -0,0 +1,207 @@ +import { useQuery } from '@tanstack/react-query' +import { useCallback, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useDispatch } from 'react-redux' +import { ScreenHeader } from 'src/app/components/layout/ScreenHeader' +import { RecoveryPhraseVerification } from 'src/app/features/recoveryPhraseVerification/RecoveryPhraseVerification' +import { BackupWarningBulletPoints } from 'src/app/features/settings/BackupRecoveryPhrase/BackupWarningBulletPoints' +import { NUMBER_OF_TESTS_FOR_RECOVERY_PHRASE_VERIFICATION } from 'src/app/features/settings/BackupRecoveryPhrase/constants' +import { EnterPasswordModal } from 'src/app/features/settings/password/EnterPasswordModal' +import { SeedPhraseDisplay } from 'src/app/features/settings/SettingsRecoveryPhraseScreen/SeedPhraseDisplay' +import { SettingsRecoveryPhrase } from 'src/app/features/settings/SettingsRecoveryPhraseScreen/SettingsRecoveryPhrase' +import { useExtensionNavigation } from 'src/app/navigation/utils' +import { Checkbox, Flex, SpinningLoader, Text, TouchableArea } from 'ui/src' +import { AlertTriangleFilled, FileListCheck, FileListLock } from 'ui/src/components/icons' +import { iconSizes } from 'ui/src/theme' +import { useEvent } from 'utilities/src/react/hooks' +import { useBooleanState } from 'utilities/src/react/useBooleanState' +import { EditAccountAction, editAccountActions } from 'wallet/src/features/wallet/accounts/editAccountSaga' +import { BackupType } from 'wallet/src/features/wallet/accounts/types' +import { hasBackup } from 'wallet/src/features/wallet/accounts/utils' +import { useActiveAccountWithThrow, useSignerAccounts } from 'wallet/src/features/wallet/hooks' +import { mnemonicUnlockedQuery } from 'wallet/src/features/wallet/Keyring/queries' + +enum ViewStep { + Warning = 0, + Password = 1, + Reveal = 2, + Confirm = 3, +} + +export function BackupRecoveryPhraseScreen(): JSX.Element { + return ( + + + + + + ) +} + +function BackupRecoveryPhraseScreenSteps(): JSX.Element { + const { t } = useTranslation() + const dispatch = useDispatch() + const activeAccount = useActiveAccountWithThrow() + + const { navigateBack } = useExtensionNavigation() + + const [viewStep, setViewStep] = useState(ViewStep.Warning) + + const mnemonicId = useSignerAccounts()[0]?.mnemonicId + + if (!mnemonicId) { + throw new Error('Invalid render of `ViewRecoveryPhraseScreen` without `mnemonicId`') + } + + const showPasswordModal = useCallback((): void => { + setViewStep(ViewStep.Password) + }, []) + + const { value: isDisclaimerChecked, toggle: toggleDisclaimer } = useBooleanState(false) + + const hasMaybeManualBackup = hasBackup(BackupType.MaybeManual, activeAccount) + + const onBackupComplete = useEvent(() => { + dispatch( + editAccountActions.trigger({ + type: EditAccountAction.AddBackupMethod, + address: activeAccount.address, + backupMethod: BackupType.Manual, + }), + ) + + if (hasMaybeManualBackup) { + // Remove `maybe-manual` backup type when completing manual backup. + dispatch( + editAccountActions.trigger({ + type: EditAccountAction.RemoveBackupMethod, + address: activeAccount.address, + backupMethod: BackupType.MaybeManual, + }), + ) + } + + navigateBack() + }) + + switch (viewStep) { + case ViewStep.Warning: + case ViewStep.Password: + return ( + } + nextButtonEnabled={true} + nextButtonText={t('common.button.continue')} + nextButtonEmphasis="primary" + subtitle={t('onboarding.backup.view.subtitle.message1')} + title={t('onboarding.backup.manual.displayWarning.title')} + onNextPressed={showPasswordModal} + > + setViewStep(ViewStep.Warning)} + onNext={() => setViewStep(ViewStep.Reveal)} + /> + + + + + + ) + case ViewStep.Reveal: + return ( + } + iconBackgroundColor="$surface3" + nextButtonEnabled={isDisclaimerChecked} + nextButtonText={t('common.button.continue')} + nextButtonEmphasis="primary" + subtitle={t('onboarding.backup.view.subtitle.message2')} + title={t('onboarding.backup.view.title')} + titleColor="$neutral1" + onNextPressed={() => setViewStep(ViewStep.Confirm)} + > + + + + + + + + + {t('onboarding.backup.speedBump.manual.disclaimer')} + + + + + + ) + case ViewStep.Confirm: + return + } +} + +function RecoveryPhraseVerificationStep({ + mnemonicId, + onComplete, +}: { + mnemonicId: string + onComplete: () => void +}): JSX.Element { + const { t } = useTranslation() + + const [subtitle, setSubtitle] = useState('') + const [hasError, setHasError] = useState(false) + const [numberOfWordsVerified, setNumberOfWordsVerified] = useState(0) + + const { data: mnemonic, error } = useQuery(mnemonicUnlockedQuery(mnemonicId)) + + if (error) { + // This should never happen. We can't recover from a missing mnemonic. + const missingMnemonicError = new Error('Missing mnemonic in `RecoveryPhraseVerificationStep`: ' + mnemonicId) + missingMnemonicError.cause = error + throw missingMnemonicError + } + + const mnemonicArray = useMemo(() => (mnemonic ? mnemonic.split(' ') : null), [mnemonic]) + + return ( + } + iconBackgroundColor="$surface3" + nextButtonEnabled={false} + nextButtonText={t('onboarding.backup.manual.progress', { + completedStepsCount: numberOfWordsVerified, + totalStepsCount: NUMBER_OF_TESTS_FOR_RECOVERY_PHRASE_VERIFICATION, + })} + nextButtonEmphasis="primary" + subtitle={subtitle} + title={t('onboarding.backup.manual.title')} + titleColor="$neutral1" + onNextPressed={() => {}} + > + + {!mnemonicArray ? ( + + + + ) : ( + <> + setNumberOfWordsVerified(numberOfWordsVerified)} + setSubtitle={setSubtitle} + setHasError={setHasError} + /> + + + {t('onboarding.backup.manual.error')} + + + )} + + + ) +} diff --git a/apps/extension/src/app/features/settings/BackupRecoveryPhrase/BackupWarningBulletPoints.tsx b/apps/extension/src/app/features/settings/BackupRecoveryPhrase/BackupWarningBulletPoints.tsx new file mode 100644 index 00000000..fe044010 --- /dev/null +++ b/apps/extension/src/app/features/settings/BackupRecoveryPhrase/BackupWarningBulletPoints.tsx @@ -0,0 +1,46 @@ +import { FunctionComponent } from 'react' +import { Trans, useTranslation } from 'react-i18next' +import { Circle, Flex, IconProps, Text } from 'ui/src' +import { EyeOff, Key, PencilDetailed } from 'ui/src/components/icons' +import { iconSizes } from 'ui/src/theme' + +export function BackupWarningBulletPoints(): JSX.Element { + const { t } = useTranslation() + + return ( + + + + {t('onboarding.backup.view.warning.message1')} + + + + {t('onboarding.backup.view.warning.message2')} + + + + + }} + i18nKey="onboarding.backup.view.warning.message3" + /> + + + + ) +} + +function WarningIcon({ Icon }: { Icon: FunctionComponent }): JSX.Element { + return ( + + + + ) +} diff --git a/apps/extension/src/app/features/settings/BackupRecoveryPhrase/constants.ts b/apps/extension/src/app/features/settings/BackupRecoveryPhrase/constants.ts new file mode 100644 index 00000000..6ca34f03 --- /dev/null +++ b/apps/extension/src/app/features/settings/BackupRecoveryPhrase/constants.ts @@ -0,0 +1 @@ +export const NUMBER_OF_TESTS_FOR_RECOVERY_PHRASE_VERIFICATION = 3 diff --git a/apps/extension/src/app/features/settings/BiometricUnlock/BiometricAuthModal.tsx b/apps/extension/src/app/features/settings/BiometricUnlock/BiometricAuthModal.tsx new file mode 100644 index 00000000..b2a3aaf3 --- /dev/null +++ b/apps/extension/src/app/features/settings/BiometricUnlock/BiometricAuthModal.tsx @@ -0,0 +1,69 @@ +import { useTranslation } from 'react-i18next' +import { Button, Flex, GeneratedIcon, Square, Text, useSporeColors } from 'ui/src' +import { HelpCenter } from 'ui/src/components/icons' +import { Modal } from 'uniswap/src/components/modals/Modal' +import { uniswapUrls } from 'uniswap/src/constants/urls' +import { ModalName } from 'uniswap/src/features/telemetry/constants' +import { useEvent } from 'utilities/src/react/hooks' + +export function BiometricAuthModal({ + onClose, + biometricMethodName, + title, + Icon, +}: { + onClose: () => void + biometricMethodName: string + title: string + Icon: GeneratedIcon +}): JSX.Element { + const { t } = useTranslation() + const colors = useSporeColors() + + const onPressGetHelp = useEvent((): void => { + window.open(uniswapUrls.helpArticleUrls.extensionBiometricsEnrollment, '_blank') + }) + + return ( + + + + + + + + + + + + + + + {title} + + + + {t('settings.setting.biometrics.extension.waitingForBiometricsModal.content', { + biometricsMethod: biometricMethodName, + })} + + + + + + + + + ) +} diff --git a/apps/extension/src/app/features/settings/BiometricUnlock/BiometricUnlockSettingsToggleRow.tsx b/apps/extension/src/app/features/settings/BiometricUnlock/BiometricUnlockSettingsToggleRow.tsx new file mode 100644 index 00000000..12b545e6 --- /dev/null +++ b/apps/extension/src/app/features/settings/BiometricUnlock/BiometricUnlockSettingsToggleRow.tsx @@ -0,0 +1,104 @@ +import { useQuery } from '@tanstack/react-query' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useBiometricUnlockDisableMutation } from 'src/app/features/biometricUnlock/useBiometricUnlockDisableMutation' +import { useBiometricUnlockSetupMutation } from 'src/app/features/biometricUnlock/useBiometricUnlockSetupMutation' +import { useHasBiometricUnlockCredential } from 'src/app/features/biometricUnlock/useShouldShowBiometricUnlock' +import { useShouldShowBiometricUnlockEnrollment } from 'src/app/features/biometricUnlock/useShouldShowBiometricUnlockEnrollment' +import { BiometricAuthModal } from 'src/app/features/settings/BiometricUnlock/BiometricAuthModal' +import { SettingsToggleRow } from 'src/app/features/settings/components/SettingsToggleRow' +import { EnterPasswordModal } from 'src/app/features/settings/password/EnterPasswordModal' +import { builtInBiometricCapabilitiesQuery } from 'src/app/utils/device/builtInBiometricCapabilitiesQuery' +import { Fingerprint } from 'ui/src/components/icons' +import { useEvent } from 'utilities/src/react/hooks' + +enum ShowModal { + Password = 'password', + WaitingForBiometrics = 'waiting', +} + +export function BiometricUnlockSettingsToggleRow(): JSX.Element | null { + const { t } = useTranslation() + const [modal, setModal] = useState(null) + + const showPasswordModal = useEvent(() => setModal(ShowModal.Password)) + const showWaitingForBiometricsModal = useEvent(() => setModal(ShowModal.WaitingForBiometrics)) + + const hidePasswordModal = useEvent(() => { + if (modal === ShowModal.Password) { + setModal(null) + } + }) + + const hideWaitingForBiometricsModal = useEvent(() => { + if (modal === ShowModal.WaitingForBiometrics) { + setModal(null) + } + }) + + const hasBiometricUnlockCredential = useHasBiometricUnlockCredential() + const showBiometricUnlockEnrollment = useShouldShowBiometricUnlockEnrollment({ flow: 'settings' }) + + // We want to show the toggle when the user has a credential even if enrollment is not available, + // so that they can remove their passkey if they want to. + const showBiometricUnlockToggle = hasBiometricUnlockCredential || showBiometricUnlockEnrollment + + const { data: biometricCapabilities } = useQuery(builtInBiometricCapabilitiesQuery({ t })) + + const { mutate: setupBiometricUnlock } = useBiometricUnlockSetupMutation({ onSuccess: hideWaitingForBiometricsModal }) + const { mutate: disableBiometricUnlock } = useBiometricUnlockDisableMutation() + + const onPasswordModalNext = useEvent((password?: string): void => { + hidePasswordModal() + + if (!password) { + return + } + + if (hasBiometricUnlockCredential) { + disableBiometricUnlock() + } else { + showWaitingForBiometricsModal() + setupBiometricUnlock(password) + } + }) + + if (!showBiometricUnlockToggle) { + return null + } + + const Icon = biometricCapabilities?.icon ?? Fingerprint + const name = biometricCapabilities?.name ?? t('common.biometrics.generic') + + return ( + <> + + + {modal === ShowModal.Password && ( + + )} + + {modal === ShowModal.WaitingForBiometrics && ( + + )} + + ) +} diff --git a/apps/extension/src/app/features/settings/DevMenuScreen.tsx b/apps/extension/src/app/features/settings/DevMenuScreen.tsx new file mode 100644 index 00000000..3045eb95 --- /dev/null +++ b/apps/extension/src/app/features/settings/DevMenuScreen.tsx @@ -0,0 +1,52 @@ +import { ScreenHeader } from 'src/app/components/layout/ScreenHeader' +import { SettingsItem } from 'src/app/features/settings/components/SettingsItem' +import { AppRoutes, SettingsRoutes } from 'src/app/navigation/constants' +import { useExtensionNavigation } from 'src/app/navigation/utils' +import { Accordion, Flex, ScrollView, Text } from 'ui/src' +import { Clock, Wrench } from 'ui/src/components/icons' +import { CacheConfig } from 'uniswap/src/components/gating/CacheConfig' +import { GatingOverrides } from 'uniswap/src/components/gating/GatingOverrides' + +/** + * When modifying this component, take into consideration that this is used + * both as a full screen page in the Sidebar, and as a modal in the Onboarding page. + */ +export function DevMenuScreen(): JSX.Element { + const { navigateTo } = useExtensionNavigation() + + return ( + + + + + + Debug Screens + + navigateTo(`/${AppRoutes.Settings}/${SettingsRoutes.SessionsDebug}`)} + /> + navigateTo(`/${AppRoutes.Settings}/${SettingsRoutes.HashcashBenchmark}`)} + /> + + + Gating + + + + + + + Miscellaneous + + + + + + + ) +} diff --git a/apps/extension/src/app/features/settings/DeviceAccessScreen.tsx b/apps/extension/src/app/features/settings/DeviceAccessScreen.tsx new file mode 100644 index 00000000..c739b0cb --- /dev/null +++ b/apps/extension/src/app/features/settings/DeviceAccessScreen.tsx @@ -0,0 +1,122 @@ +import { useQuery } from '@tanstack/react-query' +import { useTranslation } from 'react-i18next' +import { useDispatch, useSelector } from 'react-redux' +import { ScreenHeader } from 'src/app/components/layout/ScreenHeader' +import { useShouldShowBiometricUnlock } from 'src/app/features/biometricUnlock/useShouldShowBiometricUnlock' +import { useShouldShowBiometricUnlockEnrollment } from 'src/app/features/biometricUnlock/useShouldShowBiometricUnlockEnrollment' +import { BiometricAuthModal } from 'src/app/features/settings/BiometricUnlock/BiometricAuthModal' +import { BiometricUnlockSettingsToggleRow } from 'src/app/features/settings/BiometricUnlock/BiometricUnlockSettingsToggleRow' +import { SettingsItem } from 'src/app/features/settings/components/SettingsItem' +import { CreateNewPasswordModal } from 'src/app/features/settings/password/CreateNewPasswordModal' +import { EnterPasswordModal } from 'src/app/features/settings/password/EnterPasswordModal' +import { PasswordResetFlowState, usePasswordResetFlow } from 'src/app/features/settings/password/usePasswordResetFlow' +import { SettingsItemWithDropdown } from 'src/app/features/settings/SettingsItemWithDropdown' +import { builtInBiometricCapabilitiesQuery } from 'src/app/utils/device/builtInBiometricCapabilitiesQuery' +import { ExtensionState } from 'src/store/extensionReducer' +import { Flex, ScrollView } from 'ui/src' +import { Stopwatch } from 'ui/src/components/icons' +import { Key } from 'ui/src/components/icons/Key' +import { DeviceAccessTimeout, ORDERED_DEVICE_ACCESS_TIMEOUTS } from 'uniswap/src/features/settings/constants' +import { setDeviceAccessTimeout } from 'uniswap/src/features/settings/slice' + +function getDeviceAccessTimeoutLabel(t: ReturnType['t'], timeout: DeviceAccessTimeout): string { + switch (timeout) { + case DeviceAccessTimeout.FiveMinutes: + return t('settings.setting.deviceAccessTimeout.5minutes') + case DeviceAccessTimeout.ThirtyMinutes: + return t('settings.setting.deviceAccessTimeout.30minutes') + case DeviceAccessTimeout.OneHour: + return t('settings.setting.deviceAccessTimeout.1hour') + case DeviceAccessTimeout.TwentyFourHours: + return t('settings.setting.deviceAccessTimeout.24hours') + case DeviceAccessTimeout.Never: + return t('settings.setting.deviceAccessTimeout.never') + default: + return t('settings.setting.deviceAccessTimeout.30minutes') + } +} + +export function DeviceAccessScreen(): JSX.Element { + const { t } = useTranslation() + const dispatch = useDispatch() + + const deviceAccessTimeout = useSelector((state: ExtensionState) => state.userSettings.deviceAccessTimeout) + + const hasBiometricUnlockCredential = useShouldShowBiometricUnlock() + const showBiometricUnlockEnrollment = useShouldShowBiometricUnlockEnrollment({ flow: 'settings' }) + const { data: biometricCapabilities } = useQuery(builtInBiometricCapabilitiesQuery({ t })) + + const { + flowState, + oldPassword, + startPasswordReset, + closeModal, + onPasswordModalNext, + onChangePasswordModalNext, + onBiometricAuthModalClose, + } = usePasswordResetFlow() + + return ( + <> + + + + + {(hasBiometricUnlockCredential || showBiometricUnlockEnrollment) && } + { + return { + label: getDeviceAccessTimeoutLabel(t, timeout), + value: timeout, + } + })} + selected={getDeviceAccessTimeoutLabel(t, deviceAccessTimeout)} + title={t('settings.setting.deviceAccessTimeout.title')} + onSelect={(value) => { + const timeout = value as DeviceAccessTimeout + dispatch(setDeviceAccessTimeout(timeout)) + }} + /> + + + + + + {(() => { + switch (flowState) { + case PasswordResetFlowState.EnterCurrentPassword: + return ( + closeModal(PasswordResetFlowState.EnterCurrentPassword)} + shouldReturnPassword + hideBiometrics={true} + /> + ) + case PasswordResetFlowState.EnterNewPassword: + return ( + closeModal(PasswordResetFlowState.EnterNewPassword)} + /> + ) + case PasswordResetFlowState.BiometricAuth: + return biometricCapabilities ? ( + + ) : null // If no biometric capabilities, this state should never be reached + default: + return null + } + })()} + + ) +} diff --git a/apps/extension/src/app/features/settings/HashcashBenchmarkScreen.tsx b/apps/extension/src/app/features/settings/HashcashBenchmarkScreen.tsx new file mode 100644 index 00000000..e66ec5be --- /dev/null +++ b/apps/extension/src/app/features/settings/HashcashBenchmarkScreen.tsx @@ -0,0 +1,753 @@ +/* oxlint-disable max-lines */ +import { createHashcashMultiWorkerChannel, createHashcashWorkerChannel } from '@universe/sessions' +import { findProof as jsFindProof } from '@universe/sessions/src/challenge-solvers/hashcash/core' +import { memo, useCallback, useEffect, useMemo } from 'react' +import { ScreenHeader } from 'src/app/components/layout/ScreenHeader' +import { + type BenchmarkResult, + type Implementation, + type LogEntry, + useHashcashBenchmarkStore, +} from 'src/app/features/settings/stores/hashcashBenchmarkStore' +import { Button, Flex, ScrollView, Text, TouchableArea } from 'ui/src' +import { logger } from 'utilities/src/logger/logger' +import { useShallow } from 'zustand/shallow' + +function formatTime(date: Date): string { + return date.toLocaleTimeString('en-US', { + hour12: false, + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }) +} + +function formatDuration(ms: number): string { + if (ms < 1000) { + return `${ms.toFixed(0)}ms` + } + return `${(ms / 1000).toFixed(2)}s` +} + +const DIFFICULTIES = [1, 2, 3, 4, 5] + +/** + * Calculate max_proof_length based on difficulty. + * For difficulty N (N zero bytes = N*8 bits), expected attempts = 2^(N*8). + * We provide 4x expected attempts for ~98% probability of finding a proof. + */ +const getMaxProofLength = (difficulty: number): number => { + // 2^(difficulty * 8) gives expected attempts + // Multiply by 4 for high probability of success + const expectedAttempts = Math.pow(2, difficulty * 8) + // Cap at a reasonable maximum to prevent infinite searches + return Math.min(expectedAttempts * 4, 20_000_000_000) +} + +/** + * Wait for React to render and the browser to paint. + * Uses double requestAnimationFrame - first frame commits React changes, + * second frame ensures paint has happened. + */ +const waitForPaint = (): Promise => + new Promise((resolve) => { + requestAnimationFrame(() => { + requestAnimationFrame(() => resolve()) + }) + }) + +const IMPLEMENTATIONS: { value: Implementation; label: string }[] = [ + { value: 'all', label: 'All' }, + { value: 'multi-worker', label: 'Multi-Worker' }, + { value: 'worker', label: 'Worker' }, + { value: 'js', label: 'JS' }, +] + +// Memoized difficulty selector button +const DifficultyButton = memo(function DifficultyButton({ + difficulty, + isSelected, + isDisabled, + onPress, +}: { + difficulty: number + isSelected: boolean + isDisabled: boolean + onPress: () => void +}): JSX.Element { + return ( + + + + {difficulty} + + + + ) +}) + +// Memoized implementation selector button +const ImplButton = memo(function ImplButton({ + impl, + isSelected, + isDisabled, + onPress, +}: { + impl: { value: Implementation; label: string } + isSelected: boolean + isDisabled: boolean + onPress: () => void +}): JSX.Element { + return ( + + + + {impl.label} + + + + ) +}) + +// Memoized log entry component +const LogEntryRow = memo(function LogEntryRow({ log, index }: { log: LogEntry; index: number }): JSX.Element { + return ( + + {formatTime(log.timestamp)} - {log.message} + + ) +}) + +// Memoized result card component +const ResultCard = memo(function ResultCard({ + difficulty, + multiWorker, + worker, + js, +}: { + difficulty: string + multiWorker: BenchmarkResult | undefined + worker: BenchmarkResult | undefined + js: BenchmarkResult | undefined +}): JSX.Element { + // Calculate speedups + const multiWorkerVsJs = multiWorker && js ? js.timeMs / multiWorker.timeMs : null + const workerVsJs = worker && js ? js.timeMs / worker.timeMs : null + const multiWorkerVsWorker = multiWorker && worker ? worker.timeMs / multiWorker.timeMs : null + + return ( + + Difficulty {difficulty} + + {multiWorker && ( + + + + Multi-Worker + + + {formatDuration(multiWorker.timeMs)} + + + + + Attempts: + + + {multiWorker.attempts.toLocaleString()} + + + + + Hash Rate: + + + {multiWorker.hashRate.toLocaleString()} h/s + + + + )} + + {worker && ( + + + + Single Worker + + + {formatDuration(worker.timeMs)} + + + + + Attempts: + + + {worker.attempts.toLocaleString()} + + + + + Hash Rate: + + + {worker.hashRate.toLocaleString()} h/s + + + + )} + + {js && ( + + + + JavaScript + + + {formatDuration(js.timeMs)} + + + + + Attempts: + + + {js.attempts.toLocaleString()} + + + + + Hash Rate: + + + {js.hashRate.toLocaleString()} h/s + + + + )} + + {/* Speedup comparisons */} + + {multiWorkerVsJs && ( + + + Multi-Worker is {multiWorkerVsJs.toFixed(1)}x faster than JS + + + )} + {multiWorkerVsWorker && ( + + + Multi-Worker is {multiWorkerVsWorker.toFixed(1)}x faster than Single Worker + + + )} + {workerVsJs && !multiWorkerVsJs && ( + + 1 ? '$statusSuccess' : '$statusCritical'} + textAlign="center" + > + {workerVsJs > 1 + ? `Worker is ${workerVsJs.toFixed(1)}x faster than JS` + : `JS is ${(1 / workerVsJs).toFixed(1)}x faster`} + + + )} + + + ) +}) + +// Progress section - only re-renders when progress changes +const ProgressSection = memo(function ProgressSection(): JSX.Element | null { + const progress = useHashcashBenchmarkStore( + useShallow((state) => ({ + isRunning: state.progress.isRunning, + currentImpl: state.progress.currentImpl, + difficulty: state.progress.difficulty, + startTime: state.progress.startTime, + elapsedMs: state.progress.elapsedMs, + estimatedAttempts: state.progress.estimatedAttempts, + })), + ) + + if (!progress.isRunning) { + return null + } + + return ( + + Current Progress + + + + Running: + + + {progress.currentImpl === 'multi-worker' + ? 'Multi-Worker' + : progress.currentImpl === 'worker' + ? 'Single Worker' + : 'JavaScript'}{' '} + @ Difficulty {progress.difficulty} + + + + {progress.startTime !== null ? ( + <> + + + Elapsed: + + + {formatDuration(progress.elapsedMs)} + + + + + + Est. Attempts: + + + ~{progress.estimatedAttempts.toLocaleString()} + + + + ) : ( + + JS blocks main thread - no progress updates + + )} + + ) +}) + +// Operation log section +const LogSection = memo(function LogSection(): JSX.Element | null { + const logs = useHashcashBenchmarkStore((state) => state.logs) + const clearLogs = useHashcashBenchmarkStore((state) => state.clearLogs) + + if (logs.length === 0) { + return null + } + + return ( + + + Operation Log + + + Clear + + + + + {logs.map((log, index) => ( + + ))} + + ) +}) + +/** + * Benchmark screen for comparing Web Worker vs JavaScript hashcash performance. + * Access via Dev menu in development builds. + */ +export function HashcashBenchmarkScreen(): JSX.Element { + // Individual selectors for minimal re-renders + const selectedDifficulty = useHashcashBenchmarkStore((state) => state.selectedDifficulty) + const selectedImpl = useHashcashBenchmarkStore((state) => state.selectedImpl) + const results = useHashcashBenchmarkStore((state) => state.results) + const isRunning = useHashcashBenchmarkStore((state) => state.progress.isRunning) + const progressStartTime = useHashcashBenchmarkStore((state) => state.progress.startTime) + const measuredHashRate = useHashcashBenchmarkStore((state) => state.measuredHashRate) + + // Actions (stable references) + const setDifficulty = useHashcashBenchmarkStore((state) => state.setDifficulty) + const setImpl = useHashcashBenchmarkStore((state) => state.setImpl) + const addResult = useHashcashBenchmarkStore((state) => state.addResult) + const clearResults = useHashcashBenchmarkStore((state) => state.clearResults) + const addLog = useHashcashBenchmarkStore((state) => state.addLog) + const startBenchmark = useHashcashBenchmarkStore((state) => state.startBenchmark) + const updateProgress = useHashcashBenchmarkStore((state) => state.updateProgress) + const endBenchmark = useHashcashBenchmarkStore((state) => state.endBenchmark) + const cancel = useHashcashBenchmarkStore((state) => state.cancel) + const resetCancel = useHashcashBenchmarkStore((state) => state.resetCancel) + + // Progress timer - only runs for worker benchmarks (JS blocks the thread) + useEffect(() => { + if (isRunning && progressStartTime !== null) { + const interval = setInterval(() => { + const elapsed = performance.now() - progressStartTime + // Use measured hash rate if available, otherwise estimate 500k for worker + const hashRate = measuredHashRate ?? 500000 + const estimated = Math.floor((elapsed / 1000) * hashRate) + updateProgress(elapsed, estimated) + }, 100) + + return (): void => { + clearInterval(interval) + } + } + return undefined + }, [isRunning, progressStartTime, measuredHashRate, updateProgress]) + + const runWorkerBenchmark = useCallback(async (difficulty: number): Promise => { + if (useHashcashBenchmarkStore.getState().isCancelled) { + return null + } + + const challenge = { + difficulty, + subject: 'Benchmark', + algorithm: 'sha256' as const, + nonce: 'dGVzdC1ub25jZS1iZW5jaG1hcms=', + max_proof_length: getMaxProofLength(difficulty), + } + + const channel = createHashcashWorkerChannel({ + getWorker: () => + new Worker( + new URL('@universe/sessions/src/challenge-solvers/hashcash/worker/hashcash.worker.ts', import.meta.url), + { type: 'module' }, + ), + }) + + try { + const startTime = performance.now() + const result = await channel.api.findProof({ challenge }) + const endTime = performance.now() + const timeMs = endTime - startTime + + if (useHashcashBenchmarkStore.getState().isCancelled) { + return null + } + + const attempts = result?.attempts ?? 0 + const hashRate = timeMs > 0 ? Math.round((attempts / timeMs) * 1000) : 0 + + logger.debug( + 'HashcashBenchmark', + 'runWorkerBenchmark', + `Worker result: difficulty=${difficulty}, attempts=${attempts}, timeMs=${timeMs.toFixed(2)}, hashRate=${hashRate}, hasResult=${!!result}`, + ) + + return { + implementation: 'worker', + difficulty, + counter: result?.counter ?? null, + attempts, + timeMs, + hashRate, + } + } finally { + channel.terminate() + } + }, []) + + const runJSBenchmark = useCallback(async (difficulty: number): Promise => { + if (useHashcashBenchmarkStore.getState().isCancelled) { + return null + } + + const challenge = { + difficulty, + subject: 'Benchmark', + algorithm: 'sha256' as const, + nonce: 'dGVzdC1ub25jZS1iZW5jaG1hcms=', + max_proof_length: getMaxProofLength(difficulty), + } + + const startTime = performance.now() + // findProof is async (uses Web Crypto) + const result = await jsFindProof({ challenge }) + const endTime = performance.now() + const timeMs = endTime - startTime + + if (useHashcashBenchmarkStore.getState().isCancelled) { + return null + } + + const attempts = result?.attempts ?? 0 + const hashRate = timeMs > 0 ? Math.round((attempts / timeMs) * 1000) : 0 + + return { + implementation: 'js', + difficulty, + counter: result?.counter ?? null, + attempts, + timeMs, + hashRate, + } + }, []) + + const runMultiWorkerBenchmark = useCallback(async (difficulty: number): Promise => { + if (useHashcashBenchmarkStore.getState().isCancelled) { + return null + } + + const challenge = { + difficulty, + subject: 'Benchmark', + algorithm: 'sha256' as const, + nonce: 'dGVzdC1ub25jZS1iZW5jaG1hcms=', + max_proof_length: getMaxProofLength(difficulty), + } + + const workerCount = typeof navigator !== 'undefined' ? navigator.hardwareConcurrency || 4 : 4 + const channel = createHashcashMultiWorkerChannel({ + workerCount, + getWorker: () => + new Worker( + new URL('@universe/sessions/src/challenge-solvers/hashcash/worker/hashcash.worker.ts', import.meta.url), + { type: 'module' }, + ), + }) + + try { + const startTime = performance.now() + const result = await channel.api.findProof({ challenge }) + const endTime = performance.now() + const timeMs = endTime - startTime + + if (useHashcashBenchmarkStore.getState().isCancelled) { + return null + } + + const attempts = result?.attempts ?? 0 + const hashRate = timeMs > 0 ? Math.round((attempts / timeMs) * 1000) : 0 + + logger.debug( + 'HashcashBenchmark', + 'runMultiWorkerBenchmark', + `Multi-worker result: difficulty=${difficulty}, workers=${workerCount}, attempts=${attempts}, timeMs=${timeMs.toFixed(2)}, hashRate=${hashRate}, hasResult=${!!result}`, + ) + + return { + implementation: 'multi-worker', + difficulty, + counter: result?.counter ?? null, + attempts, + timeMs, + hashRate, + } + } finally { + channel.terminate() + } + }, []) + + const runBenchmark = useCallback(async (): Promise => { + resetCancel() + const difficulty = useHashcashBenchmarkStore.getState().selectedDifficulty + const impl = useHashcashBenchmarkStore.getState().selectedImpl + + addLog(`Benchmark started (difficulty=${difficulty}, impl=${impl})`) + + try { + // Run multi-worker if requested + if ((impl === 'all' || impl === 'multi-worker') && !useHashcashBenchmarkStore.getState().isCancelled) { + const workerCount = typeof navigator !== 'undefined' ? navigator.hardwareConcurrency || 4 : 4 + startBenchmark('multi-worker', difficulty) + addLog(`Running Multi-Worker (${workerCount} workers, difficulty=${difficulty})...`) + + const multiWorkerResult = await runMultiWorkerBenchmark(difficulty) + if (multiWorkerResult && !useHashcashBenchmarkStore.getState().isCancelled) { + addResult(multiWorkerResult) + addLog( + `Multi-Worker completed: ${formatDuration(multiWorkerResult.timeMs)}, ${multiWorkerResult.attempts.toLocaleString()} attempts`, + 'success', + ) + } + } + + // Run single worker if requested + if ((impl === 'all' || impl === 'worker') && !useHashcashBenchmarkStore.getState().isCancelled) { + startBenchmark('worker', difficulty) + addLog(`Running Single Worker (difficulty=${difficulty})...`) + + const workerResult = await runWorkerBenchmark(difficulty) + if (workerResult && !useHashcashBenchmarkStore.getState().isCancelled) { + addResult(workerResult) + addLog( + `Single Worker completed: ${formatDuration(workerResult.timeMs)}, ${workerResult.attempts.toLocaleString()} attempts`, + 'success', + ) + } + } + + // Run JS if requested + if ((impl === 'all' || impl === 'js') && !useHashcashBenchmarkStore.getState().isCancelled) { + startBenchmark('js', difficulty) + addLog(`Running JavaScript (uses Web Crypto)...`) + + // Wait for React to render the progress card before blocking the thread + await waitForPaint() + + const jsResult = await runJSBenchmark(difficulty) + if (jsResult && !useHashcashBenchmarkStore.getState().isCancelled) { + addResult(jsResult) + addLog( + `JavaScript completed: ${formatDuration(jsResult.timeMs)}, ${jsResult.attempts.toLocaleString()} attempts`, + 'success', + ) + } + } + + if (!useHashcashBenchmarkStore.getState().isCancelled) { + addLog('Benchmark completed', 'success') + } + } catch (error) { + addLog(`Benchmark failed: ${error}`, 'error') + logger.error(error, { tags: { file: 'HashcashBenchmarkScreen', function: 'runBenchmark' } }) + } finally { + endBenchmark() + } + }, [ + resetCancel, + addLog, + startBenchmark, + runMultiWorkerBenchmark, + runWorkerBenchmark, + runJSBenchmark, + addResult, + endBenchmark, + ]) + + const handleCancel = useCallback((): void => { + cancel() + addLog('Benchmark cancelled', 'info') + }, [cancel, addLog]) + + const handleClearResults = useCallback((): void => { + clearResults() + addLog('Results cleared', 'info') + }, [clearResults, addLog]) + + // Group results by difficulty for comparison + const groupedResults = useMemo(() => { + return results.reduce( + (acc, result) => { + if (!acc[result.difficulty]) { + acc[result.difficulty] = {} + } + // oxlint-disable-next-line typescript/no-non-null-assertion -- we just initialized this above + acc[result.difficulty]![result.implementation] = result + return acc + }, + {} as Record>>, + ) + }, [results]) + + return ( + + + + + + Compare Multi-Worker, Single Worker, and JavaScript hashcash performance with Web Crypto. + + + {/* Benchmark Controls */} + + Benchmark Controls + + {/* Difficulty Selection */} + + + Difficulty: + + + {DIFFICULTIES.map((d) => ( + setDifficulty(d)} + /> + ))} + + + + {/* Implementation Selection */} + + + Implementation: + + + {IMPLEMENTATIONS.map((impl) => ( + setImpl(impl.value)} + /> + ))} + + + + {/* Action Buttons */} + + + {isRunning && ( + + )} + + + + + {/* Current Progress */} + + + {/* Results */} + {Object.entries(groupedResults).map(([difficulty, impls]) => ( + + ))} + + {/* Empty State */} + {results.length === 0 && !isRunning && ( + + + Select difficulty and implementation, then run a benchmark. + + + )} + + {/* Operation Log */} + + + + ) +} diff --git a/apps/extension/src/app/features/settings/SessionsDebugScreen.tsx b/apps/extension/src/app/features/settings/SessionsDebugScreen.tsx new file mode 100644 index 00000000..97c3b807 --- /dev/null +++ b/apps/extension/src/app/features/settings/SessionsDebugScreen.tsx @@ -0,0 +1,552 @@ +/* oxlint-disable max-lines */ +import { getEntryGatewayUrl, provideSessionService } from '@universe/api' +import { + ChallengeType, + createHashcashSolver, + createHashcashWorkerChannel, + type SessionService, +} from '@universe/sessions' +import { memo, useCallback, useEffect, useRef } from 'react' +import { ScreenHeader } from 'src/app/components/layout/ScreenHeader' +import { type LogEntry, useSessionsDebugStore } from 'src/app/features/settings/stores/sessionsDebugStore' +import { Button, Flex, ScrollView, Text, TouchableArea } from 'ui/src' +import { CopyAlt } from 'ui/src/components/icons' +import { setClipboard } from 'utilities/src/clipboard/clipboard' +import { logger } from 'utilities/src/logger/logger' +import { useShallow } from 'zustand/shallow' + +// Storage keys (must match session storage) +const SESSION_ID_KEY = 'UNISWAP_SESSION_ID' +const DEVICE_ID_KEY = 'UNISWAP_DEVICE_ID' +const UNISWAP_IDENTIFIER_KEY = 'UNISWAP_IDENTIFIER' + +function formatTime(date: Date): string { + return date.toLocaleTimeString('en-US', { + hour12: false, + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }) +} + +function truncateId(id: string | null, length = 16): string { + if (!id) { + return 'None' + } + if (id.length <= length) { + return id + } + return `${id.slice(0, length)}...` +} + +// Memoized log entry component +const LogEntryRow = memo(function LogEntryRow({ log, index }: { log: LogEntry; index: number }): JSX.Element { + return ( + + {formatTime(log.timestamp)} - {log.message} + + ) +}) + +// Operation log section +const LogSection = memo(function LogSection(): JSX.Element | null { + const logs = useSessionsDebugStore((state) => state.logs) + const clearLogs = useSessionsDebugStore((state) => state.clearLogs) + + if (logs.length === 0) { + return null + } + + return ( + + + Operation Log + + + Clear + + + + + {logs.map((log, index) => ( + + ))} + + ) +}) + +// Hashcash progress section +const HashcashProgressSection = memo(function HashcashProgressSection(): JSX.Element | null { + const progress = useSessionsDebugStore( + useShallow((state) => ({ + isRunning: state.hashcashProgress.isRunning, + difficulty: state.hashcashProgress.difficulty, + estimatedAttempts: state.hashcashProgress.estimatedAttempts, + elapsedMs: state.hashcashProgress.elapsedMs, + actualResult: state.hashcashProgress.actualResult, + })), + ) + + if (!progress.isRunning && !progress.actualResult) { + return null + } + + return ( + + Hashcash Progress + + + + Status: + + + {progress.isRunning ? 'Solving...' : 'Complete'} + + + + + + Difficulty: + + + {progress.difficulty} + + + + + + Attempts: + + + {progress.actualResult + ? (progress.actualResult.iterationCount ?? 0).toLocaleString() + : `~${progress.estimatedAttempts.toLocaleString()}`} + + + + + + Time: + + + {progress.actualResult + ? `${(progress.actualResult.durationMs / 1000).toFixed(2)}s` + : `${(progress.elapsedMs / 1000).toFixed(2)}s`} + + + + {progress.actualResult && ( + + + Hash Rate: + + + {((progress.actualResult.iterationCount ?? 0) / (progress.actualResult.durationMs / 1000)).toLocaleString( + undefined, + { maximumFractionDigits: 0 }, + )}{' '} + h/s + + + )} + + ) +}) + +// Current operation display +const CurrentOperationSection = memo(function CurrentOperationSection(): JSX.Element | null { + const currentOperation = useSessionsDebugStore((state) => state.currentOperation) + + if (!currentOperation) { + return null + } + + return ( + + + {currentOperation} + + + ) +}) + +/** + * Sessions Debug Screen for testing session initialization flow. + * Access via Dev menu in development builds. + */ +export function SessionsDebugScreen(): JSX.Element { + // Individual selectors for minimal re-renders + const session = useSessionsDebugStore( + useShallow((state) => ({ + sessionId: state.session.sessionId, + deviceId: state.session.deviceId, + uniswapIdentifier: state.session.uniswapIdentifier, + })), + ) + const challenge = useSessionsDebugStore((state) => state.challenge) + const isLoading = useSessionsDebugStore((state) => state.isLoading) + const hashcashIsRunning = useSessionsDebugStore((state) => state.hashcashProgress.isRunning) + const hashcashStartTime = useSessionsDebugStore((state) => state.hashcashProgress.startTime) + + // Actions (stable references) + const setSession = useSessionsDebugStore((state) => state.setSession) + const setChallenge = useSessionsDebugStore((state) => state.setChallenge) + const startOperation = useSessionsDebugStore((state) => state.startOperation) + const endOperation = useSessionsDebugStore((state) => state.endOperation) + const addLog = useSessionsDebugStore((state) => state.addLog) + const startHashcash = useSessionsDebugStore((state) => state.startHashcash) + const updateHashcashProgress = useSessionsDebugStore((state) => state.updateHashcashProgress) + const completeHashcash = useSessionsDebugStore((state) => state.completeHashcash) + const stopHashcash = useSessionsDebugStore((state) => state.stopHashcash) + const reset = useSessionsDebugStore((state) => state.reset) + + const sessionServiceRef = useRef(null) + + const getSessionService = useCallback((): SessionService => { + if (!sessionServiceRef.current) { + sessionServiceRef.current = provideSessionService({ + getBaseUrl: getEntryGatewayUrl, + getIsSessionServiceEnabled: () => true, // Always enabled for debug + getLogger: () => logger, + }) + } + return sessionServiceRef.current + }, []) + + const refreshSessionState = useCallback(async (): Promise => { + const [sessionId, deviceId, uniswapIdentifier] = await Promise.all([ + localStorage.getItem(SESSION_ID_KEY), + localStorage.getItem(DEVICE_ID_KEY), + localStorage.getItem(UNISWAP_IDENTIFIER_KEY), + ]) + setSession({ + sessionId: sessionId || null, + deviceId: deviceId || null, + uniswapIdentifier: uniswapIdentifier || null, + }) + }, [setSession]) + + // Initial load + useEffect(() => { + const loadInitialState = async (): Promise => { + const [sessionId, deviceId, uniswapIdentifier] = await Promise.all([ + localStorage.getItem(SESSION_ID_KEY), + localStorage.getItem(DEVICE_ID_KEY), + localStorage.getItem(UNISWAP_IDENTIFIER_KEY), + ]) + setSession({ + sessionId: sessionId || null, + deviceId: deviceId || null, + uniswapIdentifier: uniswapIdentifier || null, + }) + } + loadInitialState() + }, [setSession]) + + // Progress timer for hashcash + useEffect(() => { + if (hashcashIsRunning && hashcashStartTime !== null) { + const interval = setInterval(() => { + const elapsed = performance.now() - hashcashStartTime + // Estimate ~500k hashes/sec on web worker + const estimatedAttempts = Math.floor((elapsed / 1000) * 500000) + updateHashcashProgress(elapsed, estimatedAttempts) + }, 100) + + return (): void => { + clearInterval(interval) + } + } + return undefined + }, [hashcashIsRunning, hashcashStartTime, updateHashcashProgress]) + + const clearAllState = useCallback(async (): Promise => { + startOperation('Clearing all state...') + try { + localStorage.removeItem(SESSION_ID_KEY) + localStorage.removeItem(DEVICE_ID_KEY) + localStorage.removeItem(UNISWAP_IDENTIFIER_KEY) + sessionServiceRef.current = null + setChallenge(null) + reset() + addLog('Cleared all session state', 'success') + await refreshSessionState() + } catch (error) { + addLog(`Failed to clear state: ${error}`, 'error') + logger.error(error, { tags: { file: 'SessionsDebugScreen', function: 'clearAllState' } }) + } finally { + endOperation() + } + }, [startOperation, setChallenge, reset, addLog, refreshSessionState, endOperation]) + + const handleInitSession = useCallback(async (): Promise => { + startOperation('Initializing session...') + addLog('Init session started') + try { + const service = getSessionService() + const result = await service.initSession() + addLog(`Session initialized. needChallenge: ${result.needChallenge}`, 'success') + if (result.sessionId) { + addLog(`Session ID: ${truncateId(result.sessionId)}`) + } + await refreshSessionState() + } catch (error) { + addLog(`Init session failed: ${error}`, 'error') + logger.error(error, { tags: { file: 'SessionsDebugScreen', function: 'handleInitSession' } }) + } finally { + endOperation() + } + }, [startOperation, addLog, getSessionService, refreshSessionState, endOperation]) + + const handleRequestChallenge = useCallback(async (): Promise => { + startOperation('Requesting challenge...') + addLog('Request challenge started') + try { + const service = getSessionService() + const challengeResult = await service.requestChallenge() + setChallenge(challengeResult) + const challengeTypeName = ChallengeType[challengeResult.challengeType] || 'Unknown' + addLog(`Challenge received: ${challengeTypeName}`, 'success') + addLog(`Challenge ID: ${truncateId(challengeResult.challengeId)}`) + + if (challengeResult.challengeType === ChallengeType.HASHCASH && challengeResult.extra['challengeData']) { + try { + const challengeData = JSON.parse(challengeResult.extra['challengeData']) + addLog(`Difficulty: ${challengeData.difficulty}`) + } catch { + // Ignore parse errors + } + } + } catch (error) { + addLog(`Request challenge failed: ${error}`, 'error') + logger.error(error, { tags: { file: 'SessionsDebugScreen', function: 'handleRequestChallenge' } }) + } finally { + endOperation() + } + }, [startOperation, addLog, getSessionService, setChallenge, endOperation]) + + const handleSolveChallenge = useCallback(async (): Promise => { + const currentChallenge = useSessionsDebugStore.getState().challenge + if (!currentChallenge) { + addLog('No challenge to solve. Request a challenge first.', 'error') + return + } + + if (currentChallenge.challengeType !== ChallengeType.HASHCASH) { + addLog('Only Hashcash challenges are supported', 'error') + return + } + + startOperation('Solving hashcash challenge...') + addLog('Hashcash solve started') + + // Parse difficulty for progress display + let difficulty = 0 + if (currentChallenge.extra['challengeData']) { + try { + const challengeData = JSON.parse(currentChallenge.extra['challengeData']) + difficulty = challengeData.difficulty || 0 + } catch { + // Use default + } + } + + startHashcash(difficulty) + + try { + const solver = createHashcashSolver({ + performanceTracker: { + now: () => performance.now(), + }, + getWorkerChannel: () => + createHashcashWorkerChannel({ + getWorker: () => + new Worker( + new URL('@universe/sessions/src/challenge-solvers/hashcash/worker/hashcash.worker.ts', import.meta.url), + { type: 'module' }, + ), + }), + onSolveCompleted: (data) => { + completeHashcash(data) + }, + }) + + const solution = await solver.solve({ + challengeId: currentChallenge.challengeId, + challengeType: currentChallenge.challengeType, + extra: currentChallenge.extra, + }) + + addLog(`Challenge solved!`, 'success') + addLog(`Solution: ${truncateId(solution, 32)}`) + + // Verify with backend + startOperation('Verifying session...') + addLog('Verifying session with backend...') + const service = getSessionService() + const verifyResult = await service.verifySession({ + solution, + challengeId: currentChallenge.challengeId, + challengeType: currentChallenge.challengeType, + }) + + if (verifyResult.retry) { + addLog('Verification returned retry=true. May need another challenge.', 'info') + } else { + addLog('Session verified successfully!', 'success') + } + + setChallenge(null) + await refreshSessionState() + } catch (error) { + stopHashcash() + addLog(`Solve challenge failed: ${error}`, 'error') + logger.error(error, { tags: { file: 'SessionsDebugScreen', function: 'handleSolveChallenge' } }) + } finally { + endOperation() + } + }, [ + addLog, + startOperation, + startHashcash, + completeHashcash, + getSessionService, + setChallenge, + refreshSessionState, + stopHashcash, + endOperation, + ]) + + const copyToClipboard = useCallback( + async (value: string | null, label: string): Promise => { + if (!value) { + return + } + await setClipboard(value) + addLog(`Copied ${label} to clipboard`, 'info') + }, + [addLog], + ) + + const hasChallenge = challenge !== null + + return ( + + + + + + Test session initialization flow step by step. + + + {/* Session Status Section */} + + Session Status + + + + + Session ID: + + + + {truncateId(session.sessionId)} + + {session.sessionId && ( + copyToClipboard(session.sessionId, 'Session ID')}> + + + )} + + + + + + Device ID: + + + + {truncateId(session.deviceId)} + + {session.deviceId && ( + copyToClipboard(session.deviceId, 'Device ID')}> + + + )} + + + + + + Uniswap ID: + + + + {truncateId(session.uniswapIdentifier)} + + {session.uniswapIdentifier && ( + copyToClipboard(session.uniswapIdentifier, 'Uniswap ID')}> + + + )} + + + + + + Challenge Pending: + + + {hasChallenge ? 'Yes' : 'No'} + + + + + + {/* Action Buttons */} + + + + + + {/* Step-by-Step Testing */} + + Step-by-Step Testing + + + + + + + + {/* Current Operation */} + + + {/* Hashcash Progress */} + + + {/* Operation Log */} + + + + ) +} diff --git a/apps/extension/src/app/features/settings/SettingsDropdown.tsx b/apps/extension/src/app/features/settings/SettingsDropdown.tsx new file mode 100644 index 00000000..cc3c7c8f --- /dev/null +++ b/apps/extension/src/app/features/settings/SettingsDropdown.tsx @@ -0,0 +1,94 @@ +import { useState } from 'react' +import { Flex, Popover, ScrollView, Text, TouchableArea } from 'ui/src' +import { Check, RotatableChevron } from 'ui/src/components/icons' +import { iconSizes, zIndexes } from 'ui/src/theme' + +type DropdownItem = { + label: string + value: unknown +} + +export type SettingsDropdownProps = { + selected: string + items: DropdownItem[] + disableDropdown?: boolean + onSelect: (item: unknown) => void +} + +const MAX_DROPDOWN_HEIGHT = 220 +const MAX_DROPDOWN_WIDTH = 250 + +export function SettingsDropdown({ selected, items, disableDropdown, onSelect }: SettingsDropdownProps): JSX.Element { + const [isOpen, setIsOpen] = useState(false) + + return ( + + + + + + {selected} + + + + + + + + + + {items.map((item, index) => ( + { + onSelect(item.value) + setIsOpen(false) + }} + > + + + + {item.label} + + + {selected === item.label ? ( + + ) : ( + + )} + + + ))} + + + + + + + ) +} diff --git a/apps/extension/src/app/features/settings/SettingsItemWithDropdown.tsx b/apps/extension/src/app/features/settings/SettingsItemWithDropdown.tsx new file mode 100644 index 00000000..a0ebe3cb --- /dev/null +++ b/apps/extension/src/app/features/settings/SettingsItemWithDropdown.tsx @@ -0,0 +1,32 @@ +import { SCREEN_ITEM_HORIZONTAL_PAD } from 'src/app/constants' +import { SettingsDropdown, SettingsDropdownProps } from 'src/app/features/settings/SettingsDropdown' +import { Flex, GeneratedIcon, Text, TouchableArea } from 'ui/src' + +type SettingsItemWithDropdownProps = { + Icon: GeneratedIcon + title: string + disableDropdown?: boolean + onDisabledDropdownPress?: () => void +} & SettingsDropdownProps + +export function SettingsItemWithDropdown(props: SettingsItemWithDropdownProps): JSX.Element { + const { title, disableDropdown, Icon, onDisabledDropdownPress, ...dropdownProps } = props + + const dropdown = + + return ( + + + + + {title} + + + {disableDropdown ? ( + onDisabledDropdownPress?.()}>{dropdown} + ) : ( + dropdown + )} + + ) +} diff --git a/apps/extension/src/app/features/settings/SettingsManageConnectionsScreen/SettingsManageConnectionsScreen.tsx b/apps/extension/src/app/features/settings/SettingsManageConnectionsScreen/SettingsManageConnectionsScreen.tsx new file mode 100644 index 00000000..74980be6 --- /dev/null +++ b/apps/extension/src/app/features/settings/SettingsManageConnectionsScreen/SettingsManageConnectionsScreen.tsx @@ -0,0 +1,177 @@ +import { useCallback, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useDispatch } from 'react-redux' +import { useSearchParams } from 'react-router' +import { ScreenHeader } from 'src/app/components/layout/ScreenHeader' +import { removeAllDappConnectionsForAccount, removeDappConnection } from 'src/app/features/dapp/actions' +import { useAllDappConnectionsForAccount } from 'src/app/features/dapp/hooks' +import { dappStore } from 'src/app/features/dapp/store' +import { NoDappConnections } from 'src/app/features/settings/SettingsManageConnectionsScreen/internal/NoDappConnections' +import { Flex, Text, TouchableArea, UniversalImage } from 'ui/src' +import { MinusCircle } from 'ui/src/components/icons' +import { borderRadii, breakpoints, fonts, gap, iconSizes } from 'ui/src/theme' +import { DappIconPlaceholder } from 'uniswap/src/components/dapps/DappIconPlaceholder' +import { pushNotification } from 'uniswap/src/features/notifications/slice/slice' +import { AppNotificationType } from 'uniswap/src/features/notifications/slice/types' +import { ExtensionEventName } from 'uniswap/src/features/telemetry/constants' +import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import Trace from 'uniswap/src/features/telemetry/Trace' +import { ExtensionScreens } from 'uniswap/src/types/screens/extension' +import { isEVMAddress } from 'utilities/src/addresses/evm/evm' +import { extractNameFromUrl } from 'utilities/src/format/extractNameFromUrl' +import { extractUrlHost } from 'utilities/src/format/urls' +import { DappEllipsisDropdown } from 'wallet/src/components/settings/DappEllipsisDropdown/DappEllipsisDropdown' +import { useActiveAccountWithThrow } from 'wallet/src/features/wallet/hooks' + +const MIN_SCREEN_WIDTH = breakpoints.xxs +const HORIZONTAL_SPACING = 12 +// when sidebar is at the minimum width (360px), this will allow 2 cards to cleanly fit per row +const TILE_WIDTH = (MIN_SCREEN_WIDTH - 3 * HORIZONTAL_SPACING) / 2 + +const titleVariant: keyof typeof fonts = 'body3' +const subtitleVariant: keyof typeof fonts = 'body4' +const textGap: number = gap.gap4 +const textAreaHeight = fonts[titleVariant].lineHeight + fonts[subtitleVariant].lineHeight + textGap + +export function SettingsManageConnectionsScreen(): JSX.Element { + const dispatch = useDispatch() + const { t } = useTranslation() + const [searchParams] = useSearchParams() + const activeAccount = useActiveAccountWithThrow() + + // Capture address on mount to prevent screen flash when URL clears during navigation exit + const [initialAddress] = useState(() => { + const param = searchParams.get('address') + return param && isEVMAddress(param) ? param : null + }) + + const targetAddress = initialAddress ?? activeAccount.address + const targetAccount = initialAddress ? { ...activeAccount, address: initialAddress } : activeAccount + + const dappUrls = useAllDappConnectionsForAccount(targetAddress) + + const getHandleRemoveConnection = useCallback( + (dappUrl: string) => async () => { + const dappInfo = dappStore.getDappInfo(dappUrl) + + dispatch( + pushNotification({ + type: AppNotificationType.DappDisconnected, + dappIconUrl: dappStore.getDappInfo(dappUrl)?.iconUrl, + }), + ) + sendAnalyticsEvent(ExtensionEventName.DappDisconnect, { + dappUrl, + chainId: dappInfo?.lastChainId, + activeConnectedAddress: dappInfo?.activeConnectedAddress, + connectedAddresses: dappInfo?.connectedAccounts.map((account) => account.address) ?? [], + }) + await removeDappConnection(dappUrl, targetAccount) + }, + // oxlint-disable-next-line react/exhaustive-deps -- biome-parity: oxlint is stricter here + [dispatch, targetAccount], + ) + + const DappTiles = useMemo( + () => + dappUrls.map((dappUrl) => { + const dappInfo = dappStore.getDappInfo(dappUrl) + const name = extractNameFromUrl(dappUrl) + + const hostName = extractUrlHost(dappUrl) + const title = dappInfo?.displayName || hostName + + const DeleteDappButton = ( + + + + ) + + const DappIcon = ( + } + size={{ + height: iconSizes.icon32, + width: iconSizes.icon32, + }} + style={{ image: { borderRadius: borderRadii.rounded8 } }} + uri={dappInfo?.iconUrl} + /> + ) + + /** + * TEXT AREA; TITLE/SUBTITLE + * + * we only need to set the text area height because it is the only section with optional fields + */ + const Title = ( + + + {title} + + {hostName !== title && ( + + {hostName} + + )} + + ) + + return ( + + {DeleteDappButton} + {DappIcon} + {Title} + + ) + }), + [dappUrls, getHandleRemoveConnection], + ) + + const hasConnections = Boolean(DappTiles.length) + + return ( + + + ) : undefined + } + title={t('settings.setting.wallet.connections.title')} + /> + + {hasConnections ? DappTiles : } + + + ) +} diff --git a/apps/extension/src/app/features/settings/SettingsManageConnectionsScreen/internal/NoDappConnections.tsx b/apps/extension/src/app/features/settings/SettingsManageConnectionsScreen/internal/NoDappConnections.tsx new file mode 100644 index 00000000..a031392f --- /dev/null +++ b/apps/extension/src/app/features/settings/SettingsManageConnectionsScreen/internal/NoDappConnections.tsx @@ -0,0 +1,27 @@ +import { useTranslation } from 'react-i18next' +import { Link } from 'react-router' +import { Flex, Text } from 'ui/src' +import { uniswapUrls } from 'uniswap/src/constants/urls' + +export function NoDappConnections(): JSX.Element { + const { t } = useTranslation() + return ( + + + {t('walletConnect.dapps.manage.empty.title')} + + + {t('settings.setting.connections.noConnectionsDescription')} + + + + {t('extension.connection.popup.trouble')} + + + + ) +} diff --git a/apps/extension/src/app/features/settings/SettingsRecoveryPhraseScreen/RemoveRecoveryPhraseVerify.tsx b/apps/extension/src/app/features/settings/SettingsRecoveryPhraseScreen/RemoveRecoveryPhraseVerify.tsx new file mode 100644 index 00000000..f60a84d6 --- /dev/null +++ b/apps/extension/src/app/features/settings/SettingsRecoveryPhraseScreen/RemoveRecoveryPhraseVerify.tsx @@ -0,0 +1,143 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useDispatch } from 'react-redux' +import { ScreenHeader } from 'src/app/components/layout/ScreenHeader' +import { PasswordInput } from 'src/app/components/PasswordInput' +import { removeAllDappConnectionsFromExtension } from 'src/app/features/dapp/actions' +import { SettingsRecoveryPhrase } from 'src/app/features/settings/SettingsRecoveryPhraseScreen/SettingsRecoveryPhrase' +import { focusOrCreateOnboardingTab } from 'src/app/navigation/focusOrCreateOnboardingTab' +import { Flex, inputStyles, LabeledCheckbox, Text } from 'ui/src' +import { TrashFilled } from 'ui/src/components/icons' +import { setIsTestnetModeEnabled } from 'uniswap/src/features/settings/slice' +import { WalletEventName } from 'uniswap/src/features/telemetry/constants' +import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { logger } from 'utilities/src/logger/logger' +import { EditAccountAction, editAccountActions } from 'wallet/src/features/wallet/accounts/editAccountSaga' +import { useSignerAccounts } from 'wallet/src/features/wallet/hooks' +import { Keyring } from 'wallet/src/features/wallet/Keyring/Keyring' + +export function RemoveRecoveryPhraseVerify(): JSX.Element { + const { t } = useTranslation() + const dispatch = useDispatch() + + const [password, setPassword] = useState('') + const [showPasswordError, setShowPasswordError] = useState(false) + const [hideInput, setHideInput] = useState(true) + const [checked, setChecked] = useState(false) + + const onChangeText = (text: string): void => { + setPassword(text) + setShowPasswordError(false) + } + + const onCheckPressed = (): void => { + setChecked(!checked) + } + + const associatedAccounts = useSignerAccounts() + + const onRemove = async (): Promise => { + const accountsToRemove = associatedAccounts + const mnemonicId = accountsToRemove[0]?.mnemonicId + const accAddress = accountsToRemove[0]?.address + + if (!accAddress) { + logger.error(new Error('No accounts to remove'), { + tags: { file: 'RemoveRecoveryPhraseVerify', function: 'onRemove' }, + }) + return + } + + if (!mnemonicId) { + logger.error(new Error('mnemonicId does not exist'), { + tags: { file: 'RemoveRecoveryPhraseVerify', function: 'onRemove' }, + }) + return + } + + await Keyring.removeMnemonic(mnemonicId) + await Keyring.removePassword() + + await removeAllDappConnectionsFromExtension() + await dispatch(setIsTestnetModeEnabled(false)) + + await dispatch( + editAccountActions.trigger({ + type: EditAccountAction.Remove, + accounts: accountsToRemove, + }), + ) + + sendAnalyticsEvent(WalletEventName.WalletRemoved, { + wallets_removed: accountsToRemove.map((a) => a.address), + }) + + await focusOrCreateOnboardingTab() + window.close() + } + + const checkPassword = async (): Promise => { + if (!checked) { + return + } + const success = await Keyring.checkPassword(password) + if (!success) { + setShowPasswordError(true) + return + } + await onRemove() + } + + const removeButtonEnabled = checked && !showPasswordError && password.length > 0 + + return ( + + + } + nextButtonEnabled={removeButtonEnabled} + nextButtonText={t('setting.recoveryPhrase.remove')} + nextButtonVariant="critical" + subtitle={t('setting.recoveryPhrase.remove.subtitle')} + title={t('setting.recoveryPhrase.remove.title')} + onNextPressed={checkPassword} + > + + + + + {showPasswordError ? t('extension.passwordPrompt.error.wrongPassword') : ''} + + + + + + {t('setting.recoveryPhrase.remove.confirm.title')} + + + {t('setting.recoveryPhrase.remove.confirm.subtitle')} + + + } + onCheckPressed={onCheckPressed} + /> + + + + + ) +} diff --git a/apps/extension/src/app/features/settings/SettingsRecoveryPhraseScreen/RemoveRecoveryPhraseWallets.tsx b/apps/extension/src/app/features/settings/SettingsRecoveryPhraseScreen/RemoveRecoveryPhraseWallets.tsx new file mode 100644 index 00000000..76bddd0d --- /dev/null +++ b/apps/extension/src/app/features/settings/SettingsRecoveryPhraseScreen/RemoveRecoveryPhraseWallets.tsx @@ -0,0 +1,114 @@ +import { useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { ScreenHeader } from 'src/app/components/layout/ScreenHeader' +import { SettingsRecoveryPhrase } from 'src/app/features/settings/SettingsRecoveryPhraseScreen/SettingsRecoveryPhrase' +import { AppRoutes, RemoveRecoveryPhraseRoutes, SettingsRoutes } from 'src/app/navigation/constants' +import { useExtensionNavigation } from 'src/app/navigation/utils' +import { Flex, ScrollView, Text } from 'ui/src' +import { AlertTriangleFilled } from 'ui/src/components/icons' +import { iconSizes } from 'ui/src/theme' +import { AddressDisplay } from 'uniswap/src/components/accounts/AddressDisplay' +import { useLocalizationContext } from 'uniswap/src/features/language/LocalizationContext' +import { NumberType } from 'utilities/src/format/types' +import { useAccountListData } from 'wallet/src/features/accounts/useAccountListData' +import { Account } from 'wallet/src/features/wallet/accounts/types' +import { useSignerAccounts } from 'wallet/src/features/wallet/hooks' + +export function RemoveRecoveryPhraseWallets(): JSX.Element { + const { t } = useTranslation() + const { navigateTo } = useExtensionNavigation() + + const accounts = useSignerAccounts() + + return ( + + + } + nextButtonEnabled={true} + nextButtonText={t('common.button.continue')} + nextButtonEmphasis="secondary" + subtitle={t('setting.recoveryPhrase.remove.initial.subtitle')} + title={t('setting.recoveryPhrase.remove.initial.title')} + onNextPressed={(): void => { + navigateTo( + `/${AppRoutes.Settings}/${SettingsRoutes.RemoveRecoveryPhrase}/${RemoveRecoveryPhraseRoutes.Verify}`, + ) + }} + > + + + + ) +} + +// TODO(@thomasthachil): merge this with mobile AccountList +function AssociatedAccountsList({ accounts }: { accounts: Account[] }): JSX.Element { + const addresses = useMemo(() => accounts.map((account) => account.address), [accounts]) + const { data, loading } = useAccountListData({ + addresses, + notifyOnNetworkStatusChange: true, + }) + + const sortedAddressesByBalance = addresses + .map((address) => { + const wallet = data?.portfolios?.find((portfolio) => portfolio?.ownerAddress === address) + return { address, balance: wallet?.tokensTotalDenominatedValue?.value } + }) + .sort((a, b) => (b.balance ?? 0) - (a.balance ?? 0)) + + return ( + + + {sortedAddressesByBalance.map(({ address, balance }, index) => ( + + ))} + + + ) +} + +function AssociatedAccountRow({ + index, + address, + balance, + totalCount, + loading, +}: { + index: number + address: string + balance: number | undefined + totalCount: number + loading: boolean +}): JSX.Element { + const { convertFiatAmountFormatted } = useLocalizationContext() + const balanceFormatted = convertFiatAmountFormatted(balance, NumberType.PortfolioBalance) + + return ( + + + + + + + {balanceFormatted} + + + + ) +} diff --git a/apps/extension/src/app/features/settings/SettingsRecoveryPhraseScreen/SeedPhraseDisplay.tsx b/apps/extension/src/app/features/settings/SettingsRecoveryPhraseScreen/SeedPhraseDisplay.tsx new file mode 100644 index 00000000..2488bea6 --- /dev/null +++ b/apps/extension/src/app/features/settings/SettingsRecoveryPhraseScreen/SeedPhraseDisplay.tsx @@ -0,0 +1,134 @@ +import { useQuery } from '@tanstack/react-query' +import { useEffect, useState } from 'react' +import { LayoutChangeEvent } from 'react-native' +import { CopyButton } from 'src/app/components/buttons/CopyButton' +import { Flex, Separator, Text } from 'ui/src' +import { spacing } from 'ui/src/theme' +import { WalletEventName } from 'uniswap/src/features/telemetry/constants' +import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { setClipboard } from 'utilities/src/clipboard/clipboard' +import { logger } from 'utilities/src/logger/logger' +import { mnemonicUnlockedQuery } from 'wallet/src/features/wallet/Keyring/queries' + +function SeedPhraseColumnGroup({ recoveryPhraseArray }: { recoveryPhraseArray: string[] }): JSX.Element { + const [largestIndexWidth, setLargestIndexWidth] = useState(0) + + const halfLength = Math.ceil(recoveryPhraseArray.length / 2) + const firstHalfWords = recoveryPhraseArray.slice(0, halfLength) + const secondHalfWords = recoveryPhraseArray.slice(halfLength) + + const onIndexLayout = (event: LayoutChangeEvent): void => { + const { width } = event.nativeEvent.layout + if (width > largestIndexWidth) { + setLargestIndexWidth(width) + } + } + + return ( + + + + + + ) +} + +function SeedPhraseColumn({ + words, + indexOffset, + largestIndexWidth, + onIndexLayout, +}: { + words: string[] + indexOffset: number + largestIndexWidth: number + onIndexLayout: (event: LayoutChangeEvent) => void +}): JSX.Element { + return ( + + {words.map((word, index) => ( + + ))} + + ) +} + +function SeedPhraseWord({ + index, + word, + indexMinWidth, + onIndexLayout, +}: { + index: number + word: string + indexMinWidth: number + onIndexLayout: (event: LayoutChangeEvent) => void +}): JSX.Element { + return ( + + + {index} + + + {word} + + + ) +} + +export function SeedPhraseDisplay({ mnemonicId }: { mnemonicId: string }): JSX.Element { + const placeholderWordArrayLength = 12 + + const { data: recoveryPhraseString } = useQuery(mnemonicUnlockedQuery(mnemonicId)) + const recoveryPhraseArray = recoveryPhraseString?.split(' ') ?? Array(placeholderWordArrayLength).fill('') + + const onCopyPress = async (): Promise => { + try { + if (recoveryPhraseString) { + await setClipboard(recoveryPhraseString) + } + } catch (error) { + logger.error(error, { + tags: { file: 'SeedPhraseDisplay', function: 'onCopyPress' }, + }) + } + } + + useEffect(() => { + sendAnalyticsEvent(WalletEventName.ViewRecoveryPhrase) + }, []) + + return ( + + + + + + + + + ) +} diff --git a/apps/extension/src/app/features/settings/SettingsRecoveryPhraseScreen/SettingsRecoveryPhrase.tsx b/apps/extension/src/app/features/settings/SettingsRecoveryPhraseScreen/SettingsRecoveryPhrase.tsx new file mode 100644 index 00000000..304ae34d --- /dev/null +++ b/apps/extension/src/app/features/settings/SettingsRecoveryPhraseScreen/SettingsRecoveryPhrase.tsx @@ -0,0 +1,60 @@ +import { ComponentProps } from 'react' +import { Button, ButtonEmphasis, ButtonVariant, Flex, Square, Text } from 'ui/src' + +type SettingsRecoveryPhraseProps = { + title: string + titleColor?: ComponentProps['color'] + subtitle: string + icon: React.ReactNode + iconBackgroundColor?: ComponentProps['backgroundColor'] + nextButtonEnabled: boolean + nextButtonText: string + nextButtonVariant?: ButtonVariant + nextButtonEmphasis?: ButtonEmphasis + onNextPressed: () => void + children: React.ReactNode +} + +export function SettingsRecoveryPhrase({ + title, + titleColor = '$statusCritical', + subtitle, + icon, + iconBackgroundColor = '$statusCritical2', + nextButtonEnabled, + nextButtonText, + nextButtonVariant, + nextButtonEmphasis, + onNextPressed, + children, +}: SettingsRecoveryPhraseProps): JSX.Element { + return ( + + + + {icon} + + + + {title} + + + {subtitle} + + + + {children} + + + + + ) +} diff --git a/apps/extension/src/app/features/settings/SettingsRecoveryPhraseScreen/ViewRecoveryPhraseScreen.tsx b/apps/extension/src/app/features/settings/SettingsRecoveryPhraseScreen/ViewRecoveryPhraseScreen.tsx new file mode 100644 index 00000000..e99a1181 --- /dev/null +++ b/apps/extension/src/app/features/settings/SettingsRecoveryPhraseScreen/ViewRecoveryPhraseScreen.tsx @@ -0,0 +1,138 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { ScreenHeader } from 'src/app/components/layout/ScreenHeader' +import { EnterPasswordModal } from 'src/app/features/settings/password/EnterPasswordModal' +import { SeedPhraseDisplay } from 'src/app/features/settings/SettingsRecoveryPhraseScreen/SeedPhraseDisplay' +import { SettingsRecoveryPhrase } from 'src/app/features/settings/SettingsRecoveryPhraseScreen/SettingsRecoveryPhrase' +import { AppRoutes, RemoveRecoveryPhraseRoutes, SettingsRoutes } from 'src/app/navigation/constants' +import { navigate } from 'src/app/navigation/state' +import { Button, Flex, Text } from 'ui/src' +import { AlertTriangleFilled, Eye, Key, Laptop } from 'ui/src/components/icons' +import { useSignerAccounts } from 'wallet/src/features/wallet/hooks' + +const enum ViewStep { + Warning = 0, + Password = 1, + Reveal = 2, +} + +/** + * This screen is rendered both as a settings route and as a modal in the force upgrade prompt. + * When making UI changes, please verify both versions look good. + */ +export function ViewRecoveryPhraseScreen({ + mnemonicId: mnemonicIdProp, + showRemoveButton = true, + onBackClick, +}: { + mnemonicId?: string + showRemoveButton?: boolean + onBackClick?: () => void +}): JSX.Element { + const { t } = useTranslation() + + const [viewStep, setViewStep] = useState(ViewStep.Warning) + + const mnemonicAccounts = useSignerAccounts() + const mnemonicAccount = mnemonicAccounts[0] + + const mnemonicId = mnemonicIdProp ?? mnemonicAccount?.mnemonicId + + if (!mnemonicId) { + throw new Error('Invalid render of `ViewRecoveryPhraseScreen` without `mnemonicId`') + } + + const showPasswordModal = (): void => { + setViewStep(ViewStep.Password) + } + + return ( + + + + {viewStep !== ViewStep.Reveal ? ( + } + nextButtonEnabled={true} + nextButtonText={t('common.button.continue')} + nextButtonEmphasis="secondary" + subtitle={t('setting.recoveryPhrase.view.warning.message1')} + title={t('setting.recoveryPhrase.view.warning.title')} + onNextPressed={showPasswordModal} + > + setViewStep(ViewStep.Warning)} + onNext={() => setViewStep(ViewStep.Reveal)} + /> + + + + + + + + {t('setting.recoveryPhrase.view.warning.message2')} + + + + + + + + + {t('setting.recoveryPhrase.view.warning.message3')} + + + + + + + + + {t('setting.recoveryPhrase.view.warning.message4')} + + + + + ) : ( + + + + + + {t('setting.recoveryPhrase.warning.view.message')} + + + + {showRemoveButton && ( + + + + + + )} + + )} + + ) +} diff --git a/apps/extension/src/app/features/settings/SettingsScreen.tsx b/apps/extension/src/app/features/settings/SettingsScreen.tsx new file mode 100644 index 00000000..25565937 --- /dev/null +++ b/apps/extension/src/app/features/settings/SettingsScreen.tsx @@ -0,0 +1,312 @@ +import { FeatureFlags, useFeatureFlag } from '@universe/gating' +import { useCallback, useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useDispatch } from 'react-redux' +import { useLocation } from 'react-router' +import { ScreenHeader } from 'src/app/components/layout/ScreenHeader' +import { SettingsItem } from 'src/app/features/settings/components/SettingsItem' +import { SettingsSection } from 'src/app/features/settings/components/SettingsSection' +import { SettingsToggleRow } from 'src/app/features/settings/components/SettingsToggleRow' +import { SettingsItemWithDropdown } from 'src/app/features/settings/SettingsItemWithDropdown' +import { ThemeToggleWithLabel } from 'src/app/features/settings/ThemeToggle' +import { AppRoutes, SettingsRoutes } from 'src/app/navigation/constants' +import { useExtensionNavigation } from 'src/app/navigation/utils' +import { getIsDefaultProviderFromStorage, setIsDefaultProviderToStorage } from 'src/app/utils/provider' +import { Button, Flex, ScrollView, Text } from 'ui/src' +import { + ArrowUpRight, + Chart, + Coins, + FileListLock, + HelpCenter, + Language as LanguageIcon, + LineChartDots, + Lock, + Passkey, + Settings, + Sliders, + Wrench, +} from 'ui/src/components/icons' +import { uniswapUrls } from 'uniswap/src/constants/urls' +import { resetUniswapBehaviorHistory } from 'uniswap/src/features/behaviorHistory/slice' +import { useEnabledChains } from 'uniswap/src/features/chains/hooks/useEnabledChains' +import { FiatCurrency, ORDERED_CURRENCIES } from 'uniswap/src/features/fiatCurrency/constants' +import { getFiatCurrencyName, useAppFiatCurrencyInfo } from 'uniswap/src/features/fiatCurrency/hooks' +import { Language, WALLET_SUPPORTED_LANGUAGES } from 'uniswap/src/features/language/constants' +import { getLanguageInfo, useCurrentLanguageInfo } from 'uniswap/src/features/language/hooks' +import { PasskeyManagementModal } from 'uniswap/src/features/passkey/PasskeyManagementModal' +import { + setCurrentFiatCurrency, + setCurrentLanguage, + setIsTestnetModeEnabled, +} from 'uniswap/src/features/settings/slice' +import { WalletEventName } from 'uniswap/src/features/telemetry/constants' +import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import Trace from 'uniswap/src/features/telemetry/Trace' +import { TestnetModeModal } from 'uniswap/src/features/testnets/TestnetModeModal' +import { changeLanguage } from 'uniswap/src/i18n' +import { TestID } from 'uniswap/src/test/fixtures/testIDs' +import { ExtensionScreens } from 'uniswap/src/types/screens/extension' +import { isDevEnv } from 'utilities/src/environment/env' +import { logger } from 'utilities/src/logger/logger' +import { PermissionsModal } from 'wallet/src/components/settings/permissions/PermissionsModal' +import { PortfolioBalanceModal } from 'wallet/src/components/settings/portfolioBalance/PortfolioBalanceModal' +import { SmartWalletAdvancedSettingsModal } from 'wallet/src/components/smartWallet/modals/SmartWalletAdvancedSettingsModal' +import { authActions } from 'wallet/src/features/auth/saga' +import { AuthActionType } from 'wallet/src/features/auth/types' +import { resetWalletBehaviorHistory } from 'wallet/src/features/behaviorHistory/slice' +import { BackupType } from 'wallet/src/features/wallet/accounts/types' +import { hasBackup } from 'wallet/src/features/wallet/accounts/utils' +import { useSignerAccounts } from 'wallet/src/features/wallet/hooks' + +const manifestVersion = chrome.runtime.getManifest().version + +export function SettingsScreen(): JSX.Element { + const { t } = useTranslation() + const dispatch = useDispatch() + const location = useLocation() + const { navigateTo, navigateBack } = useExtensionNavigation() + const currentLanguageInfo = useCurrentLanguageInfo() + const appFiatCurrencyInfo = useAppFiatCurrencyInfo() + + const isSmartWalletEnabled = useFeatureFlag(FeatureFlags.SmartWalletSettings) + + const signerAccount = useSignerAccounts()[0] + const hasPasskeyBackup = hasBackup(BackupType.Passkey, signerAccount) + + const [isPortfolioBalanceModalOpen, setIsPortfolioBalanceModalOpen] = useState(false) + const [isTestnetModalOpen, setIsTestnetModalOpen] = useState(false) + const [isAdvancedModalOpen, setIsAdvancedModalOpen] = useState(false) + const [isPermissionsModalOpen, setIsPermissionsModalOpen] = useState(false) + const [isPasskeyModalOpen, setIsPasskeyModalOpen] = useState(false) + const [isDefaultProvider, setIsDefaultProvider] = useState(true) + + // Auto-open advanced settings modal if navigating with openAdvancedSettings state + useEffect(() => { + const state = location.state as { openAdvancedSettings?: boolean } | undefined + if (state?.openAdvancedSettings) { + setIsAdvancedModalOpen(true) + } + }, [location.state]) + + const onPressLockWallet = async (): Promise => { + navigateBack() + await dispatch(authActions.trigger({ type: AuthActionType.Lock })) + } + + // TODO(WALL-4908): consider wrapping handlers in useCallback + const { isTestnetModeEnabled } = useEnabledChains() + const handleTestnetModeToggle = async (isChecked: boolean): Promise => { + const fireAnalytic = (): void => { + sendAnalyticsEvent(WalletEventName.TestnetModeToggled, { + enabled: isChecked, + location: 'settings', + }) + } + + // trigger before toggling on (ie disabling analytics) + if (isChecked) { + // doesn't fire on time without await and i have no idea why + await fireAnalytic() + } + + dispatch(setIsTestnetModeEnabled(isChecked)) + setIsTestnetModalOpen(isChecked) + + // trigger after toggling off (ie enabling analytics) + if (!isChecked) { + // updateState() + fireAnalytic() + } + } + const handleTestnetModalClose = useCallback(() => setIsTestnetModalOpen(false), []) + + const handleAdvancedModalClose = useCallback(() => setIsAdvancedModalOpen(false), []) + + const handleSmartWalletPress = useCallback(() => { + navigateTo(`/${AppRoutes.Settings}/${SettingsRoutes.SmartWallet}`) + setIsAdvancedModalOpen(false) + }, [navigateTo]) + + const handleStoragePress = useCallback(() => { + navigateTo(`/${AppRoutes.Settings}/${SettingsRoutes.Storage}`) + setIsAdvancedModalOpen(false) + }, [navigateTo]) + + useEffect(() => { + getIsDefaultProviderFromStorage() + .then((newIsDefaultProvider) => setIsDefaultProvider(newIsDefaultProvider)) + .catch((e) => + logger.error(e, { + tags: { file: 'PermissionsModal', function: 'fetchIsDefaultProvider' }, + }), + ) + }, []) + + const handleDefaultBrowserToggle = async (isChecked: boolean): Promise => { + setIsDefaultProvider(!!isChecked) + await setIsDefaultProviderToStorage(!!isChecked) + } + + return ( + + {isPortfolioBalanceModalOpen ? ( + setIsPortfolioBalanceModalOpen(false)} + /> + ) : undefined} + {isPermissionsModalOpen ? ( + setIsPermissionsModalOpen(false)} + /> + ) : undefined} + + + {hasPasskeyBackup && ( + setIsPasskeyModalOpen(false)} + address={signerAccount?.address} + /> + )} + + + + + <> + {isDevEnv() && ( + navigateTo(`/${AppRoutes.Settings}/${SettingsRoutes.DevMenu}`)} + /> + )} + + <> + {isDevEnv() && ( + { + dispatch(resetWalletBehaviorHistory()) + dispatch(resetUniswapBehaviorHistory()) + }} + /> + )} + + + { + return { + label: getFiatCurrencyName(t, currency).shortName, + value: currency, + } + })} + selected={appFiatCurrencyInfo.code} + title={t('settings.setting.currency.title')} + onSelect={(value) => { + const currency = value as FiatCurrency + dispatch(setCurrentFiatCurrency(currency)) + }} + /> + { + return { value: language, label: getLanguageInfo(t, language).displayName } + })} + selected={currentLanguageInfo.displayName} + title={t('settings.setting.language.title')} + onSelect={async (value) => { + const language = value as Language + await changeLanguage(getLanguageInfo(t, language).locale) + dispatch(setCurrentLanguage(language)) + }} + /> + setIsPortfolioBalanceModalOpen(true)} + /> + {isSmartWalletEnabled ? ( + setIsAdvancedModalOpen(true)} + /> + ) : ( + + )} + + + + navigateTo(`/${AppRoutes.Settings}/${SettingsRoutes.DeviceAccess}`)} + /> + navigateTo(`/${AppRoutes.Settings}/${SettingsRoutes.ViewRecoveryPhrase}`)} + /> + <> + {hasPasskeyBackup && ( + setIsPasskeyModalOpen(true)} + /> + )} + + setIsPermissionsModalOpen(true)} + /> + + + + + + {`Version ${manifestVersion}`} + + + + + + + + + ) +} diff --git a/apps/extension/src/app/features/settings/SettingsScreenWrapper.tsx b/apps/extension/src/app/features/settings/SettingsScreenWrapper.tsx new file mode 100644 index 00000000..046f436a --- /dev/null +++ b/apps/extension/src/app/features/settings/SettingsScreenWrapper.tsx @@ -0,0 +1,13 @@ +import { Outlet } from 'react-router' +import { Flex } from 'ui/src' + +/** + * SettingsScreenWrapper is a wrapper used by all settings screens. + */ +export function SettingsScreenWrapper(): JSX.Element { + return ( + + + + ) +} diff --git a/apps/extension/src/app/features/settings/SettingsStorageScreen.tsx b/apps/extension/src/app/features/settings/SettingsStorageScreen.tsx new file mode 100644 index 00000000..7c7311ad --- /dev/null +++ b/apps/extension/src/app/features/settings/SettingsStorageScreen.tsx @@ -0,0 +1,28 @@ +import { useTranslation } from 'react-i18next' +import { ScreenHeader } from 'src/app/components/layout/ScreenHeader' +import { useAppStateResetter } from 'src/store/appStateResetter' +import { Flex } from 'ui/src' +import { StorageHelpIcon, StorageSettingsContent } from 'uniswap/src/features/settings/storage/StorageSettingsContent' +import { useEvent } from 'utilities/src/react/hooks' + +export function SettingsStorageScreen(): JSX.Element { + const { t } = useTranslation() + + const appStateResetter = useAppStateResetter() + const onPressClearAccountHistory = useEvent(() => appStateResetter.resetAccountHistory()) + const onPressClearUserSettings = useEvent(() => appStateResetter.resetUserSettings()) + const onPressClearCachedData = useEvent(() => appStateResetter.resetQueryCaches()) + const onPressClearAllData = useEvent(() => appStateResetter.resetAll()) + + return ( + + } /> + + + ) +} diff --git a/apps/extension/src/app/features/settings/SmartWalletSettingsScreen.tsx b/apps/extension/src/app/features/settings/SmartWalletSettingsScreen.tsx new file mode 100644 index 00000000..b83f9be6 --- /dev/null +++ b/apps/extension/src/app/features/settings/SmartWalletSettingsScreen.tsx @@ -0,0 +1,22 @@ +import { useTranslation } from 'react-i18next' +import { ScreenHeader } from 'src/app/components/layout/ScreenHeader' +import { Flex } from 'ui/src' +import { + SmartWalletHelpIcon, + SmartWalletSettingsContent, +} from 'wallet/src/features/smartWallet/SmartWalletSettingsContent' + +export function SmartWalletSettingsScreen(): JSX.Element { + const { t } = useTranslation() + + return ( + + } + /> + + + + ) +} diff --git a/apps/extension/src/app/features/settings/ThemeToggle.tsx b/apps/extension/src/app/features/settings/ThemeToggle.tsx new file mode 100644 index 00000000..705ac710 --- /dev/null +++ b/apps/extension/src/app/features/settings/ThemeToggle.tsx @@ -0,0 +1,28 @@ +import { useTranslation } from 'react-i18next' +import { SCREEN_ITEM_HORIZONTAL_PAD } from 'src/app/constants' +import { Flex, Text } from 'ui/src' +import { Contrast } from 'ui/src/components/icons' +import { ThemeToggle } from 'uniswap/src/components/appearance/ThemeToggle' + +export function ThemeToggleWithLabel(): JSX.Element { + const { t } = useTranslation() + + return ( + + + + {t('settings.setting.appearance.title')} + + + + + + ) +} diff --git a/apps/extension/src/app/features/settings/components/SettingsItem.tsx b/apps/extension/src/app/features/settings/components/SettingsItem.tsx new file mode 100644 index 00000000..65e850ee --- /dev/null +++ b/apps/extension/src/app/features/settings/components/SettingsItem.tsx @@ -0,0 +1,83 @@ +import { Link } from 'react-router' +import { ColorTokens, Flex, GeneratedIcon, Text, TouchableArea, useSporeColors } from 'ui/src' +import { RotatableChevron } from 'ui/src/components/icons' + +export function SettingsItem({ + Icon, + title, + onPress, + iconProps, + themeProps, + url, + count, + hideChevron = false, + RightIcon, + testID, +}: { + Icon: GeneratedIcon + title: string + hideChevron?: boolean + RightIcon?: GeneratedIcon + onPress?: () => void + iconProps?: { strokeWidth?: number } + // TODO: do this with a wrapping Theme, "detrimental" wasn't working + themeProps?: { color?: string; hoverColor?: string } + url?: string + count?: number + testID?: string +}): JSX.Element { + const colors = useSporeColors() + const hoverColor = themeProps?.hoverColor ?? colors.surface2.val + + const content = ( + + + + + + {title} + + + {count !== undefined && ( + + {count} + + )} + + + {RightIcon ? ( + + ) : ( + !hideChevron && + )} + + ) + + if (url) { + return ( + + {content} + + ) + } + + return content +} diff --git a/apps/extension/src/app/features/settings/components/SettingsSection.tsx b/apps/extension/src/app/features/settings/components/SettingsSection.tsx new file mode 100644 index 00000000..66ed6979 --- /dev/null +++ b/apps/extension/src/app/features/settings/components/SettingsSection.tsx @@ -0,0 +1,19 @@ +import { SCREEN_ITEM_HORIZONTAL_PAD } from 'src/app/constants' +import { Flex, Text } from 'ui/src' + +export function SettingsSection({ + title, + children, +}: { + title: string + children: JSX.Element | JSX.Element[] +}): JSX.Element { + return ( + + + {title} + + {children} + + ) +} diff --git a/apps/extension/src/app/features/settings/components/SettingsToggleRow.tsx b/apps/extension/src/app/features/settings/components/SettingsToggleRow.tsx new file mode 100644 index 00000000..56a66f55 --- /dev/null +++ b/apps/extension/src/app/features/settings/components/SettingsToggleRow.tsx @@ -0,0 +1,33 @@ +import { SCREEN_ITEM_HORIZONTAL_PAD } from 'src/app/constants' +import { Flex, GeneratedIcon, Switch, Text } from 'ui/src' + +export function SettingsToggleRow({ + Icon, + title, + checked, + disabled, + onCheckedChange, +}: { + title: string + Icon: GeneratedIcon + checked: boolean + disabled?: boolean + onCheckedChange: (checked: boolean) => void +}): JSX.Element { + return ( + + + + {title} + + + + ) +} diff --git a/apps/extension/src/app/features/settings/password/ChangePasswordForm.test.tsx b/apps/extension/src/app/features/settings/password/ChangePasswordForm.test.tsx new file mode 100644 index 00000000..f4721fb4 --- /dev/null +++ b/apps/extension/src/app/features/settings/password/ChangePasswordForm.test.tsx @@ -0,0 +1,166 @@ +import { act, fireEvent, waitFor } from '@testing-library/react' +import { ChangePasswordForm } from 'src/app/features/settings/password/ChangePasswordForm' +import { cleanup, render, screen } from 'src/test/test-utils' +import { Keyring } from 'wallet/src/features/wallet/Keyring/Keyring' + +// Mock the Keyring +jest.mock('wallet/src/features/wallet/Keyring/Keyring', () => ({ + Keyring: { + changePassword: jest.fn().mockResolvedValue(undefined), + }, +})) + +// Mock analytics +jest.mock('uniswap/src/features/telemetry/send', () => ({ + sendAnalyticsEvent: jest.fn(), +})) + +const mockChangePassword = Keyring.changePassword as jest.MockedFunction + +describe('ChangePasswordForm', () => { + const mockOnNext = jest.fn() + const oldPassword = 'MyOldPassword123!' + + beforeEach(() => { + jest.clearAllMocks() + mockChangePassword.mockClear() + }) + + afterEach(() => { + cleanup() + }) + + it('renders without error', () => { + const tree = render() + expect(tree).toMatchSnapshot() + }) + + it('renders password input fields', () => { + render() + + // Check for translated placeholders + expect(screen.getByPlaceholderText('New password')).toBeDefined() + expect(screen.getByPlaceholderText('Confirm password')).toBeDefined() + }) + + it('renders continue button', () => { + render() + + expect(screen.getByText('Continue')).toBeDefined() + }) + + it('shows error when new password matches old password', async () => { + render() + + const newPasswordInput = screen.getByPlaceholderText('New password') + const confirmPasswordInput = screen.getByPlaceholderText('Confirm password') + + // Type the same password as the old password + act(() => { + fireEvent.change(newPasswordInput, { target: { value: oldPassword } }) + fireEvent.change(confirmPasswordInput, { target: { value: oldPassword } }) + }) + + // Wait for error to appear + await waitFor(() => { + const errorText = screen.getByText('New password must be different from current password') + expect(errorText).toBeDefined() + }) + }) + + it('clears error when password changes to be different', async () => { + render() + + const newPasswordInput = screen.getByPlaceholderText('New password') + const confirmPasswordInput = screen.getByPlaceholderText('Confirm password') + + // First, type the same password as old password + act(() => { + fireEvent.change(newPasswordInput, { target: { value: oldPassword } }) + fireEvent.change(confirmPasswordInput, { target: { value: oldPassword } }) + }) + + // Wait for error to appear + await waitFor(() => { + expect(screen.getByText('New password must be different from current password')).toBeDefined() + }) + + // Clear and type a different password + act(() => { + fireEvent.change(newPasswordInput, { target: { value: 'DifferentPassword789!' } }) + fireEvent.change(confirmPasswordInput, { target: { value: 'DifferentPassword789!' } }) + }) + + // Error should be cleared + await waitFor(() => { + expect(screen.queryByText('New password must be different from current password')).toBeNull() + }) + }) + + it('does not call onNext when passwords match old password', async () => { + render() + + const newPasswordInput = screen.getByPlaceholderText('New password') + const confirmPasswordInput = screen.getByPlaceholderText('Confirm password') + const submitButton = screen.getByText('Continue') + + // Type the same password as the old password + act(() => { + fireEvent.change(newPasswordInput, { target: { value: oldPassword } }) + fireEvent.change(confirmPasswordInput, { target: { value: oldPassword } }) + }) + + // Try to submit + await act(async () => { + fireEvent.click(submitButton) + }) + + expect(mockOnNext).not.toHaveBeenCalled() + expect(mockChangePassword).not.toHaveBeenCalled() + }) + + it('calls onNext and changePassword when passwords are different and valid', async () => { + render() + + const newPasswordInput = screen.getByPlaceholderText('New password') + const confirmPasswordInput = screen.getByPlaceholderText('Confirm password') + const submitButton = screen.getByText('Continue') + + const newPassword = 'MyNewStrongPassword456!' + + // Type a different password + act(() => { + fireEvent.change(newPasswordInput, { target: { value: newPassword } }) + fireEvent.change(confirmPasswordInput, { target: { value: newPassword } }) + }) + + // Submit the form + await act(async () => { + fireEvent.click(submitButton) + }) + + await waitFor(() => { + expect(mockChangePassword).toHaveBeenCalledWith(newPassword) + expect(mockOnNext).toHaveBeenCalledWith(newPassword) + }) + }) + + it('handles undefined oldPassword gracefully', async () => { + render() + + const newPasswordInput = screen.getByPlaceholderText('New password') + const confirmPasswordInput = screen.getByPlaceholderText('Confirm password') + + const newPassword = 'AnyPassword123!' + + // Type any password - should not show "same password" error since oldPassword is undefined + act(() => { + fireEvent.change(newPasswordInput, { target: { value: newPassword } }) + fireEvent.change(confirmPasswordInput, { target: { value: newPassword } }) + }) + + await waitFor(() => { + expect(screen.queryByText('New password must be different from current password')).toBeNull() + }) + }) +}) diff --git a/apps/extension/src/app/features/settings/password/ChangePasswordForm.tsx b/apps/extension/src/app/features/settings/password/ChangePasswordForm.tsx new file mode 100644 index 00000000..d82b56ba --- /dev/null +++ b/apps/extension/src/app/features/settings/password/ChangePasswordForm.tsx @@ -0,0 +1,112 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { PADDING_STRENGTH_INDICATOR, PasswordInput } from 'src/app/components/PasswordInput' +import { Button, Flex, Text } from 'ui/src' +import { ExtensionEventName } from 'uniswap/src/features/telemetry/constants' +import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { Keyring } from 'wallet/src/features/wallet/Keyring/Keyring' +import { PasswordErrors, usePasswordForm } from 'wallet/src/utils/password' + +export function ChangePasswordForm({ + oldPassword, + onNext, +}: { + oldPassword: string | undefined + onNext: (password: string) => void +}): JSX.Element { + const { t } = useTranslation() + + const { + enableNext, + hideInput, + debouncedPasswordStrength, + password, + onPasswordBlur, + onChangePassword, + confirmPassword, + onChangeConfirmPassword, + setHideInput, + errorText: baseErrorText, + checkSubmit, + } = usePasswordForm() + + const [customError, setCustomError] = useState(undefined) + + // Check if new password is same as old password + const isSamePassword = useMemo( + () => Boolean(password && oldPassword && password === oldPassword), + [password, oldPassword], + ) + + // Update custom error when password matches old password + useEffect(() => { + setCustomError(isSamePassword ? PasswordErrors.SamePassword : undefined) + }, [isSamePassword]) + + // Override error text if custom error exists + const errorText = useMemo(() => { + if (customError === PasswordErrors.SamePassword) { + return t('common.input.password.error.same') + } + return baseErrorText + }, [customError, baseErrorText, t]) + + const onSubmit = useCallback(async () => { + // Check for same password error + if (isSamePassword) { + setCustomError(PasswordErrors.SamePassword) + return + } + + if (checkSubmit()) { + // Just change the password and pass it to the parent + await Keyring.changePassword(password) + sendAnalyticsEvent(ExtensionEventName.PasswordChanged) + onNext(password) + } + }, [checkSubmit, password, onNext, isSamePassword]) + + return ( + + + + + + {errorText} + + + + + + + ) +} diff --git a/apps/extension/src/app/features/settings/password/CreateNewPasswordModal.tsx b/apps/extension/src/app/features/settings/password/CreateNewPasswordModal.tsx new file mode 100644 index 00000000..9411c1fa --- /dev/null +++ b/apps/extension/src/app/features/settings/password/CreateNewPasswordModal.tsx @@ -0,0 +1,45 @@ +import { useTranslation } from 'react-i18next' +import { ChangePasswordForm } from 'src/app/features/settings/password/ChangePasswordForm' +import { Flex, Square, Text, useSporeColors } from 'ui/src' +import { Lock } from 'ui/src/components/icons' +import { Modal } from 'uniswap/src/components/modals/Modal' +import { ModalName } from 'uniswap/src/features/telemetry/constants' + +export function CreateNewPasswordModal({ + isOpen, + oldPassword, + onNext, + onClose, +}: { + isOpen: boolean + oldPassword: string | undefined + onNext: (password: string) => void + onClose: () => void +}): JSX.Element { + const { t } = useTranslation() + const colors = useSporeColors() + + return ( + + + + + + + + {t('settings.setting.password.change.title')} + + + + + + ) +} diff --git a/apps/extension/src/app/features/settings/password/EnterPasswordModal.tsx b/apps/extension/src/app/features/settings/password/EnterPasswordModal.tsx new file mode 100644 index 00000000..c6d90a79 --- /dev/null +++ b/apps/extension/src/app/features/settings/password/EnterPasswordModal.tsx @@ -0,0 +1,101 @@ +import { useMutation } from '@tanstack/react-query' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { PasswordInputWithBiometrics } from 'src/app/components/PasswordInput' +import { reauthenticateWithBiometricCredential } from 'src/app/features/biometricUnlock/useUnlockWithBiometricCredentialMutation' +import { Button, Flex, inputStyles, Square, Text, useSporeColors } from 'ui/src' +import { Lock } from 'ui/src/components/icons' +import { Modal } from 'uniswap/src/components/modals/Modal' +import { ModalName } from 'uniswap/src/features/telemetry/constants' +import { Keyring } from 'wallet/src/features/wallet/Keyring/Keyring' + +export function EnterPasswordModal({ + isOpen, + onNext, + onClose, + shouldReturnPassword = false, + hideBiometrics = false, +}: { + isOpen: boolean + onNext: (password?: string) => void + onClose: () => void + shouldReturnPassword?: boolean + hideBiometrics?: boolean +}): JSX.Element { + const { t } = useTranslation() + const colors = useSporeColors() + + const [password, setPassword] = useState('') + const [showPasswordError, setShowPasswordError] = useState(false) + const [hideInput, setHideInput] = useState(true) + + const onChangeText = (text: string): void => { + setPassword(text) + setShowPasswordError(false) + } + + const checkPassword = async (): Promise => { + const success = await Keyring.checkPassword(password) + if (!success) { + setShowPasswordError(true) + return + } + onNext(shouldReturnPassword ? password : undefined) + } + + const { mutate: onPressReauthenticateWithBiometricCredential } = useMutation({ + mutationFn: reauthenticateWithBiometricCredential, + onSuccess: ({ password: credentialPassword }) => { + if (credentialPassword) { + onNext(shouldReturnPassword ? credentialPassword : undefined) + } + }, + }) + + return ( + + + + + + + + {t('extension.passwordPrompt.title')} + + + + + + {showPasswordError ? t('extension.passwordPrompt.error.wrongPassword') : ''} + + + + + + + + ) +} diff --git a/apps/extension/src/app/features/settings/password/__snapshots__/ChangePasswordForm.test.tsx.snap b/apps/extension/src/app/features/settings/password/__snapshots__/ChangePasswordForm.test.tsx.snap new file mode 100644 index 00000000..2c5a5aea --- /dev/null +++ b/apps/extension/src/app/features/settings/password/__snapshots__/ChangePasswordForm.test.tsx.snap @@ -0,0 +1,281 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ChangePasswordForm renders without error 1`] = ` +{ + "asFragment": [Function], + "baseElement": +
+ +
+
+
+ + + +
+
+ + + +
+ + + +
+
+ +
+
+ +
+
+
+ +
+ , + "container":
+ +
+
+
+ + + +
+
+ + + +
+ + + +
+
+ +
+
+ +
+
+
+ +
, + "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], +} +`; diff --git a/apps/extension/src/app/features/settings/password/usePasswordResetFlow.test.ts b/apps/extension/src/app/features/settings/password/usePasswordResetFlow.test.ts new file mode 100644 index 00000000..3685be9b --- /dev/null +++ b/apps/extension/src/app/features/settings/password/usePasswordResetFlow.test.ts @@ -0,0 +1,366 @@ +import { act, renderHook } from '@testing-library/react' +import { useDispatch } from 'react-redux' +import { useBiometricUnlockDisableMutation } from 'src/app/features/biometricUnlock/useBiometricUnlockDisableMutation' +import { useChangePasswordWithBiometricMutation } from 'src/app/features/biometricUnlock/useChangePasswordWithBiometricMutation' +import { useHasBiometricUnlockCredential } from 'src/app/features/biometricUnlock/useShouldShowBiometricUnlock' +import { PasswordResetFlowState, usePasswordResetFlow } from 'src/app/features/settings/password/usePasswordResetFlow' +import { pushNotification } from 'uniswap/src/features/notifications/slice/slice' +import { AppNotificationType } from 'uniswap/src/features/notifications/slice/types' + +// Mock dependencies +jest.mock('react-redux', () => ({ + useDispatch: jest.fn(), +})) + +jest.mock('src/app/features/biometricUnlock/useShouldShowBiometricUnlock', () => ({ + useHasBiometricUnlockCredential: jest.fn(), +})) + +jest.mock('src/app/features/biometricUnlock/useChangePasswordWithBiometricMutation', () => ({ + useChangePasswordWithBiometricMutation: jest.fn(), +})) + +jest.mock('src/app/features/biometricUnlock/useBiometricUnlockDisableMutation', () => ({ + useBiometricUnlockDisableMutation: jest.fn(), +})) + +jest.mock('uniswap/src/features/notifications/slice/slice', () => ({ + pushNotification: jest.fn(), +})) + +jest.mock('utilities/src/react/hooks', () => ({ + useEvent: jest.fn((fn) => fn), +})) + +const mockDispatch = jest.fn() +const mockMutate = jest.fn() +const mockDisableBiometricMutate = jest.fn() +const mockUseHasBiometricUnlockCredential = useHasBiometricUnlockCredential as jest.MockedFunction< + typeof useHasBiometricUnlockCredential +> +const mockUseChangePasswordWithBiometricMutation = useChangePasswordWithBiometricMutation as jest.MockedFunction< + typeof useChangePasswordWithBiometricMutation +> +const mockUseBiometricUnlockDisableMutation = useBiometricUnlockDisableMutation as jest.MockedFunction< + typeof useBiometricUnlockDisableMutation +> +const mockUseDispatch = useDispatch as jest.MockedFunction + +describe('usePasswordResetFlow', () => { + beforeEach(() => { + jest.clearAllMocks() + mockUseDispatch.mockReturnValue(mockDispatch) + mockUseHasBiometricUnlockCredential.mockReturnValue(false) + mockUseChangePasswordWithBiometricMutation.mockReturnValue({ + mutate: mockMutate, + isPending: false, + } as any) + mockUseBiometricUnlockDisableMutation.mockReturnValue({ + mutate: mockDisableBiometricMutate, + isPending: false, + } as any) + }) + + it('should initialize with None state', () => { + const { result } = renderHook(() => usePasswordResetFlow()) + + expect(result.current.flowState).toBe(PasswordResetFlowState.None) + }) + + it('should initialize with undefined oldPassword', () => { + const { result } = renderHook(() => usePasswordResetFlow()) + + expect(result.current.oldPassword).toBeUndefined() + }) + + it('should transition to EnterCurrentPassword when starting password reset', () => { + const { result } = renderHook(() => usePasswordResetFlow()) + + act(() => { + result.current.startPasswordReset() + }) + + expect(result.current.flowState).toBe(PasswordResetFlowState.EnterCurrentPassword) + }) + + it('should transition to EnterNewPassword when valid password is provided', () => { + const { result } = renderHook(() => usePasswordResetFlow()) + + act(() => { + result.current.startPasswordReset() + }) + + act(() => { + result.current.onPasswordModalNext('validPassword') + }) + + expect(result.current.flowState).toBe(PasswordResetFlowState.EnterNewPassword) + }) + + it('should store oldPassword when valid password is provided', () => { + const { result } = renderHook(() => usePasswordResetFlow()) + const testPassword = 'myOldPassword123!' + + act(() => { + result.current.startPasswordReset() + }) + + act(() => { + result.current.onPasswordModalNext(testPassword) + }) + + expect(result.current.oldPassword).toBe(testPassword) + expect(result.current.flowState).toBe(PasswordResetFlowState.EnterNewPassword) + }) + + it('should return to None state when no password is provided', () => { + const { result } = renderHook(() => usePasswordResetFlow()) + + act(() => { + result.current.startPasswordReset() + }) + + act(() => { + result.current.onPasswordModalNext() + }) + + expect(result.current.flowState).toBe(PasswordResetFlowState.None) + }) + + it('should clear oldPassword when no password is provided', () => { + const { result } = renderHook(() => usePasswordResetFlow()) + + act(() => { + result.current.startPasswordReset() + }) + + act(() => { + result.current.onPasswordModalNext('validPassword') + }) + + expect(result.current.oldPassword).toBe('validPassword') + + // Go back and provide no password + act(() => { + result.current.startPasswordReset() + }) + + act(() => { + result.current.onPasswordModalNext() + }) + + expect(result.current.oldPassword).toBeUndefined() + expect(result.current.flowState).toBe(PasswordResetFlowState.None) + }) + + it('should transition to BiometricAuth when biometric is enabled', () => { + mockUseHasBiometricUnlockCredential.mockReturnValue(true) + const { result } = renderHook(() => usePasswordResetFlow()) + + act(() => { + result.current.startPasswordReset() + }) + + act(() => { + result.current.onPasswordModalNext('validPassword') + }) + + act(() => { + result.current.onChangePasswordModalNext('newPassword') + }) + + expect(result.current.flowState).toBe(PasswordResetFlowState.BiometricAuth) + }) + + it('should complete flow immediately when biometric is disabled', () => { + mockUseHasBiometricUnlockCredential.mockReturnValue(false) + const { result } = renderHook(() => usePasswordResetFlow()) + + act(() => { + result.current.startPasswordReset() + }) + + act(() => { + result.current.onPasswordModalNext('validPassword') + }) + + act(() => { + result.current.onChangePasswordModalNext('newPassword') + }) + + expect(result.current.flowState).toBe(PasswordResetFlowState.None) + expect(mockDispatch).toHaveBeenCalledWith(pushNotification({ type: AppNotificationType.PasswordChanged })) + }) + + it('should close modal when closeModal is called with matching state', () => { + const { result } = renderHook(() => usePasswordResetFlow()) + + act(() => { + result.current.startPasswordReset() + }) + + expect(result.current.flowState).toBe(PasswordResetFlowState.EnterCurrentPassword) + + act(() => { + result.current.closeModal(PasswordResetFlowState.EnterCurrentPassword) + }) + + expect(result.current.flowState).toBe(PasswordResetFlowState.None) + }) + + it('should clear oldPassword when closeModal is called with matching state', () => { + const { result } = renderHook(() => usePasswordResetFlow()) + + act(() => { + result.current.startPasswordReset() + }) + + act(() => { + result.current.onPasswordModalNext('testPassword123') + }) + + expect(result.current.oldPassword).toBe('testPassword123') + expect(result.current.flowState).toBe(PasswordResetFlowState.EnterNewPassword) + + act(() => { + result.current.closeModal(PasswordResetFlowState.EnterNewPassword) + }) + + expect(result.current.flowState).toBe(PasswordResetFlowState.None) + expect(result.current.oldPassword).toBeUndefined() + }) + + it('should not close modal when closeModal is called with non-matching state', () => { + const { result } = renderHook(() => usePasswordResetFlow()) + + act(() => { + result.current.startPasswordReset() + }) + + expect(result.current.flowState).toBe(PasswordResetFlowState.EnterCurrentPassword) + + act(() => { + result.current.closeModal(PasswordResetFlowState.BiometricAuth) + }) + + expect(result.current.flowState).toBe(PasswordResetFlowState.EnterCurrentPassword) + }) + + it('should not clear oldPassword when closeModal is called with non-matching state', () => { + const { result } = renderHook(() => usePasswordResetFlow()) + + act(() => { + result.current.startPasswordReset() + }) + + act(() => { + result.current.onPasswordModalNext('testPassword456') + }) + + expect(result.current.oldPassword).toBe('testPassword456') + expect(result.current.flowState).toBe(PasswordResetFlowState.EnterNewPassword) + + act(() => { + result.current.closeModal(PasswordResetFlowState.BiometricAuth) + }) + + // Should not clear oldPassword or change state + expect(result.current.flowState).toBe(PasswordResetFlowState.EnterNewPassword) + expect(result.current.oldPassword).toBe('testPassword456') + }) + + it('should transition to BiometricAuth state when biometric is enabled and trigger internal mutation', () => { + mockUseHasBiometricUnlockCredential.mockReturnValue(true) + const { result } = renderHook(() => usePasswordResetFlow()) + + act(() => { + result.current.startPasswordReset() + }) + + act(() => { + result.current.onPasswordModalNext('validPassword') + }) + + act(() => { + result.current.onChangePasswordModalNext('newPassword') + }) + + // Should transition to BiometricAuth state, which internally triggers the biometric mutation + expect(result.current.flowState).toBe(PasswordResetFlowState.BiometricAuth) + }) + + it('should handle biometric mutation error and reset flow state', () => { + let onErrorCallback: ((error: Error) => void) | undefined + + mockUseHasBiometricUnlockCredential.mockReturnValue(true) + mockUseChangePasswordWithBiometricMutation.mockImplementation((options) => { + onErrorCallback = options?.onError + return { + mutate: mockMutate, + isPending: false, + } as any + }) + + const { result } = renderHook(() => usePasswordResetFlow()) + + act(() => { + result.current.startPasswordReset() + }) + + act(() => { + result.current.onPasswordModalNext('validPassword') + }) + + act(() => { + result.current.onChangePasswordModalNext('newPassword') + }) + + expect(result.current.flowState).toBe(PasswordResetFlowState.BiometricAuth) + + // Simulate biometric authentication error + act(() => { + onErrorCallback?.(new Error('Biometric authentication failed')) + }) + + // Should reset to None state after error + expect(result.current.flowState).toBe(PasswordResetFlowState.None) + }) + + it('should handle biometric mutation success and complete flow', () => { + let onSuccessCallback: (() => void) | undefined + + mockUseHasBiometricUnlockCredential.mockReturnValue(true) + mockUseChangePasswordWithBiometricMutation.mockImplementation((options) => { + onSuccessCallback = options?.onSuccess + return { + mutate: mockMutate, + isPending: false, + } as any + }) + + const { result } = renderHook(() => usePasswordResetFlow()) + + act(() => { + result.current.startPasswordReset() + }) + + act(() => { + result.current.onPasswordModalNext('validPassword') + }) + + act(() => { + result.current.onChangePasswordModalNext('newPassword') + }) + + expect(result.current.flowState).toBe(PasswordResetFlowState.BiometricAuth) + + // Simulate biometric authentication success + act(() => { + onSuccessCallback?.() + }) + + // Should complete flow and show success notification + expect(result.current.flowState).toBe(PasswordResetFlowState.None) + expect(mockDispatch).toHaveBeenCalledWith(pushNotification({ type: AppNotificationType.PasswordChanged })) + }) +}) diff --git a/apps/extension/src/app/features/settings/password/usePasswordResetFlow.ts b/apps/extension/src/app/features/settings/password/usePasswordResetFlow.ts new file mode 100644 index 00000000..9c2b61b4 --- /dev/null +++ b/apps/extension/src/app/features/settings/password/usePasswordResetFlow.ts @@ -0,0 +1,146 @@ +import { useState } from 'react' +import { useDispatch } from 'react-redux' +import { useBiometricUnlockDisableMutation } from 'src/app/features/biometricUnlock/useBiometricUnlockDisableMutation' +import { useChangePasswordWithBiometricMutation } from 'src/app/features/biometricUnlock/useChangePasswordWithBiometricMutation' +import { useHasBiometricUnlockCredential } from 'src/app/features/biometricUnlock/useShouldShowBiometricUnlock' +import { pushNotification } from 'uniswap/src/features/notifications/slice/slice' +import { AppNotificationType } from 'uniswap/src/features/notifications/slice/types' +import { useEvent } from 'utilities/src/react/hooks' + +/** + * Password Reset Flow State Machine + * + * This hook manages the password reset flow using a state machine pattern to ensure + * only one modal is open at a time and transitions are predictable. + * + * State Transition Diagram: + * ======================== + * + * Initial State: None + * + * From None: + * └─ Start Password Reset → EnterCurrentPassword + * + * From EnterCurrentPassword: + * ├─ onClose → None + * ├─ onNext (no password) → None + * └─ onNext (with password) → EnterNewPassword + * + * From EnterNewPassword: + * ├─ onClose → None + * └─ onNext → BiometricAuth (if biometric enabled) OR None (+ success notification) (if no biometric) + * + * From BiometricAuth: + * ├─ onClose → None (disable biometric unlock) + * └─ onAuthenticate → None (+ re-encrypt + success notification) + * + * State Dependencies: + * ================== + * - hasBiometricUnlockCredential: Determines EnterNewPassword → BiometricAuth vs immediate success + * + * Close Handler Strategy: + * ====================== + * Each modal uses closeModal(expectedState) which only closes if current state matches expected state. + * This prevents race conditions where late-firing close handlers interfere with state transitions. + */ + +export enum PasswordResetFlowState { + None = 'none', + EnterCurrentPassword = 'enterCurrentPassword', + EnterNewPassword = 'enterNewPassword', + BiometricAuth = 'biometricAuth', +} + +interface PasswordResetFlowResult { + // State + flowState: PasswordResetFlowState + oldPassword: string | undefined + + // Actions + startPasswordReset: () => void + closeModal: (expectedState: PasswordResetFlowState) => void + + // Modal handlers + onPasswordModalNext: (password?: string) => void + onChangePasswordModalNext: (password: string) => void + onBiometricAuthModalClose: () => void +} + +export function usePasswordResetFlow(): PasswordResetFlowResult { + const dispatch = useDispatch() + const [flowState, setFlowState] = useState(PasswordResetFlowState.None) + const [oldPassword, setOldPassword] = useState(undefined) + + const hasBiometricUnlockCredential = useHasBiometricUnlockCredential() + + const { mutate: disableBiometricUnlock } = useBiometricUnlockDisableMutation() + + // Handle biometric authentication for password change + const { mutate: reEncryptPasswordWithBiometric } = useChangePasswordWithBiometricMutation({ + onSuccess: () => { + setFlowState(PasswordResetFlowState.None) + dispatch(pushNotification({ type: AppNotificationType.PasswordChanged })) + }, + onError: () => { + // Disable biometric unlock because the encrypted password was not updated + disableBiometricUnlock() + setFlowState(PasswordResetFlowState.None) + }, + }) + + const startPasswordReset = useEvent(() => { + setFlowState(PasswordResetFlowState.EnterCurrentPassword) + }) + + const closeModal = useEvent((expectedState: PasswordResetFlowState) => { + // When transitioning between modal states, the previous modal's `isOpen` becomes `false` and triggers `onClose`. + // This check ensures the close action is from user interaction, not from modal state changes. + if (flowState === expectedState) { + setFlowState(PasswordResetFlowState.None) + setOldPassword(undefined) + } + }) + + const onPasswordModalNext = useEvent((password?: string): void => { + if (!password) { + setFlowState(PasswordResetFlowState.None) + setOldPassword(undefined) + return + } + + setOldPassword(password) + setFlowState(PasswordResetFlowState.EnterNewPassword) + }) + + const onChangePasswordModalNext = useEvent((password: string): void => { + if (hasBiometricUnlockCredential) { + // If biometric unlock is enabled, show biometric auth modal + setFlowState(PasswordResetFlowState.BiometricAuth) + reEncryptPasswordWithBiometric(password) + } else { + // If biometric unlock is not enabled, complete the flow + setFlowState(PasswordResetFlowState.None) + dispatch(pushNotification({ type: AppNotificationType.PasswordChanged })) + } + }) + + const onBiometricAuthModalClose = useEvent((): void => { + disableBiometricUnlock() + closeModal(PasswordResetFlowState.BiometricAuth) + }) + + return { + // State + flowState, + oldPassword, + + // Actions + startPasswordReset, + closeModal, + + // Modal handlers + onPasswordModalNext, + onChangePasswordModalNext, + onBiometricAuthModalClose, + } +} diff --git a/apps/extension/src/app/features/settings/stores/hashcashBenchmarkStore.ts b/apps/extension/src/app/features/settings/stores/hashcashBenchmarkStore.ts new file mode 100644 index 00000000..5c905ac1 --- /dev/null +++ b/apps/extension/src/app/features/settings/stores/hashcashBenchmarkStore.ts @@ -0,0 +1,118 @@ +import { create } from 'zustand' + +export type Implementation = 'worker' | 'multi-worker' | 'js' | 'all' + +export interface BenchmarkResult { + implementation: 'worker' | 'multi-worker' | 'js' + difficulty: number + counter: string | null + attempts: number + timeMs: number + hashRate: number +} + +interface BenchmarkProgress { + isRunning: boolean + currentImpl: 'worker' | 'multi-worker' | 'js' | null + difficulty: number + startTime: number | null + elapsedMs: number + estimatedAttempts: number +} + +export interface LogEntry { + timestamp: Date + message: string + type: 'info' | 'success' | 'error' +} + +interface HashcashBenchmarkState { + // State + results: BenchmarkResult[] + selectedDifficulty: number + selectedImpl: Implementation + logs: LogEntry[] + progress: BenchmarkProgress + measuredHashRate: number | null + isCancelled: boolean + + // Actions + setDifficulty: (difficulty: number) => void + setImpl: (impl: Implementation) => void + addResult: (result: BenchmarkResult) => void + clearResults: () => void + addLog: (message: string, type?: 'info' | 'success' | 'error') => void + clearLogs: () => void + startBenchmark: (impl: 'worker' | 'multi-worker' | 'js', difficulty: number) => void + updateProgress: (elapsedMs: number, estimatedAttempts: number) => void + endBenchmark: () => void + cancel: () => void + resetCancel: () => void +} + +const initialProgress: BenchmarkProgress = { + isRunning: false, + currentImpl: null, + difficulty: 0, + startTime: null, + elapsedMs: 0, + estimatedAttempts: 0, +} + +export const useHashcashBenchmarkStore = create((set) => ({ + results: [], + selectedDifficulty: 2, + selectedImpl: 'all', + logs: [], + progress: initialProgress, + measuredHashRate: null, + isCancelled: false, + + setDifficulty: (difficulty) => set({ selectedDifficulty: difficulty }), + + setImpl: (impl) => set({ selectedImpl: impl }), + + addResult: (result) => + set((state) => ({ + results: [...state.results, result], + // Auto-update measured hash rate from worker/multi-worker results + measuredHashRate: + (result.implementation === 'worker' || result.implementation === 'multi-worker') && result.hashRate > 0 + ? result.hashRate + : state.measuredHashRate, + })), + + clearResults: () => set({ results: [] }), + + addLog: (message, type = 'info') => + set((state) => ({ + logs: [...state.logs.slice(-19), { timestamp: new Date(), message, type }], + })), + + clearLogs: () => set({ logs: [] }), + + startBenchmark: (impl, difficulty) => + set({ + isCancelled: false, + progress: { + isRunning: true, + currentImpl: impl, + difficulty, + // Worker/multi-worker runs async so we can track progress, JS blocks the thread + startTime: impl === 'worker' || impl === 'multi-worker' ? performance.now() : null, + elapsedMs: 0, + estimatedAttempts: 0, + }, + }), + + updateProgress: (elapsedMs, estimatedAttempts) => + set((state) => ({ + progress: { ...state.progress, elapsedMs, estimatedAttempts }, + })), + + endBenchmark: () => set({ progress: initialProgress }), + + cancel: () => set({ isCancelled: true, progress: initialProgress }), + + resetCancel: () => set({ isCancelled: false }), +})) diff --git a/apps/extension/src/app/features/settings/stores/sessionsDebugStore.ts b/apps/extension/src/app/features/settings/stores/sessionsDebugStore.ts new file mode 100644 index 00000000..d34ef2b3 --- /dev/null +++ b/apps/extension/src/app/features/settings/stores/sessionsDebugStore.ts @@ -0,0 +1,112 @@ +import type { ChallengeResponse, HashcashSolveAnalytics } from '@universe/sessions' +import { create } from 'zustand' + +interface SessionState { + sessionId: string | null + deviceId: string | null + uniswapIdentifier: string | null +} + +export interface LogEntry { + timestamp: Date + message: string + type: 'info' | 'success' | 'error' +} + +interface HashcashProgress { + isRunning: boolean + difficulty: number + estimatedAttempts: number + elapsedMs: number + startTime: number | null + actualResult: HashcashSolveAnalytics | null +} + +interface SessionsDebugState { + // State + session: SessionState + challenge: ChallengeResponse | null + isLoading: boolean + currentOperation: string | null + logs: LogEntry[] + hashcashProgress: HashcashProgress + + // Actions + setSession: (session: SessionState) => void + setChallenge: (challenge: ChallengeResponse | null) => void + startOperation: (operation: string) => void + endOperation: () => void + addLog: (message: string, type?: 'info' | 'success' | 'error') => void + clearLogs: () => void + startHashcash: (difficulty: number) => void + updateHashcashProgress: (elapsedMs: number, estimatedAttempts: number) => void + completeHashcash: (result: HashcashSolveAnalytics) => void + stopHashcash: () => void + reset: () => void +} + +const initialHashcashProgress: HashcashProgress = { + isRunning: false, + difficulty: 0, + estimatedAttempts: 0, + elapsedMs: 0, + startTime: null, + actualResult: null, +} + +const initialState = { + session: { sessionId: null, deviceId: null, uniswapIdentifier: null }, + challenge: null, + isLoading: false, + currentOperation: null, + logs: [] as LogEntry[], + hashcashProgress: initialHashcashProgress, +} + +export const useSessionsDebugStore = create((set) => ({ + ...initialState, + + setSession: (session) => set({ session }), + + setChallenge: (challenge) => set({ challenge }), + + startOperation: (operation) => set({ isLoading: true, currentOperation: operation }), + + endOperation: () => set({ isLoading: false, currentOperation: null }), + + addLog: (message, type = 'info') => + set((state) => ({ + logs: [...state.logs.slice(-19), { timestamp: new Date(), message, type }], + })), + + clearLogs: () => set({ logs: [] }), + + startHashcash: (difficulty) => + set({ + hashcashProgress: { + isRunning: true, + difficulty, + estimatedAttempts: 0, + elapsedMs: 0, + startTime: performance.now(), + actualResult: null, + }, + }), + + updateHashcashProgress: (elapsedMs, estimatedAttempts) => + set((state) => ({ + hashcashProgress: { ...state.hashcashProgress, elapsedMs, estimatedAttempts }, + })), + + completeHashcash: (result) => + set((state) => ({ + hashcashProgress: { ...state.hashcashProgress, isRunning: false, actualResult: result }, + })), + + stopHashcash: () => + set((state) => ({ + hashcashProgress: { ...state.hashcashProgress, isRunning: false }, + })), + + reset: () => set(initialState), +})) diff --git a/apps/extension/src/app/features/swap/SwapFlowScreen.tsx b/apps/extension/src/app/features/swap/SwapFlowScreen.tsx new file mode 100644 index 00000000..83ab433e --- /dev/null +++ b/apps/extension/src/app/features/swap/SwapFlowScreen.tsx @@ -0,0 +1,76 @@ +import { useCallback, useEffect, useMemo, useRef } from 'react' +import { useDispatch, useSelector } from 'react-redux' +import { useExtensionNavigation } from 'src/app/navigation/utils' +import { Flex } from 'ui/src' +import { useEnabledChains } from 'uniswap/src/features/chains/hooks/useEnabledChains' +import { useHighestBalanceNativeCurrencyId } from 'uniswap/src/features/dataApi/balances/balances' +import { clearNotificationsByType } from 'uniswap/src/features/notifications/slice/slice' +import { AppNotificationType } from 'uniswap/src/features/notifications/slice/types' +import { useSwapPrefilledState } from 'uniswap/src/features/transactions/swap/form/hooks/useSwapPrefilledState' +import { selectFilteredChainIds } from 'uniswap/src/features/transactions/swap/state/selectors' +import { prepareSwapFormState, TransactionState } from 'uniswap/src/features/transactions/types/transactionState' +import { CurrencyField } from 'uniswap/src/types/currency' +import { logger } from 'utilities/src/logger/logger' +import { WalletSwapFlow } from 'wallet/src/features/transactions/swap/WalletSwapFlow' +import { invalidateAndRefetchWalletDelegationQueries } from 'wallet/src/features/transactions/watcher/transactionFinalizationSaga' +import { useActiveAccountWithThrow } from 'wallet/src/features/wallet/hooks' + +export function SwapFlowScreen(): JSX.Element { + const dispatch = useDispatch() + const { navigateBack, locationState } = useExtensionNavigation() + const { defaultChainId } = useEnabledChains() + const account = useActiveAccountWithThrow() + const ignorePersistedFilteredChainIds = !!locationState?.initialTransactionState + const persistedFilteredChainIds = useSelector(selectFilteredChainIds) + const inputCurrencyId = useHighestBalanceNativeCurrencyId({ + evmAddress: account.address, + chainId: !ignorePersistedFilteredChainIds ? persistedFilteredChainIds?.[CurrencyField.INPUT] : undefined, + }) + const initialState = prepareSwapFormState({ + inputCurrencyId, + defaultChainId, + filteredChainIdsOverride: ignorePersistedFilteredChainIds ? undefined : persistedFilteredChainIds, + }) + + // Update flow start timestamp every time modal is opened for logging + useEffect(() => { + invalidateAndRefetchWalletDelegationQueries().catch((error) => + logger.debug('SwapFlowScreen', 'useEffect', 'Failed to invalidate and refetch wallet delegation queries', error), + ) + }, []) + + const preservedTransactionStateRef = useRef(null) + const initialTransactionState = useMemo(() => { + if (locationState?.initialTransactionState) { + preservedTransactionStateRef.current = locationState.initialTransactionState + return locationState.initialTransactionState + } + + // If we have a previously preserved state, use it (prevents reset when navigating away from the swap flow) + if (preservedTransactionStateRef.current) { + return preservedTransactionStateRef.current + } + + // Only fallback to initialState if we've never had any transaction state (first time opening the swap flow) + preservedTransactionStateRef.current = initialState + return initialState + }, [locationState?.initialTransactionState, initialState]) + + const swapPrefilledState = useSwapPrefilledState(initialTransactionState) + + // Clear network change notification toasts when the swap flow closes + const onClose = useCallback(() => { + dispatch( + clearNotificationsByType({ + types: [AppNotificationType.NetworkChanged, AppNotificationType.NetworkChangedBridge], + }), + ) + navigateBack() + }, [dispatch, navigateBack]) + + return ( + + + + ) +} diff --git a/apps/extension/src/app/features/unitags/EditUnitagProfileScreen.tsx b/apps/extension/src/app/features/unitags/EditUnitagProfileScreen.tsx new file mode 100644 index 00000000..e01f9789 --- /dev/null +++ b/apps/extension/src/app/features/unitags/EditUnitagProfileScreen.tsx @@ -0,0 +1,116 @@ +import { useEffect, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { OnboardingScreen } from 'src/app/features/onboarding/OnboardingScreen' +import { useOnboardingSteps } from 'src/app/features/onboarding/OnboardingStepsContext' +import { UnitagClaimRoutes } from 'src/app/navigation/constants' +import { navigate } from 'src/app/navigation/state' +import { backgroundToSidePanelMessageChannel } from 'src/background/messagePassing/messageChannels' +import { BackgroundToSidePanelRequestType } from 'src/background/messagePassing/types/requests' +import { AnimatePresence, Flex } from 'ui/src' +import { Edit, Ellipsis, Trash } from 'ui/src/components/icons' +import { ContextMenu, MenuOptionItem } from 'uniswap/src/components/menus/ContextMenu' +import { ContextMenuTriggerMode } from 'uniswap/src/components/menus/types' +import { useUnitagsAddressQuery } from 'uniswap/src/data/apiClients/unitagsApi/useUnitagsAddressQuery' +import Trace from 'uniswap/src/features/telemetry/Trace' +import { UnitagScreens } from 'uniswap/src/types/screens/mobile' +import { useBooleanState } from 'utilities/src/react/useBooleanState' +import { ChangeUnitagModal } from 'wallet/src/features/unitags/ChangeUnitagModal' +import { DeleteUnitagModal } from 'wallet/src/features/unitags/DeleteUnitagModal' +import { EditUnitagProfileContent } from 'wallet/src/features/unitags/EditUnitagProfileContent' +import { useAccountAddressFromUrlWithThrow } from 'wallet/src/features/wallet/hooks' + +export function EditUnitagProfileScreen({ enableBack = false }: { enableBack?: boolean }): JSX.Element { + const { t } = useTranslation() + const address = useAccountAddressFromUrlWithThrow() + const { + data: retrievedUnitag, + isPending, + isFetching, + } = useUnitagsAddressQuery({ + params: address ? { address } : undefined, + }) + const unitag = retrievedUnitag?.username + + useEffect(() => { + if (!isPending && !isFetching && !unitag) { + navigate(`/${UnitagClaimRoutes.ClaimIntro}`) + } + }, [unitag, isPending, isFetching]) + + const { goToPreviousStep } = useOnboardingSteps() + + const [showDeleteUnitagModal, setShowDeleteUnitagModal] = useState(false) + const [showChangeUnitagModal, setShowChangeUnitagModal] = useState(false) + const { value: isMenuOpen, setTrue: openMenu, setFalse: closeMenu } = useBooleanState(false) + + const menuOptions = useMemo((): MenuOptionItem[] => { + return [ + { + label: t('unitags.profile.action.edit'), + onPress: (): void => setShowChangeUnitagModal(true), + Icon: Edit, + }, + { + label: t('unitags.profile.action.delete'), + onPress: (): void => setShowDeleteUnitagModal(true), + Icon: Trash, + destructive: true, + }, + ] + }, [t]) + + const refreshUnitags = async (): Promise => { + await backgroundToSidePanelMessageChannel.sendMessage({ + type: BackgroundToSidePanelRequestType.RefreshUnitags, + }) + } + + return ( + + + + + + + } + onBack={enableBack ? goToPreviousStep : undefined} + > + + {unitag && ( + <> + + + {showDeleteUnitagModal && ( + setShowDeleteUnitagModal(false)} + /> + )} + {showChangeUnitagModal && ( + setShowChangeUnitagModal(false)} + /> + )} + + + )} + + + + ) +} diff --git a/apps/extension/src/app/features/unitags/UnitagChooseProfilePicScreen.tsx b/apps/extension/src/app/features/unitags/UnitagChooseProfilePicScreen.tsx new file mode 100644 index 00000000..589452e5 --- /dev/null +++ b/apps/extension/src/app/features/unitags/UnitagChooseProfilePicScreen.tsx @@ -0,0 +1,73 @@ +import { useCallback, useEffect } from 'react' +import { useTranslation } from 'react-i18next' +import { OnboardingScreen } from 'src/app/features/onboarding/OnboardingScreen' +import { useOnboardingSteps } from 'src/app/features/onboarding/OnboardingStepsContext' +import { useUnitagClaimContext } from 'src/app/features/unitags/UnitagClaimContext' +import { backgroundToSidePanelMessageChannel } from 'src/background/messagePassing/messageChannels' +import { BackgroundToSidePanelRequestType } from 'src/background/messagePassing/types/requests' +import { Flex, Square } from 'ui/src' +import { Person } from 'ui/src/components/icons' +import { fonts, iconSizes } from 'ui/src/theme' +import Trace from 'uniswap/src/features/telemetry/Trace' +import { ExtensionUnitagClaimScreens } from 'uniswap/src/types/screens/extension' +import { logger } from 'utilities/src/logger/logger' +import { extensionNftModalProps } from 'wallet/src/features/unitags/ChooseNftModal' +import { UnitagChooseProfilePicContent } from 'wallet/src/features/unitags/UnitagChooseProfilePicContent' +import { useAccountAddressFromUrlWithThrow } from 'wallet/src/features/wallet/hooks' + +export function UnitagChooseProfilePicScreen(): JSX.Element { + const { t } = useTranslation() + const { goToNextStep, goToPreviousStep } = useOnboardingSteps() + const { unitag, entryPoint, setProfilePicUri } = useUnitagClaimContext() + const address = useAccountAddressFromUrlWithThrow() + + const onNavigateContinue = useCallback( + async (imageUri: string | undefined) => { + setProfilePicUri(imageUri) + // TODO WALL-5067 move claim logic out of UnitagChooseProfilePicContent and integrate message sending + await backgroundToSidePanelMessageChannel.sendMessage({ + type: BackgroundToSidePanelRequestType.RefreshUnitags, + }) + goToNextStep() + }, + [setProfilePicUri, goToNextStep], + ) + + useEffect(() => { + if (!unitag) { + logger.warn('UnitagChooseProfilePicScreen.tsx', 'render', 'unitag is empty when it should have a value') + } + }, [unitag]) + + return ( + + + + + } + title={t('unitags.onboarding.profile.title')} + subtitle={t('unitags.onboarding.profile.subtitle')} + onBack={goToPreviousStep} + > + + + + + + ) +} diff --git a/apps/extension/src/app/features/unitags/UnitagClaimBackground.tsx b/apps/extension/src/app/features/unitags/UnitagClaimBackground.tsx new file mode 100644 index 00000000..44fc9538 --- /dev/null +++ b/apps/extension/src/app/features/unitags/UnitagClaimBackground.tsx @@ -0,0 +1,62 @@ +import { PropsWithChildren, useMemo } from 'react' +import { Flex, useIsDarkMode } from 'ui/src' +import { + UNITAGS_ADRIAN_DARK, + UNITAGS_ADRIAN_LIGHT, + UNITAGS_ANDREW_DARK, + UNITAGS_ANDREW_LIGHT, + UNITAGS_BRYAN_DARK, + UNITAGS_BRYAN_LIGHT, + UNITAGS_CALLIL_DARK, + UNITAGS_CALLIL_LIGHT, + UNITAGS_FRED_DARK, + UNITAGS_FRED_LIGHT, + UNITAGS_MAGGIE_DARK, + UNITAGS_MAGGIE_LIGHT, + UNITAGS_PHIL_DARK, + UNITAGS_PHIL_LIGHT, + UNITAGS_SPENCER_DARK, + UNITAGS_SPENCER_LIGHT, +} from 'ui/src/assets' +import { zIndexes } from 'ui/src/theme' +import { IconCloud } from 'uniswap/src/components/IconCloud/IconCloud' + +export function UnitagClaimBackground({ children }: PropsWithChildren<{ blurAll: boolean }>): JSX.Element { + const isDarkMode = useIsDarkMode() + + const unitags = useMemo( + () => + isDarkMode + ? [ + { logoUrl: UNITAGS_ADRIAN_DARK }, + { logoUrl: UNITAGS_ANDREW_DARK }, + { logoUrl: UNITAGS_BRYAN_DARK }, + { logoUrl: UNITAGS_CALLIL_DARK }, + { logoUrl: UNITAGS_FRED_DARK }, + { logoUrl: UNITAGS_MAGGIE_DARK }, + { logoUrl: UNITAGS_PHIL_DARK }, + { logoUrl: UNITAGS_SPENCER_DARK }, + ] + : [ + { logoUrl: UNITAGS_ADRIAN_LIGHT }, + { logoUrl: UNITAGS_ANDREW_LIGHT }, + { logoUrl: UNITAGS_BRYAN_LIGHT }, + { logoUrl: UNITAGS_CALLIL_LIGHT }, + { logoUrl: UNITAGS_FRED_LIGHT }, + { logoUrl: UNITAGS_MAGGIE_LIGHT }, + { logoUrl: UNITAGS_PHIL_LIGHT }, + { logoUrl: UNITAGS_SPENCER_LIGHT }, + ], + [isDarkMode], + ) + + return ( + + + {children} + + + + + ) +} diff --git a/apps/extension/src/app/features/unitags/UnitagClaimContext.tsx b/apps/extension/src/app/features/unitags/UnitagClaimContext.tsx new file mode 100644 index 00000000..762036c4 --- /dev/null +++ b/apps/extension/src/app/features/unitags/UnitagClaimContext.tsx @@ -0,0 +1,49 @@ +import { createContext, PropsWithChildren, useContext, useMemo, useState } from 'react' +import { ExtensionScreens } from 'uniswap/src/types/screens/extension' +import { UnitagEntryPoint } from 'uniswap/src/types/screens/mobile' + +type UnitagClaimContextType = { + unitag?: string + profilePicUri?: string + entryPoint: UnitagEntryPoint + setUnitag: (unitag: string | undefined) => void + setProfilePicUri: (profilePicUri: string | undefined) => void + setEntryPoint: (entryPoint: UnitagEntryPoint) => void +} + +const initialState: UnitagClaimContextType = { + unitag: undefined, + profilePicUri: undefined, + entryPoint: ExtensionScreens.Home, + setUnitag: () => {}, + setProfilePicUri: () => {}, + setEntryPoint: () => {}, +} + +const UnitagClaimContext = createContext(initialState) + +/** + * Context used to manage unitag related data for the unitag claims app + */ +export function UnitagClaimContextProvider({ children }: PropsWithChildren): JSX.Element { + const [unitag, setUnitag] = useState(initialState.unitag) + const [profilePicUri, setProfilePicUri] = useState(initialState.profilePicUri) + const [entryPoint, setEntryPoint] = useState(initialState.entryPoint) + + const value: UnitagClaimContextType = useMemo(() => { + return { + unitag, + profilePicUri, + entryPoint, + setUnitag, + setProfilePicUri, + setEntryPoint, + } + }, [entryPoint, profilePicUri, unitag]) + + return {children} +} + +export function useUnitagClaimContext(): UnitagClaimContextType { + return useContext(UnitagClaimContext) +} diff --git a/apps/extension/src/app/features/unitags/UnitagConfirmationScreen.tsx b/apps/extension/src/app/features/unitags/UnitagConfirmationScreen.tsx new file mode 100644 index 00000000..d35cd2da --- /dev/null +++ b/apps/extension/src/app/features/unitags/UnitagConfirmationScreen.tsx @@ -0,0 +1,66 @@ +import { useEffect } from 'react' +import { useTranslation } from 'react-i18next' +import { OnboardingScreen } from 'src/app/features/onboarding/OnboardingScreen' +import { useOnboardingSteps } from 'src/app/features/onboarding/OnboardingStepsContext' +import { useUnitagClaimContext } from 'src/app/features/unitags/UnitagClaimContext' +import { closeCurrentTab } from 'src/app/navigation/utils' +import { Button, Flex, Text } from 'ui/src' +import { UNITAG_SUFFIX } from 'uniswap/src/features/unitags/constants' +import { logger } from 'utilities/src/logger/logger' +import { UnitagWithProfilePicture } from 'wallet/src/features/unitags/UnitagWithProfilePicture' +import { useAccountAddressFromUrlWithThrow } from 'wallet/src/features/wallet/hooks' + +export function UnitagConfirmationScreen(): JSX.Element { + const { t } = useTranslation() + + const address = useAccountAddressFromUrlWithThrow() + const { unitag, profilePicUri } = useUnitagClaimContext() + const { goToNextStep } = useOnboardingSteps() + + const onPressCustomize = (): void => { + // Assumes edit profile screen is next step. Uses onboarding steps for consistent nav animation + goToNextStep() + } + + useEffect(() => { + if (!unitag) { + logger.warn('UnitagConfirmationScreen.tsx', 'render', 'unitag is empty when it should have a value') + } + }, [unitag]) + + if (!unitag) { + return <> + } + + return ( + + + + + + + + {t('unitags.claim.confirmation.success.long')} + + + {t('unitags.claim.confirmation.description', { + unitagAddress: `${unitag}${UNITAG_SUFFIX}`, + })} + + + + + + + + + + + + + ) +} diff --git a/apps/extension/src/app/features/unitags/UnitagCreateUsernameScreen.tsx b/apps/extension/src/app/features/unitags/UnitagCreateUsernameScreen.tsx new file mode 100644 index 00000000..af58d020 --- /dev/null +++ b/apps/extension/src/app/features/unitags/UnitagCreateUsernameScreen.tsx @@ -0,0 +1,60 @@ +import { useCallback } from 'react' +import { useTranslation } from 'react-i18next' +import { OnboardingScreen } from 'src/app/features/onboarding/OnboardingScreen' +import { useOnboardingSteps } from 'src/app/features/onboarding/OnboardingStepsContext' +import { useUnitagClaimContext } from 'src/app/features/unitags/UnitagClaimContext' +import { Flex, Square } from 'ui/src' +import { Person } from 'ui/src/components/icons' +import { iconSizes } from 'ui/src/theme' +import Trace from 'uniswap/src/features/telemetry/Trace' +import { ClaimUnitagContent, ClaimUnitagContentProps } from 'uniswap/src/features/unitags/ClaimUnitagContent' +import { ExtensionScreens, ExtensionUnitagClaimScreens } from 'uniswap/src/types/screens/extension' +import { useAccountAddressFromUrlWithThrow } from 'wallet/src/features/wallet/hooks' + +type onNavigateContinueType = Exclude + +export function UnitagCreateUsernameScreen(): JSX.Element { + const { t } = useTranslation() + const { goToNextStep, goToPreviousStep } = useOnboardingSteps() + const { setUnitag, setEntryPoint } = useUnitagClaimContext() + const address = useAccountAddressFromUrlWithThrow() + + const onNavigateContinue = useCallback( + ({ unitag, entryPoint }: Parameters[0]) => { + setUnitag(unitag) + setEntryPoint(entryPoint) + + goToNextStep() + }, + [goToNextStep, setEntryPoint, setUnitag], + ) + + return ( + + + + + } + title={t('unitags.onboarding.claim.title.choose')} + subtitle={t('unitags.onboarding.claim.subtitle')} + onBack={goToPreviousStep} + > + + + + + + ) +} diff --git a/apps/extension/src/app/features/unitags/UnitagIntroScreen.tsx b/apps/extension/src/app/features/unitags/UnitagIntroScreen.tsx new file mode 100644 index 00000000..63cab7ca --- /dev/null +++ b/apps/extension/src/app/features/unitags/UnitagIntroScreen.tsx @@ -0,0 +1,75 @@ +import { useEffect } from 'react' +import { useTranslation } from 'react-i18next' +import { useOnboardingSteps } from 'src/app/features/onboarding/OnboardingStepsContext' +import { Terms } from 'src/app/features/onboarding/Terms' +import { UnitagClaimRoutes } from 'src/app/navigation/constants' +import { navigate } from 'src/app/navigation/state' +import { Button, Flex, GeneratedIcon, Text } from 'ui/src' +import { Bolt, Coupon, Person } from 'ui/src/components/icons' +import { useUnitagsAddressQuery } from 'uniswap/src/data/apiClients/unitagsApi/useUnitagsAddressQuery' +import { useAccountAddressFromUrlWithThrow } from 'wallet/src/features/wallet/hooks' + +const CONTAINER_WIDTH = 531 +const TERMS_WIDTH = 300 + +export function UnitagIntroScreen(): JSX.Element { + const { t } = useTranslation() + const { goToNextStep } = useOnboardingSteps() + + const address = useAccountAddressFromUrlWithThrow() + const { data: unitag } = useUnitagsAddressQuery({ + params: address ? { address } : undefined, + }) + + useEffect(() => { + if (unitag?.address) { + navigate(`/${UnitagClaimRoutes.EditProfile}`) + } + }, [unitag]) + + return ( + + + + + {t('unitags.extension.intro.title')} + + + {t('unitags.extension.intro.description')} + + + + + + + + + + + + + + + + + + + + + + + ) +} + +function UnitagIntroPill({ Icon, text }: { Icon: GeneratedIcon; text: string }): JSX.Element { + return ( + + + + {text} + + + ) +} diff --git a/apps/extension/src/app/features/warnings/StorageWarningModal.tsx b/apps/extension/src/app/features/warnings/StorageWarningModal.tsx new file mode 100644 index 00000000..78c1a785 --- /dev/null +++ b/apps/extension/src/app/features/warnings/StorageWarningModal.tsx @@ -0,0 +1,41 @@ +import { useTranslation } from 'react-i18next' +import { ONBOARDING_CONTENT_WIDTH } from 'src/app/features/onboarding/utils' +import { useCheckLowStorage } from 'src/app/features/warnings/useCheckLowStorage' +import { AppRoutes, SettingsRoutes } from 'src/app/navigation/constants' +import { useExtensionNavigation } from 'src/app/navigation/utils' +import { spacing } from 'ui/src/theme' +import { WarningSeverity } from 'uniswap/src/components/modals/WarningModal/types' +import { WarningModal } from 'uniswap/src/components/modals/WarningModal/WarningModal' +import { ModalName } from 'uniswap/src/features/telemetry/constants' + +type StorageWarningModalProps = { + isOnboarding: boolean +} + +export function StorageWarningModal({ isOnboarding }: StorageWarningModalProps): JSX.Element | null { + const { t } = useTranslation() + const { navigateTo } = useExtensionNavigation() + const { showStorageWarning, onStorageWarningClose } = useCheckLowStorage({ isOnboarding }) + + return ( + { + onStorageWarningClose() + navigateTo(`/${AppRoutes.Settings}/${SettingsRoutes.ViewRecoveryPhrase}`) + } + } + /> + ) +} diff --git a/apps/extension/src/app/features/warnings/useCheckLowStorage.ts b/apps/extension/src/app/features/warnings/useCheckLowStorage.ts new file mode 100644 index 00000000..87b9dfcf --- /dev/null +++ b/apps/extension/src/app/features/warnings/useCheckLowStorage.ts @@ -0,0 +1,48 @@ +import { useCallback, useEffect, useState } from 'react' +import { GlobalErrorEvent } from 'src/app/events/constants' +import { globalEventEmitter } from 'src/app/events/global' +import { logger } from 'utilities/src/logger/logger' + +const REMAINING_STORAGE_THRESHOLD_BYTES = 500000 // 500KB + +export function useCheckLowStorage({ isOnboarding }: { isOnboarding: boolean }): { + showStorageWarning: boolean + onStorageWarningClose: () => void +} { + const [hasShownWarning, setHasShownWarning] = useState(false) + const [showStorageWarning, setShowStorageWarning] = useState(false) + + const onStorageWarningClose = useCallback(() => setShowStorageWarning(false), []) + const triggerStorageWarning = useCallback((): void => { + if (!hasShownWarning) { + setShowStorageWarning(true) + setHasShownWarning(true) + } + }, [hasShownWarning]) + + useEffect(() => { + if (!isOnboarding) { + navigator.storage + .estimate() + .then(({ quota }) => { + if (quota && quota < REMAINING_STORAGE_THRESHOLD_BYTES) { + triggerStorageWarning() + logger.info('useCheckLowStorage.ts', 'useCheckLowStorage', 'Low storage warning shown') + } + }) + .catch(() => {}) + } + }, [isOnboarding, triggerStorageWarning]) + + useEffect(() => { + const listener = (): void => { + triggerStorageWarning() + } + globalEventEmitter.addListener(GlobalErrorEvent.ReduxStorageExceeded, listener) + return () => { + globalEventEmitter.removeListener(GlobalErrorEvent.ReduxStorageExceeded, listener) + } + }, [triggerStorageWarning]) + + return { showStorageWarning, onStorageWarningClose } +} diff --git a/apps/extension/src/app/hooks/useGet5792DappInfo.tsx b/apps/extension/src/app/hooks/useGet5792DappInfo.tsx new file mode 100644 index 00000000..da13b34b --- /dev/null +++ b/apps/extension/src/app/hooks/useGet5792DappInfo.tsx @@ -0,0 +1,11 @@ +import { useSelector } from 'react-redux' +import { DappInfo, dappStore } from 'src/app/features/dapp/store' +import { selectMostRecent5792DappUrl } from 'src/app/features/dappRequests/slice' + +export function useGet5792DappInfo(): (DappInfo & { url: string }) | undefined { + const dappUrl = useSelector(selectMostRecent5792DappUrl) + + const dappInfo = dappUrl ? dappStore.getDappInfo(dappUrl) : undefined + + return dappUrl && dappInfo ? { ...dappInfo, url: dappUrl } : undefined +} diff --git a/apps/extension/src/app/hooks/useIsExtensionPasskeyImportEnabled.ts b/apps/extension/src/app/hooks/useIsExtensionPasskeyImportEnabled.ts new file mode 100644 index 00000000..823ac6ba --- /dev/null +++ b/apps/extension/src/app/hooks/useIsExtensionPasskeyImportEnabled.ts @@ -0,0 +1,5 @@ +import { FeatureFlags, useFeatureFlag } from '@universe/gating' + +export function useIsExtensionPasskeyImportEnabled(): boolean { + return useFeatureFlag(FeatureFlags.EmbeddedWallet) +} diff --git a/apps/extension/src/app/hooks/useIsWalletUnlocked.ts b/apps/extension/src/app/hooks/useIsWalletUnlocked.ts new file mode 100644 index 00000000..de24439d --- /dev/null +++ b/apps/extension/src/app/hooks/useIsWalletUnlocked.ts @@ -0,0 +1,60 @@ +import { useCallback, useEffect, useState } from 'react' +import { logger } from 'utilities/src/logger/logger' +import { Keyring } from 'wallet/src/features/wallet/Keyring/Keyring' +import { ENCRYPTION_KEY_STORAGE_KEY, PersistedStorage } from 'wallet/src/utils/persistedStorage' + +/** + * In order to speed up the initial load of the app and avoid a half a second loading spinner every time the sidebar opens, + * we will first do a quick light check to see if the wallet *might* be unlocked by simply checking if the encryption key + * exists in local storage, but without actually verifying that this key is valid. + * + * After the React app fully loads, we will then do a more thorough check to see if the wallet is actually unlocked. + */ + +// exported to be used in saga's +export let isWalletUnlocked: boolean | null = null + +const sessionStorage = new PersistedStorage('session') + +sessionStorage + .getItem(ENCRYPTION_KEY_STORAGE_KEY) + .then((val) => { + isWalletUnlocked = val !== undefined + }) + .catch((err) => { + logger.error(err, { + tags: { + file: 'useIsWalletUnlocked.ts', + function: 'sessionStorage.getItem', + }, + }) + }) + +export function useIsWalletUnlocked(): boolean | null { + const [isUnlocked, setIsUnlocked] = useState(isWalletUnlocked) + + const checkWalletStatus = useCallback(async () => { + isWalletUnlocked = await Keyring.isUnlocked() + setIsUnlocked(isWalletUnlocked) + }, []) + + useEffect(() => { + const listener: Parameters[0] = async (changes, namespace) => { + if (namespace === 'session' && changes[ENCRYPTION_KEY_STORAGE_KEY]) { + await checkWalletStatus() + } + } + + chrome.storage.onChanged.addListener(listener) + + return () => { + chrome.storage.onChanged.removeListener(listener) + } + }, [checkWalletStatus]) + + useEffect(() => { + checkWalletStatus() + }, [checkWalletStatus]) + + return isUnlocked +} diff --git a/apps/extension/src/app/hooks/useOpeningKeyboardShortCut.test.ts b/apps/extension/src/app/hooks/useOpeningKeyboardShortCut.test.ts new file mode 100644 index 00000000..cd9b643a --- /dev/null +++ b/apps/extension/src/app/hooks/useOpeningKeyboardShortCut.test.ts @@ -0,0 +1,152 @@ +import { State, useOpeningKeyboardShortCut } from 'src/app/hooks/useOpeningKeyboardShortCut' +import * as isAppleDeviceDep from 'src/app/utils/isAppleDevice' +import { act, renderHook } from 'src/test/test-utils' + +jest.mock('src/app/utils/isAppleDevice', () => ({ + isAppleDevice: jest.fn(), +})) + +const isAppleDevice = isAppleDeviceDep.isAppleDevice as jest.MockedFunction + +describe('useOpeningKeyboardShortCut', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('should initialize with the correct keys for an Apple device', () => { + isAppleDevice.mockReturnValue(true) + const { result } = renderHook(() => useOpeningKeyboardShortCut(false)) + + expect(result.current).toEqual([ + { + fontSize: 28, + px: '$spacing28', + title: 'Shift', + state: State.KeyUp, + }, + { + fontSize: 41, + px: '$spacing16', + title: 'Meta', + state: State.KeyUp, + }, + { + fontSize: 41, + px: '$spacing24', + title: 'U', + state: State.KeyUp, + }, + ]) + }) + + it('should initialize with the correct keys for a non-Apple device', () => { + isAppleDevice.mockReturnValue(false) + const { result } = renderHook(() => useOpeningKeyboardShortCut(false)) + + expect(result.current).toEqual([ + { + fontSize: 28, + px: '$spacing28', + title: 'Shift', + state: State.KeyUp, + }, + { + fontSize: 28, + px: '$spacing12', + title: 'Ctrl', + state: State.KeyUp, + }, + { + fontSize: 41, + px: '$spacing24', + title: 'U', + state: State.KeyUp, + }, + ]) + }) + + it('should handle keyDown and keyUp events', () => { + isAppleDevice.mockReturnValue(false) + const { result } = renderHook(() => useOpeningKeyboardShortCut(false)) + + act(() => { + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Shift' })) + }) + + expect(result.current).toEqual([ + { + fontSize: 28, + px: '$spacing28', + title: 'Shift', + state: State.KeyDown, + }, + { + fontSize: 28, + px: '$spacing12', + title: 'Ctrl', + state: State.KeyUp, + }, + { + fontSize: 41, + px: '$spacing24', + title: 'U', + state: State.KeyUp, + }, + ]) + + act(() => { + window.dispatchEvent(new KeyboardEvent('keyup', { key: 'Shift' })) + }) + + expect(result.current).toEqual([ + { + fontSize: 28, + px: '$spacing28', + title: 'Shift', + state: State.KeyUp, + }, + { + fontSize: 28, + px: '$spacing12', + title: 'Ctrl', + state: State.KeyUp, + }, + { + fontSize: 41, + px: '$spacing24', + title: 'U', + state: State.KeyUp, + }, + ]) + }) + + it('should highlight keys when shortCutPressed is true', () => { + isAppleDevice.mockReturnValue(false) + const { result, rerender } = renderHook((props) => useOpeningKeyboardShortCut(props), { + initialProps: false, + }) + + rerender(true) + + expect(result.current).toEqual([ + { + fontSize: 28, + px: '$spacing28', + title: 'Shift', + state: State.Highlighted, + }, + { + fontSize: 28, + px: '$spacing12', + title: 'Ctrl', + state: State.Highlighted, + }, + { + fontSize: 41, + px: '$spacing24', + title: 'U', + state: State.Highlighted, + }, + ]) + }) +}) diff --git a/apps/extension/src/app/hooks/useOpeningKeyboardShortCut.ts b/apps/extension/src/app/hooks/useOpeningKeyboardShortCut.ts new file mode 100644 index 00000000..839910b6 --- /dev/null +++ b/apps/extension/src/app/hooks/useOpeningKeyboardShortCut.ts @@ -0,0 +1,78 @@ +import { useEffect, useReducer } from 'react' +import { KeyboardKeyProps } from 'src/app/features/onboarding/KeyboardKey' +import { isAppleDevice } from 'src/app/utils/isAppleDevice' + +const KEY_LONG_TEXT_FONT_SIZE = 28 +const KEY_SHORT_TEXT_FONT_SIZE = 41 + +// export for tests +export enum State { + KeyUp = 0, + KeyDown = 1, + Highlighted = 2, +} + +type ReducerAction = { type: 'keyUp' | 'keyDown' | 'highlight'; key: string } | { type: 'highlight' } + +export const useOpeningKeyboardShortCut = (shortCutPressed: boolean): KeyboardKeyProps[] => { + // oxlint-disable-next-line consistent-return + const reducer = (state: KeyboardKeyProps[], action: ReducerAction): KeyboardKeyProps[] => { + switch (action.type) { + case 'keyDown': + return state.map((key) => (key.title.toLowerCase() === action.key ? { ...key, state: State.KeyDown } : key)) + case 'keyUp': + return state.map((key) => + key.title.toLowerCase() === action.key || + // after pressing Cmd+ keyUp event would only be fired for Cmd, this would "simulate" keyDown for letter + // context: https://github.com/electron/electron/issues/5188 + (action.key === 'meta' && key.title.length === 1) + ? { ...key, state: shortCutPressed ? State.Highlighted : State.KeyUp } + : key, + ) + case 'highlight': + return state.map((key) => ({ ...key, state: State.Highlighted })) + } + } + + const [keys, dispatch] = useReducer(reducer, [ + { + fontSize: KEY_LONG_TEXT_FONT_SIZE, + px: '$spacing28', + title: 'Shift', + state: State.KeyUp, + }, + isAppleDevice() + ? { + fontSize: KEY_SHORT_TEXT_FONT_SIZE, + px: '$spacing16', + title: 'Meta', + state: State.KeyUp, + } + : { + fontSize: KEY_LONG_TEXT_FONT_SIZE, + px: '$spacing12', + title: 'Ctrl', + state: State.KeyUp, + }, + { fontSize: KEY_SHORT_TEXT_FONT_SIZE, px: '$spacing24', title: 'U', state: State.KeyUp }, + ]) + + useEffect(() => { + if (shortCutPressed) { + dispatch({ type: 'highlight' }) + } + }, [shortCutPressed]) + + useEffect(() => { + const keyDownHandler = (event: KeyboardEvent): void => dispatch({ type: 'keyDown', key: event.key.toLowerCase() }) + const keyUpHandler = (event: KeyboardEvent): void => dispatch({ type: 'keyUp', key: event.key.toLowerCase() }) + window.addEventListener('keydown', keyDownHandler) + window.addEventListener('keyup', keyUpHandler) + + return () => { + window.removeEventListener('keydown', keyDownHandler) + window.removeEventListener('keyup', keyUpHandler) + } + }, []) + return keys +} diff --git a/apps/extension/src/app/hooks/useOptimizedSearchParams.tsx b/apps/extension/src/app/hooks/useOptimizedSearchParams.tsx new file mode 100644 index 00000000..9649e38c --- /dev/null +++ b/apps/extension/src/app/hooks/useOptimizedSearchParams.tsx @@ -0,0 +1,30 @@ +import { useEffect, useState } from 'react' +import { createSearchParams } from 'react-router' +import { getRouter } from 'src/app/navigation/state' +import { sleep } from 'utilities/src/time/timing' + +const getSearchParams = (): URLSearchParams => createSearchParams(new URLSearchParams(window.location.hash.slice(2))) + +/** + * It's just like useSearchParams but avoids re-rendering on every page navigation + */ + +export function useOptimizedSearchParams(): URLSearchParams { + const [searchParams, setSearchParams] = useState(getSearchParams) + + useEffect(() => { + return getRouter().subscribe(async () => { + // react-router calls this before it actually updates the url bar :/ + await sleep(0) + setSearchParams((prev) => { + const next = getSearchParams() + if (prev.toString() !== next.toString()) { + return next + } + return prev + }) + }) + }, []) + + return searchParams +} diff --git a/apps/extension/src/app/navigation/HideContentsWhenSidebarBecomesInactive.tsx b/apps/extension/src/app/navigation/HideContentsWhenSidebarBecomesInactive.tsx new file mode 100644 index 00000000..a91cb1c1 --- /dev/null +++ b/apps/extension/src/app/navigation/HideContentsWhenSidebarBecomesInactive.tsx @@ -0,0 +1,31 @@ +import { PropsWithChildren, useEffect } from 'react' +import { Flex } from 'ui/src' +import { useIsChromeWindowFocusedWithTimeout } from 'uniswap/src/extension/useIsChromeWindowFocused' +import { ONE_MINUTE_MS } from 'utilities/src/time/time' +import { LandingBackground } from 'wallet/src/components/landing/LandingBackground' +import { useWalletNavigation } from 'wallet/src/contexts/WalletNavigationContext' + +// The sidebar becomes "inactive" when this amount of time has passed since the window lost focus. +const INACTIVITY_TIMEOUT = 15 * ONE_MINUTE_MS + +export function HideContentsWhenSidebarBecomesInactive({ children }: PropsWithChildren): JSX.Element { + const isChromeWindowFocused = useIsChromeWindowFocusedWithTimeout(INACTIVITY_TIMEOUT) + + const { navigateToAccountTokenList } = useWalletNavigation() + + useEffect(() => { + if (!isChromeWindowFocused) { + // We navigate to the homepage because we'll lose the local state when the sidebar becomes active again, + // and we want to avoid the user making mistakes because their swap/flow state was lost. + navigateToAccountTokenList() + } + }, [isChromeWindowFocused, navigateToAccountTokenList]) + + return isChromeWindowFocused ? ( + <>{children} + ) : ( + + + + ) +} diff --git a/apps/extension/src/app/navigation/constants.ts b/apps/extension/src/app/navigation/constants.ts new file mode 100644 index 00000000..5b50dec4 --- /dev/null +++ b/apps/extension/src/app/navigation/constants.ts @@ -0,0 +1,55 @@ +export { HomeTabs } from 'uniswap/src/types/screens/extension' + +export enum TopLevelRoutes { + Onboarding = 'onboarding', + Notifications = 'notifications', +} + +export enum OnboardingRoutes { + Create = 'create', + Import = 'import', + ImportPasskey = 'import-passkey', + Reset = 'reset', + ResetScan = 'reset-scan', + Scan = 'scan', + SelectImportMethod = 'select-import-method', + UnsupportedBrowser = 'unsupported-browser', +} + +export enum UnitagClaimRoutes { + ClaimIntro = 'claim-intro', + EditProfile = 'edit-profile', +} + +export enum AppRoutes { + AccountSwitcher = 'account-switcher', + Home = '', + Receive = 'receive', + Requests = 'requests', + Settings = 'settings', + Swap = 'swap', + Send = 'send', +} + +export enum HomeQueryParams { + Tab = 'tab', +} + +export enum SettingsRoutes { + BackupRecoveryPhrase = 'backup-recovery-phrase', + BiometricUnlockSetUp = 'biometric-unlock-set-up', + DevMenu = 'dev-menu', + DeviceAccess = 'device-access', + HashcashBenchmark = 'hashcash-benchmark', + ManageConnections = 'manage-connections', + RemoveRecoveryPhrase = 'remove-recovery-phrase', + SessionsDebug = 'sessions-debug', + SmartWallet = 'smart-wallet', + Storage = 'storage', + ViewRecoveryPhrase = 'view-recovery-phrase', +} + +export enum RemoveRecoveryPhraseRoutes { + Wallets = 'wallets', + Verify = 'verify', +} diff --git a/apps/extension/src/app/navigation/focusOrCreateOnboardingTab.ts b/apps/extension/src/app/navigation/focusOrCreateOnboardingTab.ts new file mode 100644 index 00000000..698d8ed9 --- /dev/null +++ b/apps/extension/src/app/navigation/focusOrCreateOnboardingTab.ts @@ -0,0 +1,38 @@ +import { TopLevelRoutes } from 'src/app/navigation/constants' +import { onboardingMessageChannel } from 'src/background/messagePassing/messageChannels' +import { OnboardingMessageType } from 'src/background/messagePassing/types/ExtensionMessages' + +export async function focusOrCreateOnboardingTab(page?: string): Promise { + const extension = await chrome.management.getSelf() + + const tabs = await chrome.tabs.query({ url: `chrome-extension://${extension.id}/onboarding.html*` }) + const tab = tabs[0] + + const url = 'onboarding.html#/' + (page ? page : TopLevelRoutes.Onboarding) + + if (!tab?.id) { + await chrome.tabs.create({ url }) + return + } + + await chrome.tabs.update(tab.id, { + active: true, + highlighted: true, + // We only want to update the URL if we're navigating to a specific page. + // Otherwise, just focus the existing tab without overriding the current URL. + url: page ? url : undefined, + }) + + if (page) { + // When navigating to a specific page, we need to reload the tab to ensure that the app state is reset and the store synchronization is properly initialized. + // This is necessary to handle the edge case where the user leaves a completed onboarding tab open (with synchronization paused) + // and then clicks on the "forgot password" link. + await chrome.tabs.reload(tab.id) + } + + await chrome.windows.update(tab.windowId, { focused: true }) + + await onboardingMessageChannel.sendMessage({ + type: OnboardingMessageType.HighlightOnboardingTab, + }) +} diff --git a/apps/extension/src/app/navigation/navigation.tsx b/apps/extension/src/app/navigation/navigation.tsx new file mode 100644 index 00000000..35752120 --- /dev/null +++ b/apps/extension/src/app/navigation/navigation.tsx @@ -0,0 +1,286 @@ +import { useMutation } from '@tanstack/react-query' +import { useEffect, useMemo, useRef } from 'react' +import { useSelector } from 'react-redux' +import { NavigationType, Outlet, ScrollRestoration, useLocation } from 'react-router' +import { AutoLockProvider } from 'src/app/components/AutoLockProvider' +import { SmartWalletNudgeModals } from 'src/app/components/modals/SmartWalletNudgeModals' +import { SmartWalletNudgesProvider } from 'src/app/context/SmartWalletNudgesContext' +import { DappRequestQueue } from 'src/app/features/dappRequests/DappRequestQueue' +import { ForceUpgradeModal } from 'src/app/features/forceUpgrade/ForceUpgradeModal' +import { HomeScreen } from 'src/app/features/home/HomeScreen' +import { Locked } from 'src/app/features/lockScreen/Locked' +import { NotificationToastWrapper } from 'src/app/features/notifications/NotificationToastWrapper' +import { useIsWalletUnlocked } from 'src/app/hooks/useIsWalletUnlocked' +import { AppRoutes } from 'src/app/navigation/constants' +import { focusOrCreateOnboardingTab } from 'src/app/navigation/focusOrCreateOnboardingTab' +import { HideContentsWhenSidebarBecomesInactive } from 'src/app/navigation/HideContentsWhenSidebarBecomesInactive' +import { SidebarNavigationProvider } from 'src/app/navigation/providers' +import { useRouterState } from 'src/app/navigation/state' +import { isOnboardedSelector } from 'src/app/utils/isOnboardedSelector' +import { AnimatePresence, Flex, SpinningLoader, styled } from 'ui/src' +import { TestnetModeBanner } from 'uniswap/src/components/banners/TestnetModeBanner' +import { useIsChromeWindowFocusedWithTimeout } from 'uniswap/src/extension/useIsChromeWindowFocused' +import { TokenPriceProvider } from 'uniswap/src/features/prices/TokenPriceContext' +import { useEvent, usePrevious } from 'utilities/src/react/hooks' +import { ONE_SECOND_MS } from 'utilities/src/time/time' +import { AccountsStoreContextProvider } from 'wallet/src/features/accounts/store/provider' +import { useHeartbeatReporter } from 'wallet/src/features/telemetry/hooks/useHeartbeatReporter' +import { useLastBalancesReporter } from 'wallet/src/features/telemetry/hooks/useLastBalancesReporter' +import { WalletUniswapProvider } from 'wallet/src/features/transactions/contexts/WalletUniswapContext' +import { QueuedOrderModal } from 'wallet/src/features/transactions/swap/modals/QueuedOrderModal' +import { TransactionHistoryUpdater } from 'wallet/src/features/transactions/TransactionHistoryUpdater' +import { NativeWalletProvider } from 'wallet/src/features/wallet/providers/NativeWalletProvider' + +export function MainContent(): JSX.Element { + const isOnboarded = useSelector(isOnboardedSelector) + + if (!isOnboarded) { + // TODO: add an error state that takes the user to fullscreen onboarding + throw new Error('you should have onboarded') + } + + return ( + <> + + + + ) +} + +/** + * Background side effects that run in the background and are not part of the main app. + * A separate component is used to avoid unnecessary re-rendering of the main app when + * these services are running. + */ +function BackgroundServices(): JSX.Element { + const isOnboarded = useSelector(isOnboardedSelector) + useHeartbeatReporter({ isOnboarded }) + useLastBalancesReporter({ isOnboarded }) + return <> +} + +enum Direction { + Left = 'left', + Right = 'right', + Up = 'up', + Down = 'down', +} + +const oppositeDirection = { + [Direction.Left]: Direction.Right, + [Direction.Right]: Direction.Left, + [Direction.Up]: Direction.Down, + [Direction.Down]: Direction.Up, +} + +// default is Right +const routeDirections = { + [AppRoutes.AccountSwitcher]: Direction.Up, + [AppRoutes.Swap]: Direction.Down, + [AppRoutes.Home]: Direction.Right, + [AppRoutes.Requests]: Direction.Right, + [AppRoutes.Receive]: Direction.Down, + [AppRoutes.Settings]: Direction.Right, + [AppRoutes.Send]: Direction.Down, +} satisfies Record + +const getAppRouteFromPathName = (pathname: string): AppRoutes | null => { + const val = (pathname.split('/')[1] || '') as AppRoutes + if (Object.values(AppRoutes).includes(val)) { + return val + } + return null +} + +export function WebNavigation(): JSX.Element { + const isLoggedIn = useIsWalletUnlocked() + const { pathname } = useLocation() + const history = useRef([]).current + if (history[0] !== pathname) { + history.unshift(pathname) + } + + let towards = Direction.Right + const routeName = getAppRouteFromPathName(pathname) + const routerState = useRouterState() + if (routeName != null) { + towards = routeDirections[routeName] + const isBackwards = routerState?.historyAction === NavigationType.Pop + if (isBackwards) { + const lastRoute = getAppRouteFromPathName(history[1] || '') + const previousDirection = lastRoute ? routeDirections[lastRoute] : 'right' + towards = oppositeDirection[previousDirection] + } + } + + // Only restore scroll if path on latest re-render is different from the previous path. + const prevPathname = usePrevious(pathname) + const shouldRestoreScroll = pathname !== prevPathname + const childrenMemo = useMemo(() => { + return ( + + + + + {isLoggedIn === null ? ( + + ) : isLoggedIn === true ? ( + + + + + + ) : ( + + )} + + + + ) + }, [isLoggedIn, pathname, towards]) + + return ( + + + + + + + {shouldRestoreScroll && } + {childrenMemo} + {isLoggedIn && } + + + + + + ) +} + +function Loading(): JSX.Element { + return ( + + + + ) +} + +const AnimatedPane = styled(Flex, { + zIndex: 1, + fill: true, + position: 'absolute', + inset: 0, + x: 0, + opacity: 1, + maxWidth: 'calc(min(535px, 100vw))', + minWidth: 319, + minHeight: '100vh', + mx: 'auto', + width: '100%', + + variants: { + towards: (dir: Direction) => ({ + enterStyle: { + opacity: 0, + zIndex: 1, + }, + exitStyle: { + zIndex: 0, + x: isVertical(dir) ? 0 : dir === 'left' ? 30 : -30, + y: !isVertical(dir) ? 0 : dir === 'up' ? 15 : -15, + opacity: 0, + }, + }), + } as const, +}) + +const isVertical = (dir: Direction): boolean => dir === 'up' || dir === 'down' + +function useConstant(c: A): A { + const out = useRef(undefined) + if (!out.current) { + out.current = c + } + return out.current +} + +function LoggedIn(): JSX.Element { + /** + * + * So, rendering directly means the internal hooks in Outlet + * will update instantly on page change, but we don't want that. + * + * Instead we run an animation on page change and keep the old page around + * until the animation completes. + * + * So what this does is "unwraps" the Outlet component in a sense, the hooks + * actually run inside *this* component instead of inside the sub-component + * Outlet. + * + * Then we wrap that in `useConstant` so it never changes. + * + * This makes it so the old page doesn't render with the new page contents + * as it does its exit animation. + * + **/ + const outletContents = Outlet({}) + const contents = useConstant(outletContents) + + // To avoid excessive API calls, we pause the transaction history updater a short time after the window loses focus. + const isChromeWindowFocused = useIsChromeWindowFocusedWithTimeout(30 * ONE_SECOND_MS) + + return ( + + {contents} + + + + {isChromeWindowFocused && } + + + + + + ) +} + +function LoggedOut(): JSX.Element { + const isOnboarded = useSelector(isOnboardedSelector) + const didOpenOnboarding = useRef(false) + + const focusOrCreateOnboardingTabMutation = useMutation({ + onSettled: () => { + // We keep track of this to avoid opening the onboarding page multiple times if this component remounts. + didOpenOnboarding.current = true + }, + mutationFn: () => { + return focusOrCreateOnboardingTab() + }, + onSuccess: () => { + // Automatically close the pop up after focusing on the onboarding tab. + window.close() + }, + }) + + const focusOrCreateOnboardingTabEvent = useEvent(focusOrCreateOnboardingTabMutation.mutate) + + useEffect(() => { + if (!focusOrCreateOnboardingTabMutation.isPending && !isOnboarded && !didOpenOnboarding.current) { + focusOrCreateOnboardingTabEvent() + } + }, [focusOrCreateOnboardingTabEvent, isOnboarded, focusOrCreateOnboardingTabMutation.isPending]) + + // If the user has not onboarded, we render nothing and let the `useEffect` above automatically close the popup. + // We could consider showing a loading spinner while the popup is being closed. + return isOnboarded ? : <> +} diff --git a/apps/extension/src/app/navigation/providers.tsx b/apps/extension/src/app/navigation/providers.tsx new file mode 100644 index 00000000..2f506583 --- /dev/null +++ b/apps/extension/src/app/navigation/providers.tsx @@ -0,0 +1,230 @@ +import { PropsWithChildren, useCallback } from 'react' +import { createSearchParams, useLocation, useNavigate } from 'react-router' +import { navigateToInterfaceFiatOnRamp } from 'src/app/features/for/utils' +import { AppRoutes, HomeQueryParams, HomeTabs } from 'src/app/navigation/constants' +import { navigate } from 'src/app/navigation/state' +import { + focusOrCreateTokensExploreTab, + focusOrCreateUniswapInterfaceTab, + SidebarLocationState, +} from 'src/app/navigation/utils' +import { uniswapUrls } from 'uniswap/src/constants/urls' +import { useEnabledChains } from 'uniswap/src/features/chains/hooks/useEnabledChains' +import { UniverseChainId } from 'uniswap/src/features/chains/types' +import { useNavigateToNftExplorerLink } from 'uniswap/src/features/nfts/hooks/useNavigateToNftExplorerLink' +import { CopyNotificationType } from 'uniswap/src/features/notifications/slice/types' +import { WalletEventName } from 'uniswap/src/features/telemetry/constants' +import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { ShareableEntity } from 'uniswap/src/types/sharing' +import { getPoolDetailsURL, getPortfolioUrl, getTokenUrl } from 'uniswap/src/utils/linking' +import { logger } from 'utilities/src/logger/logger' +import { escapeRegExp } from 'utilities/src/primitives/string' +import { noop } from 'utilities/src/react/noop' +import { useCopyToClipboard } from 'wallet/src/components/copy/useCopyToClipboard' +import { + getNavigateToSendFlowArgsInitialState, + getNavigateToSwapFlowArgsInitialState, + NavigateToExternalProfileArgs, + NavigateToFiatOnRampArgs, + NavigateToSendFlowArgs, + NavigateToSwapFlowArgs, + ShareTokenArgs, + WalletNavigationContextState, + WalletNavigationProvider, +} from 'wallet/src/contexts/WalletNavigationContext' + +export function OnboardingNavigationProvider({ children }: PropsWithChildren): JSX.Element { + return ( + + {children} + + ) +} + +export function SidebarNavigationProvider({ children }: PropsWithChildren): JSX.Element { + const navigateToAccountActivityList = useNavigateToAccountActivityList() + const navigateToAccountTokenList = useNavigateToAccountTokenList() + const navigateToSwapFlow = useNavigateToSwapFlow() + + return ( + + {children} + + ) +} + +function SharedExtensionNavigationProvider({ + children, + navigateToAccountActivityList, + navigateToAccountTokenList, + navigateToSwapFlow, +}: PropsWithChildren< + Pick< + WalletNavigationContextState, + 'navigateToAccountActivityList' | 'navigateToAccountTokenList' | 'navigateToSwapFlow' + > +>): JSX.Element { + const handleShareToken = useHandleShareToken() + const navigateToBuyOrReceiveWithEmptyWallet = useNavigateToBuyOrReceiveWithEmptyWallet() + const navigateToNftDetails = useNavigateToNftExplorerLink() + const navigateToReceive = useNavigateToReceive() + const navigateToSend = useNavigateToSend() + const navigateToTokenDetails = useNavigateToTokenDetails() + const navigateToFiatOnRamp = useNavigateToFiatOnRamp() + const navigateToExternalProfile = useCallback(({ address }: NavigateToExternalProfileArgs) => { + focusOrCreateUniswapInterfaceTab({ url: getPortfolioUrl(address) }) + }, []) + const navigateToPoolDetails = useNavigateToPoolDetails() + const navigateToAdvancedSettings = useNavigateToAdvancedSettings() + + return ( + + {children} + + ) +} + +function useHandleShareToken(): (args: ShareTokenArgs) => void { + const copyToClipboard = useCopyToClipboard() + + return useCallback( + async ({ currencyId }: ShareTokenArgs): Promise => { + const url = getTokenUrl(currencyId) + + if (!url) { + logger.error(new Error('Failed to get token URL'), { + tags: { file: 'providers.tsx', function: 'useHandleShareToken' }, + extra: { currencyId }, + }) + return + } + + await copyToClipboard({ textToCopy: url, copyType: CopyNotificationType.TokenUrl }) + + sendAnalyticsEvent(WalletEventName.ShareButtonClicked, { + entity: ShareableEntity.Token, + url, + }) + }, + [copyToClipboard], + ) +} + +function useNavigateToAccountActivityList(): () => void { + // TODO(EXT-1029): determine why we need useNavigate here + const navigateFix = useNavigate() + + return useCallback( + (): void | Promise => + navigateFix({ + pathname: AppRoutes.Home, + search: createSearchParams({ + [HomeQueryParams.Tab]: HomeTabs.Activity, + }).toString(), + }), + [navigateFix], + ) +} + +function useNavigateToAccountTokenList(): () => void { + // TODO(EXT-1029): determine why we need useNavigate here + const navigateFix = useNavigate() + + return useCallback( + (): void | Promise => + navigateFix({ + pathname: AppRoutes.Home, + search: createSearchParams({ + [HomeQueryParams.Tab]: HomeTabs.Tokens, + }).toString(), + }), + [navigateFix], + ) +} + +function useNavigateToReceive(): () => void { + return useCallback((): void => navigate(`/${AppRoutes.Receive}`), []) +} + +function useNavigateToSend(): (args: NavigateToSendFlowArgs) => void { + return useCallback((args: NavigateToSendFlowArgs): void => { + const initialState = getNavigateToSendFlowArgsInitialState(args) + + const state: SidebarLocationState = args ? { initialTransactionState: initialState } : undefined + + navigate(`/${AppRoutes.Send}`, { state }) + }, []) +} + +function useNavigateToSwapFlow(): (args: NavigateToSwapFlowArgs) => void { + const { defaultChainId } = useEnabledChains() + const location = useLocation() + return useCallback( + (args: NavigateToSwapFlowArgs): void => { + const initialState = getNavigateToSwapFlowArgsInitialState(args, defaultChainId) + + const state: SidebarLocationState = initialState ? { initialTransactionState: initialState } : undefined + + const isCurrentlyOnSwap = location.pathname === `/${AppRoutes.Swap}` + navigate(`/${AppRoutes.Swap}`, { state, replace: isCurrentlyOnSwap }) + }, + [defaultChainId, location.pathname], + ) +} + +function useNavigateToTokenDetails(): (currencyId: string) => void { + return useCallback(async (currencyId: string): Promise => { + await focusOrCreateTokensExploreTab({ currencyId }) + }, []) +} + +function useNavigateToPoolDetails(): (args: { poolId: Address; chainId: UniverseChainId }) => void { + return useCallback(async ({ poolId, chainId }: { poolId: Address; chainId: UniverseChainId }): Promise => { + await focusOrCreateUniswapInterfaceTab({ + url: getPoolDetailsURL(poolId, chainId), + // We want to reuse the active tab only if it's already in any other PDP. + // oxlint-disable-next-line security/detect-non-literal-regexp + reuseActiveTabIfItMatches: new RegExp(`^${escapeRegExp(uniswapUrls.webInterfacePoolsUrl)}`), + }) + }, []) +} + +function useNavigateToBuyOrReceiveWithEmptyWallet(): () => void { + return useCallback((): void => { + navigateToInterfaceFiatOnRamp() + }, []) +} + +function useNavigateToFiatOnRamp(): (args: NavigateToFiatOnRampArgs) => void { + return useCallback(({ prefilledCurrency }: NavigateToFiatOnRampArgs): void => { + navigateToInterfaceFiatOnRamp(prefilledCurrency?.currencyInfo?.currency.chainId) + }, []) +} + +function useNavigateToAdvancedSettings(): () => void { + return useCallback((): void => { + navigate(`/${AppRoutes.Settings}`, { state: { openAdvancedSettings: true } }) + }, []) +} diff --git a/apps/extension/src/app/navigation/state.ts b/apps/extension/src/app/navigation/state.ts new file mode 100644 index 00000000..3dae446c --- /dev/null +++ b/apps/extension/src/app/navigation/state.ts @@ -0,0 +1,85 @@ +import { useEffect, useState } from 'react' +import { createHashRouter, Location, NavigationType } from 'react-router' + +interface RouterState { + historyAction: NavigationType + location: Location +} + +/** + * Note this file is separate from SidebarApp on purpose! + * + * Because the router imports all the top-level pages, you can't import it from + * below those pages without causing circular imports. + * + * Circular imports break many things - HMR, bundle splitting, tree shaking, + * etc. + * + * So instead we use this file as a way to "push" the router into an import that + * is safe from circularity. + */ + +type RouterStateListener = (state: RouterState) => void + +let state: RouterState | null = null + +const listeners = new Set() + +export function setRouterState(next: RouterState): void { + state = next + listeners.forEach((l) => l(next)) +} + +function subscribeToRouterState(listener: RouterStateListener): () => void { + listeners.add(listener) + + if (state) { + listener(state) + } + + return () => { + listeners.delete(listener) + } +} + +export function useRouterState(): RouterState | null { + const [val, setVal] = useState(state) + + useEffect(() => { + return subscribeToRouterState(setVal) + }, []) + + return val +} + +// as far as i can tell, react-router doesn't give us this type so have to work around +type Router = ReturnType + +let router: Router | null = null + +export function setRouter(next: Router): void { + router = next +} + +export function getRouter(): Router { + if (!router) { + throw new Error('Invalid call to `getRouter` before the router was initialized') + } + return router +} + +type RouterNavigate = Router['navigate'] +type RouterNavigateArgs = Parameters + +// this is a navigate that doesn't need any useNavigate() hook, which in react router has performance issues: +// https://github.com/remix-run/react-router/issues/7634#issuecomment-1306650156 +// note: useNavigation().navigate() returns void, so making this match that function for easier swapping out +export const navigate = (to: RouterNavigateArgs[0] | number, opts?: RouterNavigateArgs[1]): void => { + if (typeof to === 'number') { + // oxlint-disable-next-line no-void -- Router navigation returns Promise requiring explicit void handling + void getRouter().navigate(to) + return + } + // oxlint-disable-next-line no-void -- Router navigation returns Promise requiring explicit void handling + void getRouter().navigate(to, opts) +} diff --git a/apps/extension/src/app/navigation/utils.ts b/apps/extension/src/app/navigation/utils.ts new file mode 100644 index 00000000..4e882539 --- /dev/null +++ b/apps/extension/src/app/navigation/utils.ts @@ -0,0 +1,243 @@ +import { To, useLocation } from 'react-router' +import { UnitagClaimRoutes } from 'src/app/navigation/constants' +import { navigate } from 'src/app/navigation/state' +import { uniswapUrls } from 'uniswap/src/constants/urls' +import { TransactionState } from 'uniswap/src/features/transactions/types/transactionState' +import { getTokenUrl } from 'uniswap/src/utils/linking' +import { logger } from 'utilities/src/logger/logger' +import { escapeRegExp } from 'utilities/src/primitives/string' +import { useEvent } from 'utilities/src/react/hooks' + +export type SidebarLocationState = + | { + initialTransactionState?: TransactionState + } + | undefined + +export const useExtensionNavigation = (): { + navigateTo: (path: To) => void + navigateBack: () => void + locationState: SidebarLocationState +} => { + const navigateTo = useEvent((path: To): void => navigate(path)) + const navigateBack = useEvent((): void => { + navigate(-1) + }) + const locationState = useLocation().state as SidebarLocationState + + return { navigateTo, navigateBack, locationState } +} + +export async function focusOrCreateUnitagTab(address: Address, page: UnitagClaimRoutes): Promise { + const extension = await chrome.management.getSelf() + + const tabs = await chrome.tabs.query({ url: `chrome-extension://${extension.id}/unitagClaim.html*` }) + const tab = tabs[0] + + const url = `unitagClaim.html#/${page}?address=${address}` + + if (!tab?.id) { + await chrome.tabs.create({ url }) + return + } + + await chrome.tabs.update(tab.id, { + active: true, + highlighted: true, + url, + }) + + await chrome.windows.update(tab.windowId, { focused: true }) +} + +export async function focusOrCreateDappRequestWindow(tabId: number | undefined, windowId: number): Promise { + const extension = await chrome.management.getSelf() + + const tabs = await chrome.tabs.query({ url: `chrome-extension://${extension.id}/fallback-popup.html*` }) + const tab = tabs[0] + + const height = 410 + const width = 330 + + const { left, top } = await calculatePopupWindowPosition({ width, height }) + + let url = `fallback-popup.html?windowId=${windowId}` + if (tabId) { + url += `&tabId=${tabId}` + } + + if (!tab?.id) { + await chrome.windows.create({ + url, + type: 'popup', + top, + left, + width, + height, + }) + return + } + + await chrome.tabs.update(tab.id, { + url, + active: true, + highlighted: true, + }) + await chrome.windows.update(tab.windowId, { focused: true, top, left, width, height }) +} + +/** + * To avoid opening too many tabs while also ensuring that we don't take over the user's active tab, + * we only update the URL of the active tab if it's already in a specific route of the Uniswap interface. + * + * If the current tab is not in that route, we open a new tab instead. + */ +export async function focusOrCreateUniswapInterfaceTab({ + url, + reuseActiveTabIfItMatches, +}: { + url: string + reuseActiveTabIfItMatches?: RegExp +}): Promise { + const tabs = await chrome.tabs.query({ active: true, currentWindow: true }) + + const activeTab = tabs[0] + const activeTabUrl = activeTab?.url + + const isInNewTabPage = activeTabUrl === 'chrome://newtab/' + + const shouldReuseActiveTab = reuseActiveTabIfItMatches + ? activeTabUrl && reuseActiveTabIfItMatches.test(activeTabUrl) + : false + + if (activeTab?.id && (shouldReuseActiveTab || isInNewTabPage)) { + await chrome.tabs.update(activeTab.id, { + active: true, + highlighted: true, + url, + }) + return + } + + await chrome.tabs.create({ url }) +} + +export async function focusOrCreateTokensExploreTab({ currencyId }: { currencyId: string }): Promise { + const url = getTokenUrl(currencyId) + + if (!url) { + logger.error(new Error('Failed to get token URL'), { + tags: { file: 'navigation/utils.ts', function: 'focusOrCreateTokensExploreTab' }, + extra: { currencyId }, + }) + return undefined + } + + return focusOrCreateUniswapInterfaceTab({ + url, + // We want to reuse the active tab only if it's already in any other TDP. + // oxlint-disable-next-line security/detect-non-literal-regexp + reuseActiveTabIfItMatches: new RegExp(`^${escapeRegExp(uniswapUrls.webInterfaceTokensUrl)}`), + }) +} + +export async function getCurrentTabAndWindowId(): Promise<{ tabId: number; windowId: number }> { + const tabs = await chrome.tabs.query({ active: true, currentWindow: true }) + if (tabs.length === 0 || !tabs[0] || typeof tabs[0].id !== 'number' || typeof tabs[0].windowId !== 'number') { + throw new Error('No active tab found or missing tab/window ID') + } + return { tabId: tabs[0].id, windowId: tabs[0].windowId } +} + +export async function closeCurrentTab(): Promise { + try { + const tab = await chrome.tabs.getCurrent() + + if (tab?.id) { + await chrome.tabs.remove(tab.id) + } else { + throw new Error('chrome.tabs.getCurrent did not return a tab with an id') + } + } catch (e) { + logger.error(e, { + tags: { + file: 'utils.ts', + function: 'closeCurrentTab', + }, + }) + } +} + +/** + * Calculates the top and left position of a centered pop up window, + * making sure it's on the same monitor as the current window. + */ +async function calculatePopupWindowPosition({ + width, + height, +}: { + width: number + height: number +}): Promise<{ left: number; top: number }> { + const currentWindow = await chrome.windows.getCurrent() + const currentWindowLeft = currentWindow.left ?? 0 + const currentWindowTop = currentWindow.top ?? 0 + const currentWindowWidth = currentWindow.width ?? width + const currentWindowHeight = currentWindow.height ?? height + + return { + left: Math.round(currentWindowLeft + (currentWindowWidth - width) / 2), + top: Math.round(currentWindowTop + (currentWindowHeight - height) / 2), + } +} + +/** + * Opens a popup window centered on the current window, making sure it's on the same monitor. + */ +export async function openPopupWindow({ + url, + width, + height, +}: { + url: string + width: number + height: number +}): Promise { + const { left, top } = await calculatePopupWindowPosition({ width, height }) + + const popupWindow = await chrome.windows.create({ + url, + type: 'popup', + width, + height, + left, + top, + }) + + return popupWindow +} + +export function closeWindow(window: chrome.windows.Window | undefined): void { + if (!window?.id) { + return + } + + chrome.windows.remove(window.id).catch((error) => { + logger.error(error, { + tags: { + file: 'navigation/utils.ts', + function: 'closeWindow', + }, + }) + }) +} + +export async function bringWindowToFront(windowId: number, options?: { centered?: boolean }): Promise { + if (options?.centered) { + const window = await chrome.windows.get(windowId) + const { left, top } = await calculatePopupWindowPosition({ width: window.width ?? 0, height: window.height ?? 0 }) + await chrome.windows.update(windowId, { left, top }) + } + + await chrome.windows.update(windowId, { focused: true }) +} diff --git a/apps/extension/src/app/saga.ts b/apps/extension/src/app/saga.ts new file mode 100644 index 00000000..acc25708 --- /dev/null +++ b/apps/extension/src/app/saga.ts @@ -0,0 +1,123 @@ +import { initDappStore } from 'src/app/features/dapp/saga' +import { + prepareAndSignDappTransactionActions, + prepareAndSignDappTransactionReducer, + prepareAndSignDappTransactionSaga, + prepareAndSignDappTransactionSagaName, +} from 'src/app/features/dappRequests/configuredSagas' +import { dappRequestApprovalWatcher } from 'src/app/features/dappRequests/dappRequestApprovalWatcherSaga' +import { dappRequestWatcher } from 'src/app/features/dappRequests/saga' +import { call, spawn } from 'typed-redux-saga' +import { getMonitoredSagaReducers, type MonitoredSaga } from 'uniswap/src/utils/saga' +import { apolloClientRef } from 'wallet/src/data/apollo/usePersistedApolloClient' +import { authActions, authReducer, authSaga, authSagaName } from 'wallet/src/features/auth/saga' +import { initProviders } from 'wallet/src/features/providers/saga' +import { + removeDelegationActions, + removeDelegationReducer, + removeDelegationSaga, + removeDelegationSagaName, +} from 'wallet/src/features/smartWallet/sagas/removeDelegationSaga' +import { + executePlanActions, + executePlanReducer, + executePlanSaga, + executePlanSagaName, + executeSwapActions, + executeSwapReducer, + executeSwapSaga, + executeSwapSagaName, + prepareAndSignSwapActions, + prepareAndSignSwapReducer, + prepareAndSignSwapSaga, + prepareAndSignSwapSagaName, +} from 'wallet/src/features/transactions/swap/configuredSagas' +import { watchTransactionEvents } from 'wallet/src/features/transactions/watcher/transactionFinalizationSaga' +import { transactionWatcher } from 'wallet/src/features/transactions/watcher/transactionWatcherSaga' +import { + editAccountActions, + editAccountReducer, + editAccountSaga, + editAccountSagaName, +} from 'wallet/src/features/wallet/accounts/editAccountSaga' +import { + createAccountsActions, + createAccountsReducer, + createAccountsSaga, + createAccountsSagaName, +} from 'wallet/src/features/wallet/create/createAccountsSaga' + +// Stateful sagas that are registered with the store on startup +const monitoredSagas: Record = { + [authSagaName]: { + name: authSagaName, + wrappedSaga: authSaga, + reducer: authReducer, + actions: authActions, + }, + [createAccountsSagaName]: { + name: createAccountsSagaName, + wrappedSaga: createAccountsSaga, + reducer: createAccountsReducer, + actions: createAccountsActions, + }, + [editAccountSagaName]: { + name: editAccountSagaName, + wrappedSaga: editAccountSaga, + reducer: editAccountReducer, + actions: editAccountActions, + }, + [prepareAndSignSwapSagaName]: { + name: prepareAndSignSwapSagaName, + wrappedSaga: prepareAndSignSwapSaga, + reducer: prepareAndSignSwapReducer, + actions: prepareAndSignSwapActions, + }, + [executeSwapSagaName]: { + name: executeSwapSagaName, + wrappedSaga: executeSwapSaga, + reducer: executeSwapReducer, + actions: executeSwapActions, + }, + [executePlanSagaName]: { + name: executePlanSagaName, + wrappedSaga: executePlanSaga, + reducer: executePlanReducer, + actions: executePlanActions, + }, + [removeDelegationSagaName]: { + name: removeDelegationSagaName, + wrappedSaga: removeDelegationSaga, + reducer: removeDelegationReducer, + actions: removeDelegationActions, + }, + [prepareAndSignDappTransactionSagaName]: { + name: prepareAndSignDappTransactionSagaName, + wrappedSaga: prepareAndSignDappTransactionSaga, + reducer: prepareAndSignDappTransactionReducer, + actions: prepareAndSignDappTransactionActions, + }, +} as const + +const sagasInitializedOnStartup = [ + initDappStore, + dappRequestApprovalWatcher, + dappRequestWatcher, + initProviders, + watchTransactionEvents, +] as const + +export const monitoredSagaReducers = getMonitoredSagaReducers(monitoredSagas) + +export function* rootExtensionSaga() { + for (const s of sagasInitializedOnStartup) { + yield* spawn(s) + } + + const apolloClient = yield* call(apolloClientRef.onReady) + yield* spawn(transactionWatcher, { apolloClient }) + + for (const m of Object.values(monitoredSagas)) { + yield* spawn(m['wrappedSaga']) + } +} diff --git a/apps/extension/src/app/utils/analytics.ts b/apps/extension/src/app/utils/analytics.ts new file mode 100644 index 00000000..7bbf9145 --- /dev/null +++ b/apps/extension/src/app/utils/analytics.ts @@ -0,0 +1,28 @@ +import '@tamagui/core/reset.css' +import 'src/app/Global.css' +import 'symbol-observable' // Needed by `reduxed-chrome-storage` as polyfill, order matters +import { EXTENSION_ORIGIN_APPLICATION } from 'src/app/version' +import { uniswapUrls } from 'uniswap/src/constants/urls' +import { getUniqueId } from 'utilities/src/device/uniqueId' +import { isTestEnv } from 'utilities/src/environment/env' +import { logger } from 'utilities/src/logger/logger' +// oxlint-disable-next-line no-restricted-imports -- Direct utilities import required for analytics initialization +import { analytics, getAnalyticsAtomDirect } from 'utilities/src/telemetry/analytics/analytics' +import { ApplicationTransport } from 'utilities/src/telemetry/analytics/ApplicationTransport' + +export async function initExtensionAnalytics(): Promise { + if (isTestEnv()) { + logger.debug('analytics.ts', 'initExtensionAnalytics', 'Skipping Amplitude initialization in test environment') + return + } + + const analyticsAllowed = await getAnalyticsAtomDirect(true) + await analytics.init({ + transportProvider: new ApplicationTransport({ + serverUrl: uniswapUrls.amplitudeProxyUrl, + appOrigin: EXTENSION_ORIGIN_APPLICATION, + }), + allowed: analyticsAllowed, + userIdGetter: getUniqueId, + }) +} diff --git a/apps/extension/src/app/utils/chrome.ts b/apps/extension/src/app/utils/chrome.ts new file mode 100644 index 00000000..42d1a6e4 --- /dev/null +++ b/apps/extension/src/app/utils/chrome.ts @@ -0,0 +1,26 @@ +/** + * Helper function to detect if user is using arc chromium browser + * Will not work until some time after (eg 1s) stylesheets are loaded + * @returns true if user is using arc browser + */ +export function isArcBrowser(): boolean { + return !!getComputedStyle(document.documentElement).getPropertyValue('--arc-palette-background') +} + +/** + * Helper function to detect if user is using an android device + * @returns true if user is using an android device + */ +export function isAndroid(): boolean { + return navigator.userAgent.toLowerCase().indexOf('android') > -1 +} + +/** + * Helper function to check if chrome extension environment supports side panel + * Some environments have the functions defined but do not do anything so needs to be explicitly checked + * @returns true if chrome environment supports side panel + */ +export function checksIfSupportsSidePanel(): boolean { + // oxlint-disable-next-line typescript/no-unnecessary-condition + return !!chrome.sidePanel && !isArcBrowser() && !isAndroid() +} diff --git a/apps/extension/src/app/utils/device/builtInBiometricCapabilitiesQuery.ts b/apps/extension/src/app/utils/device/builtInBiometricCapabilitiesQuery.ts new file mode 100644 index 00000000..7659baf2 --- /dev/null +++ b/apps/extension/src/app/utils/device/builtInBiometricCapabilitiesQuery.ts @@ -0,0 +1,106 @@ +import { queryOptions } from '@tanstack/react-query' +import { TFunction } from 'i18next' +import { GeneratedIcon } from 'ui/src' +import { Fingerprint } from 'ui/src/components/icons' +import { getChromeRuntimeWithThrow } from 'utilities/src/chrome/chrome' +import { logger } from 'utilities/src/logger/logger' +import { ReactQueryCacheKey } from 'utilities/src/reactQuery/cache' +import { QueryOptionsResult } from 'utilities/src/reactQuery/queryOptions' +import { MAX_REACT_QUERY_CACHE_TIME_MS, ONE_SECOND_MS } from 'utilities/src/time/time' + +type BuiltInBiometricCapabilities = { + name: string + icon: GeneratedIcon + hasBuiltInBiometricSensor: boolean + os: chrome.runtime.PlatformOs +} + +export function builtInBiometricCapabilitiesQuery({ + t, +}: { + t: TFunction +}): QueryOptionsResult< + BuiltInBiometricCapabilities, + Error, + BuiltInBiometricCapabilities, + [ReactQueryCacheKey.ExtensionBuiltInBiometricCapabilities] +> { + return queryOptions({ + queryKey: [ReactQueryCacheKey.ExtensionBuiltInBiometricCapabilities], + queryFn: async () => await getBuiltInBiometricCapabilities({ t }), + staleTime: 5 * ONE_SECOND_MS, + gcTime: MAX_REACT_QUERY_CACHE_TIME_MS, + }) +} + +async function getBuiltInBiometricCapabilities({ t }: { t: TFunction }): Promise { + try { + const { os } = await getChromeRuntimeWithThrow().getPlatformInfo() + + return { + os, + hasBuiltInBiometricSensor: await isUserVerifyingPlatformAuthenticatorAvailable(), + ...getPlatformAuthenticatorNameAndIcon({ os, t }), + } + } catch (err) { + // We want to log any error and then rethrow so that useQuery will return the proper error state. + + const error = new Error('Failed to get built-in biometric capabilities') + error.cause = err + + logger.error(error, { + tags: { + file: 'useBuiltInBiometricCapabilitiesQuery.ts', + function: 'getBuiltInBiometricCapabilities', + }, + }) + + throw error + } +} + +export async function isUserVerifyingPlatformAuthenticatorAvailable(): Promise { + // Check if WebAuthn is supported in this browser. + // oxlint-disable-next-line typescript/no-unnecessary-condition + if (!navigator.credentials || !navigator.credentials.create || !window.PublicKeyCredential) { + return false + } + + // Check if the device has a built-in biometric sensor (for example, Touch ID or Windows Hello). + try { + return await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable() + } catch (err) { + logger.error(err, { + tags: { + file: 'useBuiltInBiometricCapabilitiesQuery.ts', + function: 'isUserVerifyingPlatformAuthenticatorAvailable', + }, + }) + + return false + } +} + +function getPlatformAuthenticatorNameAndIcon({ t, os }: { t: TFunction; os: chrome.runtime.PlatformOs }): { + name: string + icon: GeneratedIcon +} { + switch (os) { + case 'mac': + return { + name: t('common.biometrics.touchId'), + icon: Fingerprint, + } + case 'win': + return { + name: t('common.biometrics.windowsHello'), + // TODO(WALL-6938): add Windows Hello icon + icon: Fingerprint, + } + default: + return { + name: t('common.biometrics.generic'), + icon: Fingerprint, + } + } +} diff --git a/apps/extension/src/app/utils/devtools.ts b/apps/extension/src/app/utils/devtools.ts new file mode 100644 index 00000000..fe355ad4 --- /dev/null +++ b/apps/extension/src/app/utils/devtools.ts @@ -0,0 +1,3 @@ +if (process.env.NODE_ENV === 'development' && window.location.search.includes('why-did-you-render')) { + require('./whyDidYouRender') +} diff --git a/apps/extension/src/app/utils/isAppleDevice.test.ts b/apps/extension/src/app/utils/isAppleDevice.test.ts new file mode 100644 index 00000000..558774ab --- /dev/null +++ b/apps/extension/src/app/utils/isAppleDevice.test.ts @@ -0,0 +1,56 @@ +import { isAppleDevice } from 'src/app/utils/isAppleDevice' + +describe('isAppleDevice', () => { + beforeEach(() => { + // Reset any mocks before each test + jest.resetModules() + }) + + it('should return true for macOS', () => { + Object.defineProperty(window.navigator, 'platform', { + value: 'MacIntel', + writable: true, + }) + expect(isAppleDevice()).toBe(true) + }) + + it('should return true for iPhone', () => { + Object.defineProperty(window.navigator, 'platform', { + value: 'iPhone', + writable: true, + }) + expect(isAppleDevice()).toBe(true) + }) + + it('should return true for iPad', () => { + Object.defineProperty(window.navigator, 'platform', { + value: 'iPad', + writable: true, + }) + expect(isAppleDevice()).toBe(true) + }) + + it('should return false for Windows', () => { + Object.defineProperty(window.navigator, 'platform', { + value: 'Win32', + writable: true, + }) + expect(isAppleDevice()).toBe(false) + }) + + it('should return false for Linux', () => { + Object.defineProperty(window.navigator, 'platform', { + value: 'Linux', + writable: true, + }) + expect(isAppleDevice()).toBe(false) + }) + + it('should return false for Android', () => { + Object.defineProperty(window.navigator, 'platform', { + value: 'Android', + writable: true, + }) + expect(isAppleDevice()).toBe(false) + }) +}) diff --git a/apps/extension/src/app/utils/isAppleDevice.ts b/apps/extension/src/app/utils/isAppleDevice.ts new file mode 100644 index 00000000..48adc395 --- /dev/null +++ b/apps/extension/src/app/utils/isAppleDevice.ts @@ -0,0 +1,7 @@ +/** + * Checks if the operating system is macOS. + * @returns {boolean} - True if the OS is macOS, otherwise false. + */ +export function isAppleDevice(): boolean { + return /Mac|iPod|iPhone|iPad/.test(navigator.platform) +} diff --git a/apps/extension/src/app/utils/isOnboardedSelector.ts b/apps/extension/src/app/utils/isOnboardedSelector.ts new file mode 100644 index 00000000..bfe1b7ac --- /dev/null +++ b/apps/extension/src/app/utils/isOnboardedSelector.ts @@ -0,0 +1,5 @@ +import { ExtensionState } from 'src/store/extensionReducer' + +export const isOnboardedSelector: (state: ExtensionState) => boolean = (state: ExtensionState) => { + return Object.values(state.wallet.accounts).length > 0 +} diff --git a/apps/extension/src/app/utils/provider.ts b/apps/extension/src/app/utils/provider.ts new file mode 100644 index 00000000..1f5f0d79 --- /dev/null +++ b/apps/extension/src/app/utils/provider.ts @@ -0,0 +1,54 @@ +/** + * The goal of the override logic is to asynchronously update window.ethereum to reflect the user's preference, if they decide not to set our wallet as the default. + * + * Important context: + * + * ethereum.ts injects the provider and is scoped to the main execution world + * - it has access to the window events but not the extension chrome storage queries + * injected.ts facilitates dapp <> extension interactions and is scoped to the isolated extension execution world + * - it has access to both window events and chrome storage queries, but it doesn't inject the 1193 provider into window + * - it is ran before ethereum.ts due to the ordering in manifest.json + * + * Due to these constraints, we need to use injected.ts to query the extension's local storage for the user's default wallet preference, and fire an event with the value that ethereum.ts can access. + * + * Here is the happy path flow: + * + * 1. User sets default wallet preference + * 2. Extension local storage tracks user's preference + * 3. User opens a dapp + * 4. injected.ts completes; it adds a one-time listener for a provider config (ie default wallet preference) request + * 5. ethereum.ts adds a listener for the dapp provider config response, then fires an event requesting the config from injected.js + * 6. we will optimistically inject as the default in the meantime + * 7. after receiving a response, we handle accordingly: + * + * +----------------------------+-----------------------------------------+-----------------------------------------------+ + * | Is Uniswap Default Wallet? | Provider slot occupied by other wallet? | Behavior | + * +----------------------------+-----------------------------------------+-----------------------------------------------+ + * | Yes | Yes | We override + spoof `isMetaMask` | + * | Yes | No | We override + spoof `isMetaMask` | + * | No | Yes | We do nothing (ie leave their provider alone) | + * | No | No | We override w/o spoof | + * +----------------------------+-----------------------------------------+-----------------------------------------------+ + */ + +const IS_DEFAULT_PROVIDER_KEY = 'is_default_provider' +const DEFAULT_VALUE = true + +export async function setIsDefaultProviderToStorage(isDefault: boolean): Promise { + await chrome.storage.local.set({ [IS_DEFAULT_PROVIDER_KEY]: isDefault }) +} + +export async function getIsDefaultProviderFromStorage(): Promise { + // oxlint-disable-next-line typescript/no-unnecessary-condition + const isDefaultProvider = (await chrome.storage.local.get(IS_DEFAULT_PROVIDER_KEY))?.[IS_DEFAULT_PROVIDER_KEY] + + if (isDefaultProvider !== undefined) { + const value = JSON.parse(isDefaultProvider) + + if (typeof value === 'boolean') { + return value + } + } + + return DEFAULT_VALUE +} diff --git a/apps/extension/src/app/utils/whyDidYouRender.ts b/apps/extension/src/app/utils/whyDidYouRender.ts new file mode 100644 index 00000000..ca201e74 --- /dev/null +++ b/apps/extension/src/app/utils/whyDidYouRender.ts @@ -0,0 +1,13 @@ +import whyDidYouRender from '@welldone-software/why-did-you-render' +import React from 'react' + +if (process.env.NODE_ENV === 'development' && process.env.WDYR === 'true') { + whyDidYouRender(React, { + // use this to filter down to specific component names, ie /Select.*/ + include: [/.*/], + collapseGroups: true, + logOnDifferentValues: true, + trackAllPureComponents: true, + trackHooks: true, + }) +} diff --git a/apps/extension/src/app/version.ts b/apps/extension/src/app/version.ts new file mode 100644 index 00000000..73b704c8 --- /dev/null +++ b/apps/extension/src/app/version.ts @@ -0,0 +1,2 @@ +// TODO: Add to analytics package and remove +export const EXTENSION_ORIGIN_APPLICATION = 'extension' diff --git a/apps/extension/src/assets/icons/icon128.png b/apps/extension/src/assets/icons/icon128.png new file mode 100644 index 00000000..e6562013 Binary files /dev/null and b/apps/extension/src/assets/icons/icon128.png differ diff --git a/apps/extension/src/assets/icons/icon16.png b/apps/extension/src/assets/icons/icon16.png new file mode 100644 index 00000000..281c3aa9 Binary files /dev/null and b/apps/extension/src/assets/icons/icon16.png differ diff --git a/apps/extension/src/assets/icons/icon32.png b/apps/extension/src/assets/icons/icon32.png new file mode 100644 index 00000000..1133b87b Binary files /dev/null and b/apps/extension/src/assets/icons/icon32.png differ diff --git a/apps/extension/src/assets/icons/icon48.png b/apps/extension/src/assets/icons/icon48.png new file mode 100644 index 00000000..c9526445 Binary files /dev/null and b/apps/extension/src/assets/icons/icon48.png differ diff --git a/apps/extension/src/assets/icons/lux-logo.svg b/apps/extension/src/assets/icons/lux-logo.svg new file mode 100644 index 00000000..49cf6030 --- /dev/null +++ b/apps/extension/src/assets/icons/lux-logo.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/extension/src/background/backgroundDappRequests.ts b/apps/extension/src/background/backgroundDappRequests.ts new file mode 100644 index 00000000..9fb863e9 --- /dev/null +++ b/apps/extension/src/background/backgroundDappRequests.ts @@ -0,0 +1,553 @@ +/* oxlint-disable max-lines */ +import { rpcErrors, serializeError } from '@metamask/rpc-errors' +import { removeDappConnection } from 'src/app/features/dapp/actions' +import { changeChain } from 'src/app/features/dapp/changeChain' +import { dappStore } from 'src/app/features/dapp/store' +import type { SenderTabInfo } from 'src/app/features/dappRequests/shared' +import { + type ChangeChainRequest, + type DappRequest, + type GetCapabilitiesRequest, + type RevokePermissionsRequest, +} from 'src/app/features/dappRequests/types/DappRequestTypes' +import { focusOrCreateOnboardingTab } from 'src/app/navigation/focusOrCreateOnboardingTab' +import { focusOrCreateDappRequestWindow } from 'src/app/navigation/utils' +import { + contentScriptToBackgroundMessageChannel, + contentScriptUtilityMessageChannel, + createBackgroundToSidePanelMessagePort, + type DappBackgroundPortChannel, + dappResponseMessageChannel, +} from 'src/background/messagePassing/messageChannels' +import { + BackgroundToSidePanelRequestType, + ContentScriptUtilityMessageType, + type DappRequestMessage, +} from 'src/background/messagePassing/types/requests' +import { checkAreMigrationsPending, readReduxStateFromStorage } from 'src/background/utils/persistedStateUtils' +import { getFeatureFlaggedChainIds } from 'uniswap/src/features/chains/hooks/useFeatureFlaggedChainIds' +import { getEnabledChains, hexadecimalStringToInt, toSupportedChainId } from 'uniswap/src/features/chains/utils' +import { DappRequestType, DappResponseType, EthMethod } from 'uniswap/src/features/dappRequests/types' +import { ExtensionEventName } from 'uniswap/src/features/telemetry/constants' +import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { type WindowEthereumRequestProperties } from 'uniswap/src/features/telemetry/types' +import { extractBaseUrl } from 'utilities/src/format/urls' +import { logger } from 'utilities/src/logger/logger' +import { getCapabilitiesResponse } from 'wallet/src/features/batchedTransactions/utils' +import { walletContextValue } from 'wallet/src/features/wallet/context' +import { selectHasSmartWalletConsent } from 'wallet/src/features/wallet/selectors' + +// Request classification constants for determining which requests need user interaction +const REQUEST_CLASSIFICATION = { + interactive: new Set([ + DappRequestType.RequestAccount, + DappRequestType.SendTransaction, + DappRequestType.SignMessage, + DappRequestType.SignTypedData, + DappRequestType.UniswapOpenSidebar, + DappRequestType.RequestPermissions, + DappRequestType.SendCalls, + ]), + silent: new Set([DappRequestType.ChangeChain, DappRequestType.RevokePermissions, DappRequestType.GetCapabilities]), +} as const + +const windowIdToSidebarPortMap = new Map() +// TODO EXT-1020 add timeout support to avoid memory leaks +const windowIdToPendingRequestsMap = new Map() + +chrome.runtime.onConnect.addListener(async (port) => { + const windowId = port.name + const portChannel = createBackgroundToSidePanelMessagePort(port) + windowIdToSidebarPortMap.set(windowId, portChannel) + + const pendingRequests = windowIdToPendingRequestsMap.get(windowId) + + if (pendingRequests) { + for (const pendingRequest of pendingRequests) { + await portChannel.sendMessage(pendingRequest) + } + windowIdToPendingRequestsMap.delete(windowId) + } + + // Only gets called when `port.disconnect()` is called or `port.sendMessage()` for a disconnected port + port.onDisconnect.addListener(async () => { + windowIdToSidebarPortMap.delete(windowId) + }) +}) + +let initialized = false +export function initMessageBridge(): void { + if (initialized) { + return + } + + contentScriptToBackgroundMessageChannel.addAllMessageListener((message, sender) => { + // CRITICAL: This listener must NOT be async to preserve user gesture context. + // Chrome's sidePanel.open() API requires execution within ~1ms of a user gesture. + // Using async/await here breaks the gesture context and causes the error: + // "sidePanel.open() may only be called in response to a user gesture" + + // Validate sender has required information + if (!isValidSender(sender)) { + logger.error(new Error('sender.tab id or url is not defined'), { + tags: { + file: 'backgroundDappRequests.ts', + function: 'dappMessageListener', + }, + }) + return + } + + const requestType = message.type + const windowId = sender.tab.windowId + const windowIdString = windowId.toString() + const isSidebarActive = Boolean(windowIdToSidebarPortMap.get(windowIdString)) + + // CRITICAL: Open side panel synchronously to preserve user gesture context. + // This must happen immediately, before any async operations. + if (requiresSidePanel(requestType) && !isSidebarActive) { + openSidePanelSync({ + tabId: sender.tab.id, + windowId, + onSuccess: () => { + // Process request after panel opens (async operations safe here) + handleRequestAsync({ message, sender }) + }, + onError: (error, fallbackOpened) => { + // Panel failed to open, but fallback might have succeeded + logger.error(error, { + tags: { + file: 'backgroundDappRequests.ts', + function: 'initMessageBridge', + }, + extra: { + action: 'openSidePanel', + fallbackOpened, + }, + }) + + // Revalidate sender in error callback context + if (!isValidSender(sender)) { + logger.error(new Error('Sender tab info unexpectedly invalid in error callback'), { + tags: { + file: 'backgroundDappRequests.ts', + function: 'initMessageBridge', + }, + }) + return + } + + // Queue the message for when panel/popup eventually connects + // This works for both side panel and popup window + queueMessageForPanel({ + windowId, + message, + senderTabInfo: { + id: sender.tab.id, + url: sender.tab.url, + frameUrl: getFrameUrl(sender), + favIconUrl: sender.tab.favIconUrl, + }, + }) + }, + }) + } else { + // Non-interactive request or panel already open - async handling is safe + handleRequestAsync({ message, sender }) + } + }) + + contentScriptUtilityMessageChannel.addMessageListener(ContentScriptUtilityMessageType.ErrorLog, async (message) => { + // Need to re-construct the error object from the message since the error object is not serializable + logger.error(new Error(message.message), { + tags: { + file: message.fileName, + function: message.functionName, + ...message.tags, + }, + extra: message.extra, + }) + }) + + contentScriptUtilityMessageChannel.addMessageListener( + ContentScriptUtilityMessageType.AnalyticsLog, + async (message) => { + const properties: WindowEthereumRequestProperties = { + method: message.tags['method'] ?? '', + dappUrl: message.tags['dappUrl'] ?? '', + } + const eventName = message.message + switch (eventName) { + case ExtensionEventName.UnsupportedMethodRequest: + case ExtensionEventName.UnrecognizedMethodRequest: + case ExtensionEventName.DeprecatedMethodRequest: + sendAnalyticsEvent(eventName, properties) + break + default: + break + } + }, + ) + + contentScriptUtilityMessageChannel.addMessageListener(ContentScriptUtilityMessageType.FocusOnboardingTab, () => { + focusOrCreateOnboardingTab().catch((error) => + logger.error(error, { + tags: { + file: 'backgroundDappRequests.ts', + function: 'contentScriptUtilityMessageListener', + }, + }), + ) + }) + + initialized = true +} + +/** + * Dapp requests that should be silently handled by the background worker as a proxy if the sidebar is not open + * @returns true if the request was handled, false otherwise + */ +async function handleSilentBackgroundRequest(request: DappRequest, senderTabInfo: SenderTabInfo): Promise { + const dappUrl = extractBaseUrl(senderTabInfo.url) + + if (!dappUrl) { + return false + } + + // Check for pending migrations before attempting silent handling + const migrationsPending = await checkAreMigrationsPending() + if (migrationsPending) { + logger.debug( + 'backgroundDappRequests', + 'handleSilentBackgroundRequest', + 'Migrations pending, skipping silent handling', + ) + return false + } + + // Only proceed with silent handling if no migrations are pending + switch (request.type) { + case DappRequestType.ChangeChain: + handleChainChange({ + request, + dappUrl, + tabId: senderTabInfo.id, + }).catch(() => {}) + return true + case DappRequestType.RevokePermissions: + handleRevokePermissions({ + request, + dappUrl, + tabId: senderTabInfo.id, + }).catch(() => {}) + return true + case DappRequestType.GetCapabilities: + handleGetCapabilities({ + request, + tabId: senderTabInfo.id, + }).catch(() => {}) + return true + default: + return false + } +} + +async function handleChainChange({ + request, + dappUrl, + tabId, +}: { + request: ChangeChainRequest + dappUrl: string + tabId: number +}): Promise { + await dappStore.init() + const { activeConnectedAddress } = dappStore.getDappInfo(dappUrl) ?? {} + const updatedChainId = toSupportedChainId(hexadecimalStringToInt(request.chainId)) + const provider = updatedChainId ? walletContextValue.providers.getProvider(updatedChainId) : undefined + const response = changeChain({ + provider, + dappUrl, + updatedChainId, + requestId: request.requestId, + activeConnectedAddress, + }) + + await dappResponseMessageChannel.sendMessageToTab(tabId, response) +} + +async function handleRevokePermissions({ + request, + dappUrl, + tabId, +}: { + request: RevokePermissionsRequest + dappUrl: string + tabId: number +}): Promise { + await dappStore.init() + const revokedPermissions = Object.keys(request.permissions) + + if (revokedPermissions.includes(EthMethod.EthAccounts)) { + await removeDappConnection(dappUrl) + await dappResponseMessageChannel.sendMessageToTab(tabId, { + type: DappResponseType.RevokePermissionsResponse, + requestId: request.requestId, + }) + } else { + await dappResponseMessageChannel.sendMessageToTab(tabId, { + type: DappResponseType.ErrorResponse, + error: serializeError(rpcErrors.methodNotFound()), + requestId: request.requestId, + }) + } +} + +async function handleGetCapabilities({ + request, + tabId, +}: { + request: GetCapabilitiesRequest + tabId: number +}): Promise { + try { + // Get enabled chains using the same logic as the saga + const reduxState = await readReduxStateFromStorage() + const hasSmartWalletConsent = reduxState ? selectHasSmartWalletConsent(reduxState, request.address) : false + const isTestnetModeEnabled = reduxState ? (reduxState.userSettings.isTestnetModeEnabled ?? false) : false + const featureFlaggedChainIds = getFeatureFlaggedChainIds() + const { chains: enabledChains } = getEnabledChains({ + isTestnetModeEnabled, + featureFlaggedChainIds, + }) + + const chainIds = request.chainIds?.map(hexadecimalStringToInt) ?? enabledChains.map((chain) => chain.valueOf()) + const response = await getCapabilitiesResponse({ + request, + chainIds, + hasSmartWalletConsent, + }) + + await dappResponseMessageChannel.sendMessageToTab(tabId, response) + } catch (error) { + logger.error(error, { + tags: { file: 'backgroundDappRequests.ts', function: 'handleGetCapabilities' }, + extra: { request }, + }) + + // Send error response on failure + await dappResponseMessageChannel.sendMessageToTab(tabId, { + type: DappResponseType.ErrorResponse, + error: serializeError(rpcErrors.internal()), + requestId: request.requestId, + }) + } +} + +/** + * Handles dapp requests asynchronously after the side panel has been opened (if needed). + * This function contains the original async logic that was previously in the message listener. + * Moving it here allows us to open the side panel synchronously while preserving all existing behavior. + */ +async function handleRequestAsync({ + message, + sender, +}: { + message: DappRequest + sender: chrome.runtime.MessageSender +}): Promise { + // Revalidate sender + if (!isValidSender(sender)) { + logger.error(new Error('Invalid sender tab info in handleRequestAsync'), { + tags: { + file: 'backgroundDappRequests.ts', + function: 'handleRequestAsync', + }, + extra: { + hasTab: !!sender.tab, + hasId: sender.tab?.id !== undefined, + hasUrl: !!sender.tab?.url, + }, + }) + return + } + + const senderTabInfo: SenderTabInfo = { + id: sender.tab.id, + url: sender.tab.url, + frameUrl: getFrameUrl(sender), + favIconUrl: sender.tab.favIconUrl, + } + + const windowId = sender.tab.windowId + const windowIdString = windowId.toString() + const isSidebarActive = Boolean(windowIdToSidebarPortMap.get(windowIdString)) + + // Try to handle silently if sidebar is not active + if (!isSidebarActive) { + const handled = await handleSilentBackgroundRequest(message, senderTabInfo) + if (handled) { + return + } + } + + // Handle via sidebar (queue message for processing) + await handleSidebarRequest({ + request: message, + windowId, + senderTabInfo, + }) +} + +async function handleSidebarRequest({ + request, + windowId, + senderTabInfo, +}: { + request: DappRequest + windowId: number + senderTabInfo: DappRequestMessage['senderTabInfo'] +}): Promise { + const windowIdString = windowId.toString() + const portChannel = windowIdToSidebarPortMap.get(windowIdString) + const message: DappRequestMessage = { + type: BackgroundToSidePanelRequestType.DappRequestReceived, + dappRequest: request, + senderTabInfo, + isSidebarClosed: !portChannel, + } + + if (portChannel) { + // Port exists, send message directly + try { + await portChannel.sendMessage(message) + } catch (error) { + logger.error(error, { + tags: { + file: 'backgroundDappRequests.ts', + function: 'handleSidebarRequest', + }, + }) + // Queue message if send fails + queueMessageForPanel({ windowId, message: request, senderTabInfo }) + } + } else { + // IMPORTANT: No port channel means the panel is opening or about to open. + // We do NOT call openSidePanel here because it was already opened synchronously + // in the message listener to preserve the user gesture context. + // Just queue the message - it will be processed when the panel connects. + queueMessageForPanel({ windowId, message: request, senderTabInfo }) + } +} + +/** + * Determines if a request requires the side panel to be opened for user interaction + */ +function requiresSidePanel(requestType: DappRequestType): boolean { + return REQUEST_CLASSIFICATION.interactive.has(requestType) +} + +/** + * Validates that the sender has all required tab information + */ +function isValidSender(sender?: chrome.runtime.MessageSender): sender is chrome.runtime.MessageSender & { + tab: chrome.tabs.Tab & { id: number; url: string } +} { + return sender?.tab?.id !== undefined && sender.tab.url !== undefined +} + +/** + * Opens the side panel synchronously to preserve user gesture context. + * Must be called within ~1ms of user gesture. + * Falls back to opening a popup window if side panel fails. + */ +function openSidePanelSync({ + tabId, + windowId, + onSuccess, + onError, +}: { + tabId: number + windowId: number + onSuccess: () => void + onError: (error: chrome.runtime.LastError, fallbackOpened: boolean) => void +}): void { + chrome.sidePanel.open({ tabId }, () => { + const lastError = chrome.runtime.lastError + if (lastError) { + // Try fallback to popup window - still in sync callback to preserve gesture + focusOrCreateDappRequestWindow(tabId, windowId) + .then(() => { + // Fallback succeeded - notify that we opened a window instead + onError(lastError, true) + }) + .catch((fallbackError) => { + // Even fallback failed + logger.error(fallbackError, { + tags: { + file: 'backgroundDappRequests.ts', + function: 'openSidePanelSync', + }, + extra: { action: 'fallbackToPopupWindow' }, + }) + onError(lastError, false) + }) + } else { + onSuccess() + } + }) +} + +/** + * Queues a message for processing when the side panel connects + */ +function queueMessageForPanel({ + windowId, + message, + senderTabInfo, +}: { + windowId: number + message: DappRequest + senderTabInfo: SenderTabInfo +}): void { + const windowIdString = windowId.toString() + + if (!windowIdToPendingRequestsMap.has(windowIdString)) { + windowIdToPendingRequestsMap.set(windowIdString, []) + } + + const queuedMessage: DappRequestMessage = { + type: BackgroundToSidePanelRequestType.DappRequestReceived, + dappRequest: message, + senderTabInfo, + isSidebarClosed: true, + } + + windowIdToPendingRequestsMap.get(windowIdString)?.push(queuedMessage) +} + +/** + * Gets the frame URL from the message sender if the request is from an iframe with a different origin than the top-level page + * @param sender - The message sender + * @returns The frame URL if applicable, undefined otherwise + */ +function getFrameUrl(sender: chrome.runtime.MessageSender): string | undefined { + if (!sender.tab?.url || !sender.url) { + return undefined + } + + try { + const tabOrigin = new URL(sender.tab.url).origin + const senderOrigin = new URL(sender.url).origin + const isFrame = tabOrigin !== senderOrigin + return isFrame ? sender.url : undefined + } catch (error) { + logger.error(error, { + tags: { + file: 'backgroundDappRequests.ts', + function: 'getFrameUrl', + }, + }) + return undefined + } +} diff --git a/apps/extension/src/background/backgroundStore.ts b/apps/extension/src/background/backgroundStore.ts new file mode 100644 index 00000000..5c0a5d7d --- /dev/null +++ b/apps/extension/src/background/backgroundStore.ts @@ -0,0 +1,71 @@ +import { readIsOnboardedFromStorage, readReduxStateFromStorage } from 'src/background/utils/persistedStateUtils' +import { ExtensionState } from 'src/store/extensionReducer' +import { logger } from 'utilities/src/logger/logger' + +type BackgroundState = { + isOnboarded: boolean +} + +const state: BackgroundState = { + isOnboarded: false, +} + +type OnboardingChangedListener = (isOnboarded: boolean) => void +const onboardingChangedListeners: OnboardingChangedListener[] = [] + +// Allows for multiple init attempts from different sources +let initPromise: Promise | undefined + +async function init(): Promise { + if (!initPromise) { + initPromise = initInternal() + } + + return initPromise +} + +async function initInternal(): Promise { + try { + const reduxState = await readReduxStateFromStorage() + + if (!reduxState) { + logger.debug('backgroundStore.ts', 'initInternal', 'Failed to read redux state from storage') + } + + await updateFromReduxState(reduxState) + chrome.storage.local.onChanged.addListener(async (changes) => { + const newReduxState = await readReduxStateFromStorage(changes) + await updateFromReduxState(newReduxState) + }) + } catch (error) { + logger.error(error, { + tags: { + file: 'backgroundStore.ts', + function: 'init', + }, + }) + } +} + +async function updateFromReduxState(reduxState: ExtensionState | undefined): Promise { + if (reduxState) { + updateIsOnboarded(await readIsOnboardedFromStorage()) // Can replace this with selector after migration is complete + } +} + +function updateIsOnboarded(isOnboarded: boolean): void { + if (isOnboarded !== state.isOnboarded) { + state.isOnboarded = isOnboarded + onboardingChangedListeners.forEach((listener) => listener(isOnboarded)) + } +} + +function addOnboardingChangedListener(listener: OnboardingChangedListener): void { + onboardingChangedListeners.push(listener) +} + +export const backgroundStore = { + state, + init, + addOnboardingChangedListener, +} diff --git a/apps/extension/src/background/messagePassing/messageChannels.ts b/apps/extension/src/background/messagePassing/messageChannels.ts new file mode 100644 index 00000000..b4f2c6f7 --- /dev/null +++ b/apps/extension/src/background/messagePassing/messageChannels.ts @@ -0,0 +1,321 @@ +import { + AccountResponse, + AccountResponseSchema, + ChainIdResponse, + ChainIdResponseSchema, + ChangeChainRequest, + ChangeChainRequestSchema, + ChangeChainResponse, + ChangeChainResponseSchema, + ErrorResponse, + ErrorResponseSchema, + GetAccountRequest, + GetAccountRequestSchema, + GetCallsStatusRequest, + GetCallsStatusRequestSchema, + GetCallsStatusResponse, + GetCallsStatusResponseSchema, + GetCapabilitiesRequest, + GetCapabilitiesRequestSchema, + GetCapabilitiesResponse, + GetCapabilitiesResponseSchema, + GetChainIdRequest, + GetChainIdRequestSchema, + GetPermissionsRequest, + GetPermissionsRequestSchema, + GetPermissionsResponse, + GetPermissionsResponseSchema, + RequestAccountRequest, + RequestAccountRequestSchema, + RequestPermissionsRequest, + RequestPermissionsRequestSchema, + RequestPermissionsResponse, + RequestPermissionsResponseSchema, + RevokePermissionsRequest, + RevokePermissionsRequestSchema, + RevokePermissionsResponse, + RevokePermissionsResponseSchema, + SendCallsRequest, + SendCallsRequestSchema, + SendCallsResponse, + SendCallsResponseSchema, + SendTransactionRequest, + SendTransactionRequestSchema, + SendTransactionResponse, + SendTransactionResponseSchema, + SignMessageRequest, + SignMessageRequestSchema, + SignMessageResponse, + SignMessageResponseSchema, + SignTransactionRequest, + SignTransactionRequestSchema, + SignTransactionResponse, + SignTransactionResponseSchema, + SignTypedDataRequest, + SignTypedDataRequestSchema, + SignTypedDataResponse, + SignTypedDataResponseSchema, + UniswapOpenSidebarRequest, + UniswapOpenSidebarRequestSchema, + UniswapOpenSidebarResponse, + UniswapOpenSidebarResponseSchema, +} from 'src/app/features/dappRequests/types/DappRequestTypes' +import { TypedPortMessageChannel, TypedRuntimeMessageChannel } from 'src/background/messagePassing/platform' +import { + HighlightOnboardingTabMessage, + HighlightOnboardingTabMessageSchema, + OnboardingMessageType, + SidebarOpenedMessage, + SidebarOpenedMessageSchema, +} from 'src/background/messagePassing/types/ExtensionMessages' +import { + AnalyticsLog, + AnalyticsLogSchema, + ArcBrowserCheckMessage, + ArcBrowserCheckMessageSchema, + BackgroundToSidePanelRequestType, + ContentScriptUtilityMessageType, + DappRequestMessage, + DappRequestMessageSchema, + ErrorLog, + ErrorLogSchema, + ExtensionChainChange, + ExtensionChainChangeSchema, + ExtensionToDappRequestType, + FocusOnboardingMessage, + FocusOnboardingMessageSchema, + RefreshUnitagsRequest, + RefreshUnitagsRequestSchema, + TabActivatedRequest, + TabActivatedRequestSchema, + UpdateConnectionRequest, + UpdateConnectionRequestSchema, +} from 'src/background/messagePassing/types/requests' +import { MessageParsers } from 'uniswap/src/extension/messagePassing/platform' +import { DappRequestType, DappResponseType } from 'uniswap/src/features/dappRequests/types' + +enum MessageChannelName { + DappContentScript = 'DappContentScript', + DappBackground = 'DappBackground', + DappResponse = 'DappResponse', + Onboarding = 'Onboarding', + ExternalDapp = 'ExternalDapp', + ContentScriptUtility = 'ContentScriptUtility', +} + +type OnboardingMessageSchemas = { + [OnboardingMessageType.HighlightOnboardingTab]: HighlightOnboardingTabMessage + [OnboardingMessageType.SidebarOpened]: SidebarOpenedMessage +} +const onboardingMessageParsers: MessageParsers = { + [OnboardingMessageType.HighlightOnboardingTab]: (message): HighlightOnboardingTabMessage => + HighlightOnboardingTabMessageSchema.parse(message), + [OnboardingMessageType.SidebarOpened]: (message): SidebarOpenedMessage => SidebarOpenedMessageSchema.parse(message), +} + +function createOnboardingMessageChannel(): TypedRuntimeMessageChannel { + return new TypedRuntimeMessageChannel({ + channelName: MessageChannelName.Onboarding, + messageParsers: onboardingMessageParsers, + }) +} + +type BackgroundToSidePanelMessageSchemas = { + [BackgroundToSidePanelRequestType.DappRequestReceived]: DappRequestMessage + [BackgroundToSidePanelRequestType.TabActivated]: TabActivatedRequest + [BackgroundToSidePanelRequestType.RefreshUnitags]: RefreshUnitagsRequest +} +const backgroundToSidePanelMessageParsers: MessageParsers< + BackgroundToSidePanelRequestType, + BackgroundToSidePanelMessageSchemas +> = { + [BackgroundToSidePanelRequestType.DappRequestReceived]: (message): DappRequestMessage => + DappRequestMessageSchema.parse(message), + [BackgroundToSidePanelRequestType.TabActivated]: (message): TabActivatedRequest => + TabActivatedRequestSchema.parse(message), + [BackgroundToSidePanelRequestType.RefreshUnitags]: (message): RefreshUnitagsRequest => + RefreshUnitagsRequestSchema.parse(message), +} + +function createBackgroundToSidePanelMessageChannel(): TypedRuntimeMessageChannel< + BackgroundToSidePanelRequestType, + BackgroundToSidePanelMessageSchemas +> { + return new TypedRuntimeMessageChannel({ + channelName: MessageChannelName.DappBackground, + messageParsers: backgroundToSidePanelMessageParsers, + canReceiveFromWebPage: true, + }) +} + +export function createBackgroundToSidePanelMessagePort( + port: chrome.runtime.Port, +): TypedPortMessageChannel { + return new TypedPortMessageChannel({ + channelName: MessageChannelName.DappBackground, + messageParsers: backgroundToSidePanelMessageParsers, + port, + }) +} + +type ContentScriptToBackgroundMessageSchemas = { + [DappRequestType.ChangeChain]: ChangeChainRequest + [DappRequestType.GetAccount]: GetAccountRequest + [DappRequestType.GetChainId]: GetChainIdRequest + [DappRequestType.GetPermissions]: GetPermissionsRequest + [DappRequestType.RequestAccount]: RequestAccountRequest + [DappRequestType.RequestPermissions]: RequestPermissionsRequest + [DappRequestType.RevokePermissions]: RevokePermissionsRequest + [DappRequestType.SendTransaction]: SendTransactionRequest + [DappRequestType.SignMessage]: SignMessageRequest + [DappRequestType.SignTransaction]: SignTransactionRequest + [DappRequestType.SignTypedData]: SignTypedDataRequest + [DappRequestType.UniswapOpenSidebar]: UniswapOpenSidebarRequest + [DappRequestType.SendCalls]: SendCallsRequest + [DappRequestType.GetCallsStatus]: GetCallsStatusRequest + [DappRequestType.GetCapabilities]: GetCapabilitiesRequest +} +const contentScriptToBackgroundMessageParsers: MessageParsers< + DappRequestType, + ContentScriptToBackgroundMessageSchemas +> = { + [DappRequestType.ChangeChain]: (message): ChangeChainRequest => ChangeChainRequestSchema.parse(message), + [DappRequestType.GetAccount]: (message): GetAccountRequest => GetAccountRequestSchema.parse(message), + [DappRequestType.GetChainId]: (message): GetChainIdRequest => GetChainIdRequestSchema.parse(message), + [DappRequestType.GetPermissions]: (message): GetPermissionsRequest => GetPermissionsRequestSchema.parse(message), + [DappRequestType.RequestAccount]: (message): RequestAccountRequest => RequestAccountRequestSchema.parse(message), + [DappRequestType.RequestPermissions]: (message): RequestPermissionsRequest => + RequestPermissionsRequestSchema.parse(message), + [DappRequestType.RevokePermissions]: (message): RevokePermissionsRequest => + RevokePermissionsRequestSchema.parse(message), + [DappRequestType.SendTransaction]: (message): SendTransactionRequest => SendTransactionRequestSchema.parse(message), + [DappRequestType.SignMessage]: (message): SignMessageRequest => SignMessageRequestSchema.parse(message), + [DappRequestType.SignTransaction]: (message): SignTransactionRequest => SignTransactionRequestSchema.parse(message), + [DappRequestType.SignTypedData]: (message): SignTypedDataRequest => SignTypedDataRequestSchema.parse(message), + [DappRequestType.UniswapOpenSidebar]: (message): UniswapOpenSidebarRequest => + UniswapOpenSidebarRequestSchema.parse(message), + [DappRequestType.SendCalls]: (message): SendCallsRequest => SendCallsRequestSchema.parse(message), + [DappRequestType.GetCallsStatus]: (message): GetCallsStatusRequest => GetCallsStatusRequestSchema.parse(message), + [DappRequestType.GetCapabilities]: (message): GetCapabilitiesRequest => GetCapabilitiesRequestSchema.parse(message), +} + +function createContentScriptToBackgroundMessageChannel(): TypedRuntimeMessageChannel< + DappRequestType, + ContentScriptToBackgroundMessageSchemas +> { + return new TypedRuntimeMessageChannel({ + channelName: MessageChannelName.DappContentScript, + messageParsers: contentScriptToBackgroundMessageParsers, + canReceiveFromWebPage: true, + }) +} + +type DappResponseMessageSchemas = { + [DappResponseType.AccountResponse]: AccountResponse + [DappResponseType.ChainChangeResponse]: ChangeChainResponse + [DappResponseType.ChainIdResponse]: ChainIdResponse + [DappResponseType.ErrorResponse]: ErrorResponse + [DappResponseType.GetPermissionsResponse]: GetPermissionsResponse + [DappResponseType.RequestPermissionsResponse]: RequestPermissionsResponse + [DappResponseType.RevokePermissionsResponse]: RevokePermissionsResponse + [DappResponseType.SendTransactionResponse]: SendTransactionResponse + [DappResponseType.SignMessageResponse]: SignMessageResponse + [DappResponseType.SignTransactionResponse]: SignTransactionResponse + [DappResponseType.SignTypedDataResponse]: SignTypedDataResponse + [DappResponseType.UniswapOpenSidebarResponse]: UniswapOpenSidebarResponse + [DappResponseType.SendCallsResponse]: SendCallsResponse + [DappResponseType.GetCallsStatusResponse]: GetCallsStatusResponse + [DappResponseType.GetCapabilitiesResponse]: GetCapabilitiesResponse +} +const dappResponseMessageParsers: MessageParsers = { + [DappResponseType.AccountResponse]: (message): AccountResponse => AccountResponseSchema.parse(message), + [DappResponseType.ChainChangeResponse]: (message): ChangeChainResponse => ChangeChainResponseSchema.parse(message), + [DappResponseType.ChainIdResponse]: (message): ChainIdResponse => ChainIdResponseSchema.parse(message), + [DappResponseType.ErrorResponse]: (message): ErrorResponse => ErrorResponseSchema.parse(message), + [DappResponseType.GetPermissionsResponse]: (message): GetPermissionsResponse => + GetPermissionsResponseSchema.parse(message), + [DappResponseType.RequestPermissionsResponse]: (message): RequestPermissionsResponse => + RequestPermissionsResponseSchema.parse(message), + [DappResponseType.RevokePermissionsResponse]: (message): RevokePermissionsResponse => + RevokePermissionsResponseSchema.parse(message), + [DappResponseType.SendTransactionResponse]: (message): SendTransactionResponse => + SendTransactionResponseSchema.parse(message), + [DappResponseType.SignMessageResponse]: (message): SignMessageResponse => SignMessageResponseSchema.parse(message), + [DappResponseType.SignTransactionResponse]: (message): SignTransactionResponse => + SignTransactionResponseSchema.parse(message), + [DappResponseType.SignTypedDataResponse]: (message): SignTypedDataResponse => + SignTypedDataResponseSchema.parse(message), + [DappResponseType.UniswapOpenSidebarResponse]: (message): UniswapOpenSidebarResponse => + UniswapOpenSidebarResponseSchema.parse(message), + [DappResponseType.SendCallsResponse]: (message): SendCallsResponse => SendCallsResponseSchema.parse(message), + [DappResponseType.GetCallsStatusResponse]: (message): GetCallsStatusResponse => + GetCallsStatusResponseSchema.parse(message), + [DappResponseType.GetCapabilitiesResponse]: (message): GetCapabilitiesResponse => + GetCapabilitiesResponseSchema.parse(message), +} + +function createDappResponseMessageChannel(): TypedRuntimeMessageChannel { + return new TypedRuntimeMessageChannel({ + channelName: MessageChannelName.DappResponse, + messageParsers: dappResponseMessageParsers, + }) +} + +type ExternalDappMessageSchemas = { + [ExtensionToDappRequestType.SwitchChain]: ExtensionChainChange + [ExtensionToDappRequestType.UpdateConnections]: UpdateConnectionRequest +} +const externalDappMessageParsers: MessageParsers = { + [ExtensionToDappRequestType.SwitchChain]: (message): ExtensionChainChange => + ExtensionChainChangeSchema.parse(message), + [ExtensionToDappRequestType.UpdateConnections]: (message): UpdateConnectionRequest => + UpdateConnectionRequestSchema.parse(message), +} + +function createExternalDappMessageChannel(): TypedRuntimeMessageChannel< + ExtensionToDappRequestType, + ExternalDappMessageSchemas +> { + return new TypedRuntimeMessageChannel({ + channelName: MessageChannelName.ExternalDapp, + messageParsers: externalDappMessageParsers, + }) +} + +type ContentScriptUtilityMessageSchemas = { + [ContentScriptUtilityMessageType.ArcBrowserCheck]: ArcBrowserCheckMessage + [ContentScriptUtilityMessageType.FocusOnboardingTab]: FocusOnboardingMessage + [ContentScriptUtilityMessageType.ErrorLog]: ErrorLog + [ContentScriptUtilityMessageType.AnalyticsLog]: AnalyticsLog +} +const contentScriptUtilityMessageParsers: MessageParsers< + ContentScriptUtilityMessageType, + ContentScriptUtilityMessageSchemas +> = { + [ContentScriptUtilityMessageType.ArcBrowserCheck]: (message): ArcBrowserCheckMessage => + ArcBrowserCheckMessageSchema.parse(message), + [ContentScriptUtilityMessageType.FocusOnboardingTab]: (message): FocusOnboardingMessage => + FocusOnboardingMessageSchema.parse(message), + [ContentScriptUtilityMessageType.ErrorLog]: (message): ErrorLog => ErrorLogSchema.parse(message), + [ContentScriptUtilityMessageType.AnalyticsLog]: (message): AnalyticsLog => AnalyticsLogSchema.parse(message), +} + +function createContentScriptUtilityMessageChannel(): TypedRuntimeMessageChannel< + ContentScriptUtilityMessageType, + ContentScriptUtilityMessageSchemas +> { + return new TypedRuntimeMessageChannel({ + channelName: MessageChannelName.ContentScriptUtility, + messageParsers: contentScriptUtilityMessageParsers, + canReceiveFromWebPage: true, + }) +} + +export const onboardingMessageChannel = createOnboardingMessageChannel() +export const backgroundToSidePanelMessageChannel = createBackgroundToSidePanelMessageChannel() +export const contentScriptToBackgroundMessageChannel = createContentScriptToBackgroundMessageChannel() +export const dappResponseMessageChannel = createDappResponseMessageChannel() +export const externalDappMessageChannel = createExternalDappMessageChannel() +export const contentScriptUtilityMessageChannel = createContentScriptUtilityMessageChannel() + +export type DappBackgroundPortChannel = ReturnType diff --git a/apps/extension/src/background/messagePassing/messageUtils.test.ts b/apps/extension/src/background/messagePassing/messageUtils.test.ts new file mode 100644 index 00000000..479a8938 --- /dev/null +++ b/apps/extension/src/background/messagePassing/messageUtils.test.ts @@ -0,0 +1,109 @@ +import { addWindowMessageListener } from 'src/background/messagePassing/messageUtils' + +jest.mock('src/contentScript/isSandboxedFrame', () => ({ + isSandboxedFrame: jest.fn(() => false), +})) + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { isSandboxedFrame } = require('src/contentScript/isSandboxedFrame') as { + isSandboxedFrame: jest.Mock +} + +interface TestMessage { + type: 'TEST' + payload: string +} + +function isTestMessage(message: unknown): message is TestMessage { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + return typeof message === 'object' && message !== null && (message as TestMessage).type === 'TEST' +} + +function dispatchWindowMessage(data: unknown, source: MessageEventSource | null = window): void { + const event = new MessageEvent('message', { data, source }) + window.dispatchEvent(event) +} + +describe('addWindowMessageListener', () => { + afterEach(() => { + jest.clearAllMocks() + }) + + describe('normal frame (isSandboxedFrame returns false)', () => { + beforeEach(() => { + isSandboxedFrame.mockReturnValue(false) + }) + + it('calls handler when validator passes and source is window', () => { + const handler = jest.fn() + addWindowMessageListener({ validator: isTestMessage, handler }) + + dispatchWindowMessage({ type: 'TEST', payload: 'hello' }) + + expect(handler).toHaveBeenCalledWith({ type: 'TEST', payload: 'hello' }, window) + }) + + it('calls invalidMessageHandler when validator fails', () => { + const handler = jest.fn() + const invalidMessageHandler = jest.fn() + addWindowMessageListener({ validator: isTestMessage, handler, invalidMessageHandler }) + + dispatchWindowMessage({ type: 'INVALID' }) + + expect(handler).not.toHaveBeenCalled() + expect(invalidMessageHandler).toHaveBeenCalledWith({ type: 'INVALID' }, window) + }) + + it('rejects when event.source is not window', () => { + const handler = jest.fn() + const invalidMessageHandler = jest.fn() + addWindowMessageListener({ validator: isTestMessage, handler, invalidMessageHandler }) + + dispatchWindowMessage({ type: 'TEST', payload: 'hello' }, null) + + expect(handler).not.toHaveBeenCalled() + expect(invalidMessageHandler).toHaveBeenCalled() + }) + + it('removes listener when removeAfterHandled is true', () => { + const handler = jest.fn() + addWindowMessageListener({ + validator: isTestMessage, + handler, + options: { removeAfterHandled: true }, + }) + + dispatchWindowMessage({ type: 'TEST', payload: 'first' }) + dispatchWindowMessage({ type: 'TEST', payload: 'second' }) + + expect(handler).toHaveBeenCalledTimes(1) + expect(handler).toHaveBeenCalledWith({ type: 'TEST', payload: 'first' }, window) + }) + }) + + describe('sandboxed frame (isSandboxedFrame returns true)', () => { + beforeEach(() => { + isSandboxedFrame.mockReturnValue(true) + }) + + it('does NOT call handler even when validator passes and source is window', () => { + const handler = jest.fn() + addWindowMessageListener({ validator: isTestMessage, handler }) + + dispatchWindowMessage({ type: 'TEST', payload: 'hello' }) + + expect(handler).not.toHaveBeenCalled() + }) + + it('calls invalidMessageHandler when rejected due to sandbox', () => { + const handler = jest.fn() + const invalidMessageHandler = jest.fn() + addWindowMessageListener({ validator: isTestMessage, handler, invalidMessageHandler }) + + dispatchWindowMessage({ type: 'TEST', payload: 'hello' }) + + expect(handler).not.toHaveBeenCalled() + expect(invalidMessageHandler).toHaveBeenCalledWith({ type: 'TEST', payload: 'hello' }, window) + }) + }) +}) diff --git a/apps/extension/src/background/messagePassing/messageUtils.ts b/apps/extension/src/background/messagePassing/messageUtils.ts new file mode 100644 index 00000000..8dd601cb --- /dev/null +++ b/apps/extension/src/background/messagePassing/messageUtils.ts @@ -0,0 +1,39 @@ +import { isSandboxedFrame } from 'src/contentScript/isSandboxedFrame' +import { Message } from 'uniswap/src/extension/messagePassing/messageTypes' + +type MessageValidator = (message: unknown) => message is T + +type WindowMessageHandler = (message: T, source: MessageEventSource | null) => void +type InvalidWindowMessageHandler = (message: unknown, source?: MessageEventSource | null) => void + +// Message listener for chrome.window with validation logic. +// Used to pass messages between the site – and content scripts – and the extension (eg receive external messages from dapps). +export function addWindowMessageListener({ + validator, + handler, + invalidMessageHandler, + options, +}: { + validator: MessageValidator + handler: WindowMessageHandler + invalidMessageHandler?: InvalidWindowMessageHandler + options?: { removeAfterHandled?: boolean } +}): (event: MessageEvent) => void { + const listener = (event: MessageEvent): void => { + if (event.source !== window || isSandboxedFrame() || !validator(event.data)) { + invalidMessageHandler?.(event.data, event.source) + return + } + + handler(event.data, event.source) + if (options?.removeAfterHandled) { + removeWindowMessageListener(listener) + } + } + window.addEventListener('message', listener) + return listener +} + +export function removeWindowMessageListener(listener: (event: MessageEvent) => void): void { + window.removeEventListener('message', listener) +} diff --git a/apps/extension/src/background/messagePassing/platform.ts b/apps/extension/src/background/messagePassing/platform.ts new file mode 100644 index 00000000..e34dda56 --- /dev/null +++ b/apps/extension/src/background/messagePassing/platform.ts @@ -0,0 +1,272 @@ +/* oxlint-disable typescript/no-explicit-any -- Chrome extension message passing requires flexible typing for arbitrary message payloads */ +import { MessageParsers } from 'uniswap/src/extension/messagePassing/platform' +import { logger } from 'utilities/src/logger/logger' + +const EXTENSION_CONTEXT_INVALIDATED_CHROMIUM_ERROR = 'Extension context invalidated.' + +type MessageListener = (message: T, sender?: chrome.runtime.MessageSender) => void +class ChromeMessageChannel { + protected readonly channelName: string + readonly port?: chrome.runtime.Port + + // oxlint-disable-next-line typescript/no-explicit-any -- biome-parity: oxlint is stricter here + const mainListener: MessageListener = (message, sender) => { + const targetMessage = message[this.channelName] + + if (targetMessage !== undefined) { + if (sender?.tab !== undefined && !canReceiveFromWebPage) { + return + } + + if (sender?.id !== chrome.runtime.id && !this.port) { + return + } + + this.listeners.forEach((listener) => { + listener(targetMessage, sender) + }) + } + } + + if (this.port) { + this.port.onMessage.addListener((message, senderPort) => mainListener(message, senderPort.sender)) + } else { + // oxlint-disable-next-line no-restricted-syntax + chrome.runtime.onMessage.addListener(mainListener) + } + + this.sendMessage = this.sendMessage.bind(this) + this.sendMessageToTab = this.sendMessageToTab.bind(this) + this.sendMessageToTabUrl = this.sendMessageToTabUrl.bind(this) + this.addMessageListener = this.addMessageListener.bind(this) + this.removeMessageListener = this.removeMessageListener.bind(this) + } + + // oxlint-disable-next-line no-restricted-syntax + chrome.runtime.sendMessage({ [this.channelName]: message }).catch(() => {}) + } + } + + // oxlint-disable-next-line typescript/no-explicit-any -- biome-parity: oxlint is stricter here + async sendMessageToTab(tabId: number, message: any): Promise { + // oxlint-disable-next-line no-restricted-syntax + await chrome.tabs.sendMessage(tabId, { [this.channelName]: message }) + } + + // oxlint-disable-next-line typescript/no-explicit-any -- biome-parity: oxlint is stricter here + async sendMessageToTabUrl(tabUrl: string, message: any): Promise { + const urlMatcher = `${tabUrl}/*` + const promises: Promise[] = [] + chrome.tabs.query({ url: urlMatcher }, (tabs) => { + tabs.forEach((tab) => { + if (tab.id) { + promises.push( + // oxlint-disable-next-line no-restricted-syntax + chrome.tabs.sendMessage(tab.id, { [this.channelName]: message }).catch(() => { + // Not logging error here because it is expected that inactive tabs will not be able to receive the message + }), + ) + } + }) + }) + return Promise.all(promises) + } + + // oxlint-disable-next-line typescript/no-explicit-any -- biome-parity: oxlint is stricter here + removeMessageListener(listener: MessageListener): void { + this.listeners = this.listeners.filter((l) => l !== listener) + } +} + +abstract class TypedMessageChannel< + T extends string, + R extends { [key in T]: { type: key } }, + L extends { [key in T]: MessageListener } = { [key in T]: MessageListener }, +> { + private readonly chromeMessageChannel: ChromeMessageChannel + private readonly messageParsers: MessageParsers + private listeners = new Map() + + constructor({ + channelName, + port, + messageParsers, + canReceiveFromWebPage, + }: { + channelName: string + port?: chrome.runtime.Port + messageParsers: MessageParsers + canReceiveFromWebPage?: boolean + }) { + this.messageParsers = messageParsers + this.chromeMessageChannel = new ChromeMessageChannel({ + channelName, + port, + canReceiveFromWebPage, + }) + + this.chromeMessageChannel.addMessageListener((message, sender) => { + let type: T | undefined + try { + const processed = this.processMessage(message) + const messageParser = processed.messageParser + type = processed.type + + const parsed = messageParser(message) + this.listeners.get(type)?.forEach((listener) => { + listener(parsed, sender) + }) + } catch (error) { + logger.error( + new Error(`Error validating message. Possible type is ${type}`, { + cause: error, + }), + { + tags: { + file: 'platform.ts', + function: 'TypedMessageChannel.constructor', + }, + }, + ) + } + }) + + this.sendMessage = this.sendMessage.bind(this) + this.sendMessageToTab = this.sendMessageToTab.bind(this) + this.sendMessageToTabUrl = this.sendMessageToTabUrl.bind(this) + this.addMessageListener = this.addMessageListener.bind(this) + this.removeMessageListener = this.removeMessageListener.bind(this) + } + + // oxlint-disable-next-line typescript/no-unnecessary-condition + if (!messageParser) { + throw new Error(`No message parser found for type ${type}`) + } + return { type, messageParser } + } + + async sendMessage(message: R[T1]): Promise { + const { type } = message + + try { + await this.chromeMessageChannel.sendMessage(message) + return true + } catch (error) { + const isExtensionInvalidatedError = + error instanceof Error && error.message === EXTENSION_CONTEXT_INVALIDATED_CHROMIUM_ERROR + logger.error( + new Error( + `${isExtensionInvalidatedError ? 'Please refresh the page. ' : ''}Error sending message for type ${type}`, + { cause: error }, + ), + { + tags: { + file: 'platform.ts', + function: 'TypedMessageChannel.sendMessage', + }, + }, + ) + return false + } + } + + async sendMessageToTab(tabId: number, message: R[T1]): Promise { + const { type } = message + + try { + await this.chromeMessageChannel.sendMessageToTab(tabId, message) + return true + } catch (error) { + logger.error(new Error(`Error sending message to tab for type ${type}`, { cause: error }), { + tags: { + file: 'platform.ts', + function: 'TypedMessageChannel.sendMessageToTab', + }, + }) + return false + } + } + + async sendMessageToTabUrl(tabUrl: string, message: R[T1]): Promise { + const { type } = message + + try { + await this.chromeMessageChannel.sendMessageToTabUrl(tabUrl, message) + return true + } catch (error) { + logger.error(new Error(`Error sending message to tab for type ${type}`, { cause: error }), { + tags: { + file: 'platform.ts', + function: 'TypedMessageChannel.sendMessageToTabUrl', + }, + }) + return false + } + } + + addMessageListener(type: T1, listener: L[T1]): () => void { + this.listeners.set(type, this.listeners.get(type) ?? []) + this.listeners.get(type)?.push(listener) + + return () => this.removeMessageListener(type, listener) + } + + addAllMessageListener(listener: MessageListener): () => void { + const removeListeners = Object.keys(this.messageParsers).map((type) => + this.addMessageListener(type as T, listener as L[T]), + ) + + return () => removeListeners.forEach((remove) => remove()) + } + + removeMessageListener(type: T, listener: L[T]): void { + this.listeners.set(type, this.listeners.get(type)?.filter((l) => l !== listener) ?? []) + } +} + +/** + * Type-safe message channel class used for communication. Intended for general global use, backed by chrome.runtime + */ +export class TypedRuntimeMessageChannel< + T extends string, + R extends { [key in T]: { type: key } }, + L extends { [key in T]: MessageListener } = { [key in T]: MessageListener }, +> extends TypedMessageChannel { + constructor({ + channelName, + messageParsers, + canReceiveFromWebPage, + }: { + channelName: string + messageParsers: MessageParsers + canReceiveFromWebPage?: boolean + }) { + super({ channelName, messageParsers, canReceiveFromWebPage }) + } +} + +/** + * Adaptation of TypedRuntimeMessageChannel used as a wrapper around chrome.runtime.Port + */ +export class TypedPortMessageChannel< + T extends string, + R extends { [key in T]: { type: key } }, + L extends { [key in T]: MessageListener } = { [key in T]: MessageListener }, +> extends TypedMessageChannel { + readonly port: chrome.runtime.Port + + constructor({ + channelName, + messageParsers, + port, + canReceiveFromContentScript, + }: { + channelName: string + messageParsers: MessageParsers + port: chrome.runtime.Port + canReceiveFromContentScript?: boolean + }) { + super({ channelName, messageParsers, port, canReceiveFromWebPage: canReceiveFromContentScript }) + this.port = port + } +} diff --git a/apps/extension/src/background/messagePassing/types/ExtensionMessages.ts b/apps/extension/src/background/messagePassing/types/ExtensionMessages.ts new file mode 100644 index 00000000..4bb3798b --- /dev/null +++ b/apps/extension/src/background/messagePassing/types/ExtensionMessages.ts @@ -0,0 +1,17 @@ +import { MessageSchema } from 'uniswap/src/extension/messagePassing/messageTypes' +import { z } from 'zod' + +export enum OnboardingMessageType { + HighlightOnboardingTab = 'HighlightOnboardingTab', + SidebarOpened = 'SidebarOpened', +} + +export const HighlightOnboardingTabMessageSchema = MessageSchema.extend({ + type: z.literal(OnboardingMessageType.HighlightOnboardingTab), +}) +export type HighlightOnboardingTabMessage = z.infer + +export const SidebarOpenedMessageSchema = MessageSchema.extend({ + type: z.literal(OnboardingMessageType.SidebarOpened), +}) +export type SidebarOpenedMessage = z.infer diff --git a/apps/extension/src/background/messagePassing/types/requests.ts b/apps/extension/src/background/messagePassing/types/requests.ts new file mode 100644 index 00000000..8825cfb9 --- /dev/null +++ b/apps/extension/src/background/messagePassing/types/requests.ts @@ -0,0 +1,95 @@ +import { DappRequestSchema } from 'src/app/features/dappRequests/types/DappRequestTypes' +import { MessageSchema } from 'uniswap/src/extension/messagePassing/messageTypes' +import { z } from 'zod' + +// ENUMS + +// Requests from content scripts to the extension (non-dapp requests) +export enum ContentScriptUtilityMessageType { + ArcBrowserCheck = 'ArcBrowserCheck', + FocusOnboardingTab = 'FocusOnboardingTab', + ErrorLog = 'Error', + AnalyticsLog = 'AnalyticsLog', +} + +export const ErrorLogSchema = MessageSchema.extend({ + type: z.literal(ContentScriptUtilityMessageType.ErrorLog), + message: z.string(), + fileName: z.string(), + functionName: z.string(), + tags: z.record(z.string(), z.string()).optional(), + extra: z.record(z.string(), z.unknown()).optional(), +}) +export type ErrorLog = z.infer + +export const ArcBrowserCheckMessageSchema = MessageSchema.extend({ + type: z.literal(ContentScriptUtilityMessageType.ArcBrowserCheck), + isArcBrowser: z.boolean(), +}) + +export type ArcBrowserCheckMessage = z.infer + +export const AnalyticsLogSchema = MessageSchema.extend({ + type: z.literal(ContentScriptUtilityMessageType.AnalyticsLog), + message: z.string(), + tags: z.record(z.string(), z.string()), +}) +export type AnalyticsLog = z.infer + +export const FocusOnboardingMessageSchema = MessageSchema.extend({ + type: z.literal(ContentScriptUtilityMessageType.FocusOnboardingTab), +}) +export type FocusOnboardingMessage = z.infer + +// Requests from background script to the extension sidebar +export enum BackgroundToSidePanelRequestType { + TabActivated = 'TabActivated', + DappRequestReceived = 'DappRequestReceived', + RefreshUnitags = 'RefreshUnitags', +} + +export const DappRequestMessageSchema = z.object({ + type: z.literal(BackgroundToSidePanelRequestType.DappRequestReceived), + dappRequest: DappRequestSchema, + senderTabInfo: z.object({ + id: z.number(), + url: z.string(), + frameUrl: z.string().optional(), + favIconUrl: z.string().optional(), + }), + isSidebarClosed: z.optional(z.boolean()), +}) +export type DappRequestMessage = z.infer + +export const TabActivatedRequestSchema = MessageSchema.extend({ + type: z.literal(BackgroundToSidePanelRequestType.TabActivated), +}) +export type TabActivatedRequest = z.infer + +export const RefreshUnitagsRequestSchema = MessageSchema.extend({ + type: z.literal(BackgroundToSidePanelRequestType.RefreshUnitags), +}) +export type RefreshUnitagsRequest = z.infer + +// Requests outgoing from the extension to the injected script +export enum ExtensionToDappRequestType { + UpdateConnections = 'UpdateConnections', + SwitchChain = 'SwitchChain', +} + +const BaseExtensionRequestSchema = MessageSchema.extend({ + type: z.nativeEnum(ExtensionToDappRequestType), +}) + +export const ExtensionChainChangeSchema = BaseExtensionRequestSchema.extend({ + type: z.literal(ExtensionToDappRequestType.SwitchChain), + chainId: z.string(), + providerUrl: z.string(), +}) +export type ExtensionChainChange = z.infer + +export const UpdateConnectionRequestSchema = BaseExtensionRequestSchema.extend({ + type: z.literal(ExtensionToDappRequestType.UpdateConnections), + addresses: z.array(z.string()), // TODO (Thomas): Figure out what to do for type safety here +}) +export type UpdateConnectionRequest = z.infer diff --git a/apps/extension/src/background/services/security/index.ts b/apps/extension/src/background/services/security/index.ts new file mode 100644 index 00000000..13af632d --- /dev/null +++ b/apps/extension/src/background/services/security/index.ts @@ -0,0 +1,7 @@ +/** + * Security Services + * Transaction risk analysis and protection + */ + +export * from './types' +export * from './securityEngine' diff --git a/apps/extension/src/background/services/security/securityEngine.ts b/apps/extension/src/background/services/security/securityEngine.ts new file mode 100644 index 00000000..971911a3 --- /dev/null +++ b/apps/extension/src/background/services/security/securityEngine.ts @@ -0,0 +1,430 @@ +/** + * Security Engine Service + * Transaction risk analysis and scoring + * Adapted from xwallet/Rabby securityEngine + */ + +import { + ApprovalInfo, + BlacklistEntry, + DEFAULT_SECURITY_RULES, + RiskIndicator, + RiskLevel, + SecurityAssessment, + SecurityRule, + TransactionAnalysis, + WhitelistEntry, +} from './types' + +const STORAGE_KEY = 'lux_security_engine' +const CACHE_TTL = 5 * 60 * 1000 // 5 minutes + +// Max uint256 for unlimited approval detection +const MAX_UINT256 = '0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff' +const UNLIMITED_THRESHOLD = BigInt('0x' + 'f'.repeat(60)) // Very large number + +class SecurityEngineService { + private rules: Map = new Map() + private blacklist: Map = new Map() + private whitelist: Map = new Map() + private assessmentCache: Map = new Map() + private interactionHistory: Set = new Set() // Track first interactions + private initialized = false + + async init(): Promise { + if (this.initialized) return + + try { + const stored = await chrome.storage.local.get(STORAGE_KEY) + + if (stored[STORAGE_KEY]) { + const data = JSON.parse(stored[STORAGE_KEY]) + + // Load rules + if (data.rules) { + Object.entries(data.rules).forEach(([id, rule]) => { + this.rules.set(id, rule as SecurityRule) + }) + } + + // Load blacklist + if (data.blacklist) { + Object.entries(data.blacklist).forEach(([address, entry]) => { + this.blacklist.set(address.toLowerCase(), entry as BlacklistEntry) + }) + } + + // Load whitelist + if (data.whitelist) { + Object.entries(data.whitelist).forEach(([address, entry]) => { + this.whitelist.set(address.toLowerCase(), entry as WhitelistEntry) + }) + } + + // Load interaction history + if (data.interactionHistory) { + this.interactionHistory = new Set(data.interactionHistory) + } + } + + // Initialize default rules if not present + if (this.rules.size === 0) { + DEFAULT_SECURITY_RULES.forEach((rule) => { + this.rules.set(rule.id, rule) + }) + await this.persist() + } + + this.initialized = true + } catch (error) { + console.error('Failed to load security engine:', error) + // Initialize with defaults + DEFAULT_SECURITY_RULES.forEach((rule) => { + this.rules.set(rule.id, rule) + }) + this.initialized = true + } + } + + private async persist(): Promise { + const rulesObj: Record = {} + this.rules.forEach((rule, id) => { + rulesObj[id] = rule + }) + + const blacklistObj: Record = {} + this.blacklist.forEach((entry, address) => { + blacklistObj[address] = entry + }) + + const whitelistObj: Record = {} + this.whitelist.forEach((entry, address) => { + whitelistObj[address] = entry + }) + + await chrome.storage.local.set({ + [STORAGE_KEY]: JSON.stringify({ + rules: rulesObj, + blacklist: blacklistObj, + whitelist: whitelistObj, + interactionHistory: Array.from(this.interactionHistory), + }), + }) + } + + async assessTransaction(analysis: TransactionAnalysis): Promise { + await this.init() + + const cacheKey = this.getCacheKey(analysis) + const cached = this.assessmentCache.get(cacheKey) + + if (cached && Date.now() - cached.timestamp < CACHE_TTL) { + return cached + } + + const indicators: RiskIndicator[] = [] + const toAddress = analysis.to?.toLowerCase() + + // Check blacklist + const isBlacklisted = toAddress ? this.blacklist.has(toAddress) : false + if (isBlacklisted) { + const entry = this.blacklist.get(toAddress!) + indicators.push({ + ruleId: 'blacklisted', + ruleName: 'Blacklisted Address', + level: RiskLevel.Critical, + message: `This address has been flagged: ${entry?.reason || 'Unknown reason'}`, + }) + } + + // Check whitelist + const isWhitelisted = toAddress ? this.whitelist.has(toAddress) : false + + // Run enabled rules + for (const [ruleId, rule] of this.rules) { + if (!rule.enabled) continue + + const indicator = await this.evaluateRule(rule, analysis) + if (indicator) { + indicators.push(indicator) + } + } + + // Calculate overall risk level + const overallLevel = this.calculateOverallLevel(indicators, isWhitelisted) + + const assessment: SecurityAssessment = { + overallLevel, + indicators, + isBlacklisted, + isWhitelisted, + timestamp: Date.now(), + } + + // Cache the assessment + this.assessmentCache.set(cacheKey, assessment) + + // Record interaction for first-time detection + if (toAddress) { + const interactionKey = `${analysis.chainId}:${toAddress}` + if (!this.interactionHistory.has(interactionKey)) { + this.interactionHistory.add(interactionKey) + await this.persist() + } + } + + return assessment + } + + private async evaluateRule( + rule: SecurityRule, + analysis: TransactionAnalysis + ): Promise { + const toAddress = analysis.to?.toLowerCase() + + switch (rule.id) { + case 'unlimited_approval': + return this.checkUnlimitedApproval(rule, analysis.approvals) + + case 'first_interaction': + if (toAddress) { + const interactionKey = `${analysis.chainId}:${toAddress}` + if (!this.interactionHistory.has(interactionKey)) { + return { + ruleId: rule.id, + ruleName: rule.name, + level: rule.level, + message: 'This is your first time interacting with this contract', + } + } + } + return null + + case 'large_value_transfer': + return this.checkLargeValueTransfer(rule, analysis.value) + + case 'eth_sign_request': + // This would be checked separately for signing requests + return null + + case 'suspicious_data': + return this.checkSuspiciousData(rule, analysis.data) + + default: + return null + } + } + + private checkUnlimitedApproval( + rule: SecurityRule, + approvals?: ApprovalInfo[] + ): RiskIndicator | null { + if (!approvals || approvals.length === 0) return null + + for (const approval of approvals) { + if (approval.isUnlimited) { + return { + ruleId: rule.id, + ruleName: rule.name, + level: rule.level, + message: `Unlimited approval requested for ${approval.symbol || approval.token}`, + details: { spender: approval.spender, token: approval.token }, + } + } + + // Check if amount is effectively unlimited + try { + const amount = BigInt(approval.amount) + if (amount >= UNLIMITED_THRESHOLD) { + return { + ruleId: rule.id, + ruleName: rule.name, + level: rule.level, + message: `Very large approval amount for ${approval.symbol || approval.token}`, + details: { spender: approval.spender, token: approval.token, amount: approval.amount }, + } + } + } catch { + // Invalid amount format, skip + } + } + + return null + } + + private checkLargeValueTransfer(rule: SecurityRule, value: string): RiskIndicator | null { + try { + const valueWei = BigInt(value) + // Flag transfers over 10 ETH as large (configurable in future) + const threshold = BigInt('10000000000000000000') // 10 ETH in wei + + if (valueWei > threshold) { + return { + ruleId: rule.id, + ruleName: rule.name, + level: rule.level, + message: 'This is a large value transfer', + details: { value }, + } + } + } catch { + // Invalid value format + } + + return null + } + + private checkSuspiciousData(rule: SecurityRule, data: string): RiskIndicator | null { + if (!data || data === '0x') return null + + // Known suspicious patterns (simplified) + const suspiciousPatterns = [ + '0x23b872dd', // transferFrom without approval context + '0x42842e0e', // safeTransferFrom without approval context + ] + + const methodId = data.slice(0, 10).toLowerCase() + + // This is a simplified check - in production, we'd have more sophisticated analysis + // For now, just flag known risky methods + if (suspiciousPatterns.includes(methodId)) { + return { + ruleId: rule.id, + ruleName: rule.name, + level: RiskLevel.Medium, // Downgrade from rule level as this is just a hint + message: 'Transaction uses a method that could transfer your assets', + details: { methodId }, + } + } + + return null + } + + private calculateOverallLevel( + indicators: RiskIndicator[], + isWhitelisted: boolean + ): RiskLevel { + if (indicators.length === 0) { + return RiskLevel.Safe + } + + // Get the highest risk level + const levelPriority: Record = { + [RiskLevel.Safe]: 0, + [RiskLevel.Low]: 1, + [RiskLevel.Medium]: 2, + [RiskLevel.High]: 3, + [RiskLevel.Critical]: 4, + } + + let maxPriority = 0 + for (const indicator of indicators) { + const priority = levelPriority[indicator.level] + if (priority > maxPriority) { + maxPriority = priority + } + } + + // If whitelisted, cap at Medium unless Critical + if (isWhitelisted && maxPriority < levelPriority[RiskLevel.Critical]) { + maxPriority = Math.min(maxPriority, levelPriority[RiskLevel.Medium]) + } + + // Convert back to RiskLevel + for (const [level, priority] of Object.entries(levelPriority)) { + if (priority === maxPriority) { + return level as RiskLevel + } + } + + return RiskLevel.Safe + } + + private getCacheKey(analysis: TransactionAnalysis): string { + return `${analysis.chainId}:${analysis.to}:${analysis.data}:${analysis.value}` + } + + // Rule management + async setRuleEnabled(ruleId: string, enabled: boolean): Promise { + await this.init() + + const rule = this.rules.get(ruleId) + if (!rule) return false + + rule.enabled = enabled + this.rules.set(ruleId, rule) + await this.persist() + return true + } + + async getRules(): Promise { + await this.init() + return Array.from(this.rules.values()) + } + + // Blacklist management + async addToBlacklist(address: string, reason: string, source = 'user'): Promise { + await this.init() + + const entry: BlacklistEntry = { + address: address.toLowerCase(), + reason, + addedAt: Date.now(), + source, + } + + this.blacklist.set(address.toLowerCase(), entry) + await this.persist() + } + + async removeFromBlacklist(address: string): Promise { + await this.init() + + const removed = this.blacklist.delete(address.toLowerCase()) + if (removed) { + await this.persist() + } + return removed + } + + async isBlacklisted(address: string): Promise { + await this.init() + return this.blacklist.has(address.toLowerCase()) + } + + // Whitelist management + async addToWhitelist(address: string, name?: string): Promise { + await this.init() + + const entry: WhitelistEntry = { + address: address.toLowerCase(), + name, + addedAt: Date.now(), + } + + this.whitelist.set(address.toLowerCase(), entry) + await this.persist() + } + + async removeFromWhitelist(address: string): Promise { + await this.init() + + const removed = this.whitelist.delete(address.toLowerCase()) + if (removed) { + await this.persist() + } + return removed + } + + async isWhitelisted(address: string): Promise { + await this.init() + return this.whitelist.has(address.toLowerCase()) + } + + // Cache management + clearCache(): void { + this.assessmentCache.clear() + } +} + +export const securityEngine = new SecurityEngineService() diff --git a/apps/extension/src/background/services/security/types.ts b/apps/extension/src/background/services/security/types.ts new file mode 100644 index 00000000..fd7fd891 --- /dev/null +++ b/apps/extension/src/background/services/security/types.ts @@ -0,0 +1,163 @@ +/** + * Security Engine Types + * Transaction risk analysis and scoring + * Adapted from xwallet/Rabby securityEngine + */ + +export enum RiskLevel { + Safe = 'safe', + Low = 'low', + Medium = 'medium', + High = 'high', + Critical = 'critical', +} + +export interface SecurityRule { + id: string + name: string + description: string + level: RiskLevel + enabled: boolean +} + +export interface RiskIndicator { + ruleId: string + ruleName: string + level: RiskLevel + message: string + details?: Record +} + +export interface SecurityAssessment { + overallLevel: RiskLevel + indicators: RiskIndicator[] + isBlacklisted: boolean + isWhitelisted: boolean + contractVerified?: boolean + timestamp: number +} + +export interface ContractInfo { + address: string + chainId: number + isVerified: boolean + name?: string + isProxy?: boolean + implementationAddress?: string +} + +export interface TransactionAnalysis { + to: string + from: string + value: string + data: string + chainId: number + // Decoded info + methodName?: string + methodId?: string + decodedParams?: Record + // Token transfer info + tokenTransfers?: TokenTransfer[] + // Approval info + approvals?: ApprovalInfo[] +} + +export interface TokenTransfer { + token: string + from: string + to: string + amount: string + symbol?: string + decimals?: number +} + +export interface ApprovalInfo { + token: string + spender: string + amount: string + isUnlimited: boolean + symbol?: string +} + +// Known malicious addresses/contracts (maintained list) +export interface BlacklistEntry { + address: string + reason: string + addedAt: number + source: string +} + +// User whitelist for trusted addresses +export interface WhitelistEntry { + address: string + name?: string + addedAt: number +} + +// Security engine state +export interface SecurityEngineState { + rules: Record + blacklist: Record + whitelist: Record + assessmentCache: Record +} + +// Default security rules +export const DEFAULT_SECURITY_RULES: SecurityRule[] = [ + { + id: 'new_contract', + name: 'New Contract', + description: 'Contract deployed recently (less than 7 days)', + level: RiskLevel.Medium, + enabled: true, + }, + { + id: 'unverified_contract', + name: 'Unverified Contract', + description: 'Contract source code not verified', + level: RiskLevel.Medium, + enabled: true, + }, + { + id: 'unlimited_approval', + name: 'Unlimited Approval', + description: 'Transaction requests unlimited token approval', + level: RiskLevel.High, + enabled: true, + }, + { + id: 'first_interaction', + name: 'First Interaction', + description: 'First time interacting with this contract', + level: RiskLevel.Low, + enabled: true, + }, + { + id: 'suspicious_recipient', + name: 'Suspicious Recipient', + description: 'Recipient has been flagged in security databases', + level: RiskLevel.Critical, + enabled: true, + }, + { + id: 'large_value_transfer', + name: 'Large Value Transfer', + description: 'Transaction value exceeds your typical transfer amount', + level: RiskLevel.Medium, + enabled: true, + }, + { + id: 'eth_sign_request', + name: 'Raw Message Signing', + description: 'Application requesting dangerous eth_sign method', + level: RiskLevel.Critical, + enabled: true, + }, + { + id: 'suspicious_data', + name: 'Suspicious Transaction Data', + description: 'Transaction data pattern matches known exploit', + level: RiskLevel.High, + enabled: true, + }, +] diff --git a/apps/extension/src/background/utils/chromeSidePanelUtils.ts b/apps/extension/src/background/utils/chromeSidePanelUtils.ts new file mode 100644 index 00000000..deb400d1 --- /dev/null +++ b/apps/extension/src/background/utils/chromeSidePanelUtils.ts @@ -0,0 +1,56 @@ +import { focusOrCreateDappRequestWindow } from 'src/app/navigation/utils' +import { ExtensionEventName } from 'uniswap/src/features/telemetry/constants' +import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { logger } from 'utilities/src/logger/logger' + +export async function openSidePanel(tabId: number | undefined, windowId: number): Promise { + let hasError = false + try { + // Chrome API accepts either tabId or windowId, prefer tabId for specific tab targeting + if (tabId !== undefined) { + await chrome.sidePanel.open({ tabId }) + } else { + await chrome.sidePanel.open({ windowId }) + } + } catch (error) { + // TODO WALL-4313 - Backup for some broken chrome.sidePanel.open functionality + // Consider removing this once the issue is resolved or leaving as fallback + await focusOrCreateDappRequestWindow(tabId, windowId) + + hasError = true + logger.error(error, { + tags: { + file: 'background/background.ts', + function: 'openSidebar', + }, + }) + } finally { + sendAnalyticsEvent(ExtensionEventName.BackgroundAttemptedToOpenSidebar, { hasError }) + } +} + +export async function setSidePanelBehavior(behavior: chrome.sidePanel.PanelBehavior): Promise { + try { + await chrome.sidePanel.setPanelBehavior(behavior) + } catch (error) { + logger.error(error, { + tags: { + file: 'background/background.ts', + function: 'setSideBarBehavior', + }, + }) + } +} + +export async function setSidePanelOptions(options: chrome.sidePanel.PanelOptions): Promise { + try { + await chrome.sidePanel.setOptions(options) + } catch (error) { + logger.error(error, { + tags: { + file: 'background/background.ts', + function: 'setSideBarOptions', + }, + }) + } +} diff --git a/apps/extension/src/background/utils/getCalldataInfoFromTransaction.ts b/apps/extension/src/background/utils/getCalldataInfoFromTransaction.ts new file mode 100644 index 00000000..171a8232 --- /dev/null +++ b/apps/extension/src/background/utils/getCalldataInfoFromTransaction.ts @@ -0,0 +1,107 @@ +import { CommandParser, CommandType, type UniversalRouterCall } from '@uniswap/universal-router-sdk' +import { Actions, V4BaseActionsParser, type V4RouterCall } from '@uniswap/v4-sdk' +import { EthSendTransactionRPCActions } from 'src/app/features/dappRequests/types/DappRequestTypes' +import { parseCalldata as parseNfPMCalldata } from 'src/app/features/dappRequests/types/NonfungiblePositionManager' +import { type NonfungiblePositionManagerCall } from 'src/app/features/dappRequests/types/NonfungiblePositionManagerTypes' +import { type UniverseChainId } from 'uniswap/src/features/chains/types' +import { wrappedNativeCurrency } from 'uniswap/src/utils/currency' +import methodHashToFunctionSignature from 'utilities/src/calldata/methodHashToFunctionSignature' +import { noop } from 'utilities/src/react/noop' + +interface GetCalldataInfoFromTransactionReturnValue { + functionSignature?: string + contractInteractions: EthSendTransactionRPCActions + to?: string + parsedCalldata?: V4RouterCall | UniversalRouterCall | NonfungiblePositionManagerCall +} + +export default function getCalldataInfoFromTransaction({ + data, + to, + chainId, +}: { + data: string + to?: string + chainId?: UniverseChainId +}): GetCalldataInfoFromTransactionReturnValue { + const calldataMethodHash = data.substring(2, 10) + const functionSignature = methodHashToFunctionSignature(calldataMethodHash) + const contractInteractions = EthSendTransactionRPCActions.ContractInteraction + const result: GetCalldataInfoFromTransactionReturnValue = { + functionSignature, + contractInteractions, + to, + } + + if (functionSignature) { + if (['permit2Approve'].some((el) => functionSignature.includes(el))) { + result.contractInteractions = EthSendTransactionRPCActions.Permit2Approve + return result + } + if (['approve', 'permit'].some((el) => functionSignature.includes(el))) { + result.contractInteractions = EthSendTransactionRPCActions.Approve + return result + } + + try { + const v4Calldata = V4BaseActionsParser.parseCalldata(data) + + // Validate that the V4 call actually contains swap actions + const hasSwapAction = v4Calldata.actions.some( + (action) => + action.actionType === Actions.SWAP_EXACT_IN || + action.actionType === Actions.SWAP_EXACT_OUT || + action.actionType === Actions.SWAP_EXACT_IN_SINGLE || + action.actionType === Actions.SWAP_EXACT_OUT_SINGLE, + ) + + if (hasSwapAction) { + result.contractInteractions = EthSendTransactionRPCActions.Swap + result.parsedCalldata = v4Calldata + return result + } + } catch { + noop() + } + + try { + const URCalldata = CommandParser.parseCalldata(data) + + // Validate that the UR call actually contains swap commands + const hasSwapCommand = URCalldata.commands.some( + (command) => + command.commandType === CommandType.V2_SWAP_EXACT_IN || + command.commandType === CommandType.V2_SWAP_EXACT_OUT || + command.commandType === CommandType.V3_SWAP_EXACT_IN || + command.commandType === CommandType.V3_SWAP_EXACT_OUT || + command.commandType === CommandType.V4_SWAP, + ) + + if (hasSwapCommand) { + result.contractInteractions = EthSendTransactionRPCActions.Swap + result.parsedCalldata = URCalldata + return result + } + } catch { + noop() + } + + try { + const NfPMCalldata = parseNfPMCalldata(data) + result.contractInteractions = EthSendTransactionRPCActions.LP + result.parsedCalldata = NfPMCalldata + return result + } catch { + noop() + } + + const isWrapUnwrapSignature = functionSignature === 'deposit()' || functionSignature === 'withdraw(uint256)' + const wrappedNative = chainId ? wrappedNativeCurrency(chainId) : undefined + const isNativeWrappedCurrencyTo = wrappedNative && to?.toLowerCase() === wrappedNative.address.toLowerCase() + if (functionSignature.includes('wrap') || (isWrapUnwrapSignature && isNativeWrappedCurrencyTo)) { + result.contractInteractions = EthSendTransactionRPCActions.Wrap + return result + } + } + return result +} diff --git a/apps/extension/src/background/utils/loggerMiddleware.ts b/apps/extension/src/background/utils/loggerMiddleware.ts new file mode 100644 index 00000000..b334591d --- /dev/null +++ b/apps/extension/src/background/utils/loggerMiddleware.ts @@ -0,0 +1,6 @@ +import { createLogger } from 'redux-logger' + +export const loggerMiddleware = createLogger({ + collapsed: true, + diff: true, +}) diff --git a/apps/extension/src/background/utils/persistedStateUtils.ts b/apps/extension/src/background/utils/persistedStateUtils.ts new file mode 100644 index 00000000..2b646c64 --- /dev/null +++ b/apps/extension/src/background/utils/persistedStateUtils.ts @@ -0,0 +1,65 @@ +import { isOnboardedSelector } from 'src/app/utils/isOnboardedSelector' +import { STATE_STORAGE_KEY } from 'src/store/constants' +import { ExtensionState } from 'src/store/extensionReducer' +import { EXTENSION_STATE_VERSION } from 'src/store/migrations' +import { deviceAccessTimeoutToMinutes } from 'uniswap/src/features/settings/constants' +import { logger } from 'utilities/src/logger/logger' + +export async function readReduxStateFromStorage(storageChanges?: { + [key: string]: chrome.storage.StorageChange +}): Promise { + const root = storageChanges + ? storageChanges[STATE_STORAGE_KEY]?.newValue + : (await chrome.storage.local.get(STATE_STORAGE_KEY))[STATE_STORAGE_KEY] + + if (!root) { + return undefined + } + + const rootParsed = JSON.parse(root) + + Object.keys(rootParsed).forEach((key) => { + // Each reducer must be parsed individually. + rootParsed[key] = JSON.parse(rootParsed[key]) + }) + + return rootParsed as ExtensionState +} + +export async function readIsOnboardedFromStorage(): Promise { + const state = await readReduxStateFromStorage() + return state ? isOnboardedSelector(state) : false +} + +export async function readDeviceAccessTimeoutMinutesFromStorage(): Promise { + const state = await readReduxStateFromStorage() + return state ? deviceAccessTimeoutToMinutes(state.userSettings.deviceAccessTimeout) : undefined +} + +/** + * Checks if Redux migrations are pending by comparing persisted version with current version + * @returns true if migrations are pending and sidebar should handle the request + */ +export async function checkAreMigrationsPending(): Promise { + try { + const reduxState = await readReduxStateFromStorage() + if (!reduxState) { + // No persisted state - let sidebar handle initialization + return true + } + + if (!reduxState._persist?.version) { + // No version info - let sidebar handle initialization + return true + } + + // If persisted version is less than current version, migrations are pending + return reduxState._persist.version < EXTENSION_STATE_VERSION + } catch (error) { + logger.error(error, { + tags: { file: 'persistedStateUtils.ts', function: 'areMigrationsPending' }, + }) + // On error, err on the side of caution and let sidebar handle it + return true + } +} diff --git a/apps/extension/src/constants/luxChains.ts b/apps/extension/src/constants/luxChains.ts new file mode 100644 index 00000000..d2db92c8 --- /dev/null +++ b/apps/extension/src/constants/luxChains.ts @@ -0,0 +1,465 @@ +/** + * Lux Ecosystem Chain Configurations + * All mainnet and testnet chains for the Lux ecosystem + */ + +export interface ChainConfig { + chainId: number + chainIdHex: string + name: string + shortName: string + network: 'mainnet' | 'testnet' + nativeCurrency: { + name: string + symbol: string + decimals: number + } + rpcUrls: string[] + blockExplorerUrls: string[] + iconUrl?: string + isL2?: boolean + parentChainId?: number + vmType: 'evm' | 'xvm' | 'pvm' // EVM, X-Chain VM, Platform VM +} + +// ============================================ +// LUX ECOSYSTEM - MAINNET CHAINS +// ============================================ + +export const LUX_MAINNET: ChainConfig = { + chainId: 96369, + chainIdHex: '0x17871', + name: 'Lux Mainnet', + shortName: 'LUX', + network: 'mainnet', + nativeCurrency: { + name: 'Lux', + symbol: 'LUX', + decimals: 18, + }, + rpcUrls: ['https://api.lux.network/ext/bc/C/rpc'], + blockExplorerUrls: ['https://explore.lux.network'], + iconUrl: 'https://lux.network/logo.svg', + vmType: 'evm', +} + +export const ZOO_MAINNET: ChainConfig = { + chainId: 200200, + chainIdHex: '0x30da8', + name: 'Zoo Mainnet', + shortName: 'ZOO', + network: 'mainnet', + nativeCurrency: { + name: 'Zoo', + symbol: 'ZOO', + decimals: 18, + }, + rpcUrls: ['https://api.zoo.network/ext/bc/C/rpc'], + blockExplorerUrls: ['https://explore.zoo.network'], + iconUrl: 'https://zoo.network/logo.svg', + vmType: 'evm', +} + +export const SPC_MAINNET: ChainConfig = { + chainId: 36911, + chainIdHex: '0x902f', + name: 'SPC Mainnet', + shortName: 'SPC', + network: 'mainnet', + nativeCurrency: { + name: 'SPC', + symbol: 'SPC', + decimals: 18, + }, + rpcUrls: ['https://api.spc.network/ext/bc/C/rpc'], + blockExplorerUrls: ['https://explore.spc.network'], + vmType: 'evm', +} + +export const HANZO_MAINNET: ChainConfig = { + chainId: 36963, + chainIdHex: '0x9063', + name: 'Hanzo Mainnet', + shortName: 'HANZO', + network: 'mainnet', + nativeCurrency: { + name: 'Hanzo', + symbol: 'HANZO', + decimals: 18, + }, + rpcUrls: ['https://api.hanzo.network/ext/bc/C/rpc'], + blockExplorerUrls: ['https://explore.hanzo.network'], + vmType: 'evm', +} + +// ============================================ +// LUX ECOSYSTEM - TESTNET CHAINS +// ============================================ + +export const LUX_TESTNET: ChainConfig = { + chainId: 96368, + chainIdHex: '0x17870', + name: 'Lux Testnet', + shortName: 'LUX-TEST', + network: 'testnet', + nativeCurrency: { + name: 'Lux', + symbol: 'LUX', + decimals: 18, + }, + rpcUrls: ['https://api.lux-test.network/ext/bc/C/rpc'], + blockExplorerUrls: ['https://explore.lux-test.network'], + iconUrl: 'https://lux.network/logo.svg', + vmType: 'evm', +} + +export const ZOO_TESTNET: ChainConfig = { + chainId: 200201, + chainIdHex: '0x30da9', + name: 'Zoo Testnet', + shortName: 'ZOO-TEST', + network: 'testnet', + nativeCurrency: { + name: 'Zoo', + symbol: 'ZOO', + decimals: 18, + }, + rpcUrls: ['https://api.zoo-test.network/ext/bc/C/rpc'], + blockExplorerUrls: ['https://explore.zoo-test.network'], + iconUrl: 'https://zoo.network/logo.svg', + vmType: 'evm', +} + +export const SPC_TESTNET: ChainConfig = { + chainId: 36912, + chainIdHex: '0x9030', + name: 'SPC Testnet', + shortName: 'SPC-TEST', + network: 'testnet', + nativeCurrency: { + name: 'SPC', + symbol: 'SPC', + decimals: 18, + }, + rpcUrls: ['https://api.spc-test.network/ext/bc/C/rpc'], + blockExplorerUrls: ['https://explore.spc-test.network'], + vmType: 'evm', +} + +export const HANZO_TESTNET: ChainConfig = { + chainId: 36962, + chainIdHex: '0x9062', + name: 'Hanzo Testnet', + shortName: 'HANZO-TEST', + network: 'testnet', + nativeCurrency: { + name: 'Hanzo', + symbol: 'HANZO', + decimals: 18, + }, + rpcUrls: ['https://api.hanzo-test.network/ext/bc/C/rpc'], + blockExplorerUrls: ['https://explore.hanzo-test.network'], + vmType: 'evm', +} + +// ============================================ +// EXTERNAL CHAINS - L1 MAINNETS +// ============================================ + +export const ETHEREUM_MAINNET: ChainConfig = { + chainId: 1, + chainIdHex: '0x1', + name: 'Ethereum', + shortName: 'ETH', + network: 'mainnet', + nativeCurrency: { + name: 'Ether', + symbol: 'ETH', + decimals: 18, + }, + rpcUrls: ['https://eth.llamarpc.com', 'https://rpc.ankr.com/eth'], + blockExplorerUrls: ['https://etherscan.io'], + vmType: 'evm', +} + +export const BNB_MAINNET: ChainConfig = { + chainId: 56, + chainIdHex: '0x38', + name: 'BNB Smart Chain', + shortName: 'BSC', + network: 'mainnet', + nativeCurrency: { + name: 'BNB', + symbol: 'BNB', + decimals: 18, + }, + rpcUrls: ['https://bsc-dataseed.binance.org', 'https://rpc.ankr.com/bsc'], + blockExplorerUrls: ['https://bscscan.com'], + vmType: 'evm', +} + +export const POLYGON_MAINNET: ChainConfig = { + chainId: 137, + chainIdHex: '0x89', + name: 'Polygon', + shortName: 'POL', + network: 'mainnet', + nativeCurrency: { + name: 'POL', + symbol: 'POL', + decimals: 18, + }, + rpcUrls: ['https://polygon-rpc.com', 'https://rpc.ankr.com/polygon'], + blockExplorerUrls: ['https://polygonscan.com'], + vmType: 'evm', +} + +export const AVALANCHE_MAINNET: ChainConfig = { + chainId: 43114, + chainIdHex: '0xa86a', + name: 'Avalanche C-Chain', + shortName: 'AVAX', + network: 'mainnet', + nativeCurrency: { + name: 'Avalanche', + symbol: 'AVAX', + decimals: 18, + }, + rpcUrls: ['https://api.avax.network/ext/bc/C/rpc'], + blockExplorerUrls: ['https://snowtrace.io'], + vmType: 'evm', +} + +// ============================================ +// EXTERNAL CHAINS - L2 MAINNETS +// ============================================ + +export const ARBITRUM_ONE: ChainConfig = { + chainId: 42161, + chainIdHex: '0xa4b1', + name: 'Arbitrum One', + shortName: 'ARB', + network: 'mainnet', + nativeCurrency: { + name: 'Ether', + symbol: 'ETH', + decimals: 18, + }, + rpcUrls: ['https://arb1.arbitrum.io/rpc', 'https://rpc.ankr.com/arbitrum'], + blockExplorerUrls: ['https://arbiscan.io'], + isL2: true, + parentChainId: 1, + vmType: 'evm', +} + +export const OPTIMISM_MAINNET: ChainConfig = { + chainId: 10, + chainIdHex: '0xa', + name: 'Optimism', + shortName: 'OP', + network: 'mainnet', + nativeCurrency: { + name: 'Ether', + symbol: 'ETH', + decimals: 18, + }, + rpcUrls: ['https://mainnet.optimism.io', 'https://rpc.ankr.com/optimism'], + blockExplorerUrls: ['https://optimistic.etherscan.io'], + isL2: true, + parentChainId: 1, + vmType: 'evm', +} + +export const BASE_MAINNET: ChainConfig = { + chainId: 8453, + chainIdHex: '0x2105', + name: 'Base', + shortName: 'BASE', + network: 'mainnet', + nativeCurrency: { + name: 'Ether', + symbol: 'ETH', + decimals: 18, + }, + rpcUrls: ['https://mainnet.base.org', 'https://rpc.ankr.com/base'], + blockExplorerUrls: ['https://basescan.org'], + isL2: true, + parentChainId: 1, + vmType: 'evm', +} + +export const BLAST_MAINNET: ChainConfig = { + chainId: 81457, + chainIdHex: '0x13e31', + name: 'Blast', + shortName: 'BLAST', + network: 'mainnet', + nativeCurrency: { + name: 'Ether', + symbol: 'ETH', + decimals: 18, + }, + rpcUrls: ['https://rpc.blast.io'], + blockExplorerUrls: ['https://blastscan.io'], + isL2: true, + parentChainId: 1, + vmType: 'evm', +} + +export const ZORA_MAINNET: ChainConfig = { + chainId: 7777777, + chainIdHex: '0x76adf1', + name: 'Zora', + shortName: 'ZORA', + network: 'mainnet', + nativeCurrency: { + name: 'Ether', + symbol: 'ETH', + decimals: 18, + }, + rpcUrls: ['https://rpc.zora.energy'], + blockExplorerUrls: ['https://explorer.zora.energy'], + isL2: true, + parentChainId: 1, + vmType: 'evm', +} + +// ============================================ +// TESTNETS +// ============================================ + +export const ETHEREUM_SEPOLIA: ChainConfig = { + chainId: 11155111, + chainIdHex: '0xaa36a7', + name: 'Sepolia', + shortName: 'SEP', + network: 'testnet', + nativeCurrency: { + name: 'Sepolia Ether', + symbol: 'ETH', + decimals: 18, + }, + rpcUrls: ['https://rpc.sepolia.org', 'https://rpc.ankr.com/eth_sepolia'], + blockExplorerUrls: ['https://sepolia.etherscan.io'], + vmType: 'evm', +} + +export const BASE_SEPOLIA: ChainConfig = { + chainId: 84532, + chainIdHex: '0x14a34', + name: 'Base Sepolia', + shortName: 'BASE-SEP', + network: 'testnet', + nativeCurrency: { + name: 'Ether', + symbol: 'ETH', + decimals: 18, + }, + rpcUrls: ['https://sepolia.base.org'], + blockExplorerUrls: ['https://sepolia.basescan.org'], + isL2: true, + parentChainId: 11155111, + vmType: 'evm', +} + +// ============================================ +// CHAIN COLLECTIONS +// ============================================ + +// All Lux ecosystem chains +export const LUX_CHAINS: ChainConfig[] = [ + LUX_MAINNET, + LUX_TESTNET, + ZOO_MAINNET, + ZOO_TESTNET, + SPC_MAINNET, + SPC_TESTNET, + HANZO_MAINNET, + HANZO_TESTNET, +] + +// External mainnet chains +export const EXTERNAL_MAINNETS: ChainConfig[] = [ + ETHEREUM_MAINNET, + BNB_MAINNET, + POLYGON_MAINNET, + AVALANCHE_MAINNET, + ARBITRUM_ONE, + OPTIMISM_MAINNET, + BASE_MAINNET, + BLAST_MAINNET, + ZORA_MAINNET, +] + +// External testnet chains +export const EXTERNAL_TESTNETS: ChainConfig[] = [ + ETHEREUM_SEPOLIA, + BASE_SEPOLIA, +] + +// All supported chains +export const ALL_CHAINS: ChainConfig[] = [ + ...LUX_CHAINS, + ...EXTERNAL_MAINNETS, + ...EXTERNAL_TESTNETS, +] + +// Mainnet-only chains +export const MAINNET_CHAINS: ChainConfig[] = ALL_CHAINS.filter( + (c) => c.network === 'mainnet' +) + +// Testnet-only chains +export const TESTNET_CHAINS: ChainConfig[] = ALL_CHAINS.filter( + (c) => c.network === 'testnet' +) + +// L2 chains +export const L2_CHAINS: ChainConfig[] = ALL_CHAINS.filter((c) => c.isL2) + +// Chain lookup by ID +export const CHAIN_BY_ID: Record = ALL_CHAINS.reduce( + (acc, chain) => { + acc[chain.chainId] = chain + return acc + }, + {} as Record +) + +// Get chain by ID +export function getChainById(chainId: number): ChainConfig | undefined { + return CHAIN_BY_ID[chainId] +} + +// Get chain by hex ID +export function getChainByHexId(hexId: string): ChainConfig | undefined { + const chainId = parseInt(hexId, 16) + return CHAIN_BY_ID[chainId] +} + +// Check if chain is supported +export function isChainSupported(chainId: number): boolean { + return chainId in CHAIN_BY_ID +} + +// Get default RPC for chain +export function getDefaultRpc(chainId: number): string | undefined { + return CHAIN_BY_ID[chainId]?.rpcUrls[0] +} + +// Get block explorer URL +export function getExplorerUrl(chainId: number): string | undefined { + return CHAIN_BY_ID[chainId]?.blockExplorerUrls[0] +} + +// Format chain for wallet_addEthereumChain +export function formatForAddChain(chain: ChainConfig) { + return { + chainId: chain.chainIdHex, + chainName: chain.name, + nativeCurrency: chain.nativeCurrency, + rpcUrls: chain.rpcUrls, + blockExplorerUrls: chain.blockExplorerUrls, + iconUrls: chain.iconUrl ? [chain.iconUrl] : undefined, + } +} diff --git a/apps/extension/src/contentScript/WindowEthereumProxy.ts b/apps/extension/src/contentScript/WindowEthereumProxy.ts new file mode 100644 index 00000000..57d86ae7 --- /dev/null +++ b/apps/extension/src/contentScript/WindowEthereumProxy.ts @@ -0,0 +1,164 @@ +import { rpcErrors, serializeError } from '@metamask/rpc-errors' +import EventEmitter from 'eventemitter3' +import { addWindowMessageListener, removeWindowMessageListener } from 'src/background/messagePassing/messageUtils' +import { ExtensionResponse, isValidExtensionResponse } from 'src/contentScript/types' +import { BaseEthereumRequest, BaseEthereumRequestSchema } from 'src/contentScript/WindowEthereumRequestTypes' +import { logger } from 'utilities/src/logger/logger' +import { v4 as uuidv4 } from 'uuid' +import { ZodError } from 'zod' + +type EthersSendCallback = (error: unknown, response: unknown) => void +type RequestInput = BaseEthereumRequest & { id?: number; jsonrpc?: string } + +const messages = { + errors: { + disconnected: (): string => 'Uniswap Wallet: Disconnected from chain. Attempting to connect.', + invalidRequestArgs: (): string => `Uniswap Wallet: Expected a single, non-array, object argument.`, + invalidRequestGeneric: (): string => `Uniswap Wallet: Please check the input passed to the request method`, + }, +} + +/** + * Proxy class that is injected at `window.ethereum` to handle all RPC and extension API requests. + * Passes along requests to the content script which then forwards and listens for requests accordingly. + */ +export class WindowEthereumProxy extends EventEmitter { + /** + * Boolean indicating that the provider is Uniswap Wallet. + */ + isUniswapWallet = true + + /** + * Boolean to spoof MetaMask + * TODO(EXT-393): Remove this once more dapps support EIP-6963 or have explicit support for Uniswap Wallet. + */ + isMetaMask: boolean + + /** + * Pending requests are stored as promises that resolve or reject based on the response from the content script. + */ + pendingRequests: { + [key: string]: { + resolve: (value: unknown) => void + reject: (error: unknown) => void + } + } + + constructor() { + super() + + this.isMetaMask = true + this.pendingRequests = {} + } + + // Deprecated EIP-11193 method + enable = async (): Promise => { + return this.request({ method: 'eth_requestAccounts' }) + } + + // Deprecated EIP-1193 method + send = ( + methodOrRequest: string | BaseEthereumRequest, + paramsOrCallback: Array | EthersSendCallback, + ): Promise | void => { + if (typeof methodOrRequest === 'string' && typeof paramsOrCallback !== 'function') { + return this.request({ + method: methodOrRequest, + params: paramsOrCallback, + }) + } else if (typeof methodOrRequest === 'object' && typeof paramsOrCallback === 'function') { + return this.sendAsync(methodOrRequest, paramsOrCallback) + } + return Promise.reject(new Error('Unsupported function parameters')) + } + + // Deprecated EIP-1193 method still in use by some DApps + sendAsync = ( + request: RequestInput, + callback: (error: unknown, response: unknown) => void, + ): Promise | void => { + return this.request(request).then( + (response) => + callback(null, { + result: response, + id: request.id, + jsonrpc: request.jsonrpc, + }), + (error) => callback(error, null), + ) + } + + request = async (args: RequestInput): Promise => { + return new Promise((resolve, reject) => { + try { + const ethereumRequest = BaseEthereumRequestSchema.parse(args) + + // Generate a unique ID for this request and store the promise callbacks + const requestId = uuidv4() + this.pendingRequests[requestId] = { resolve, reject } + const responseListener = addWindowMessageListener({ + validator: isValidExtensionResponse, + handler: (response) => { + if (response.requestId === requestId) { + this.handleResponse(response) + removeWindowMessageListener(responseListener) + } + }, + }) + window.postMessage({ + ...ethereumRequest, + requestId, + }) + return Promise.resolve() + } catch (error) { + logger.debug('WindowEthereumProxy.ts', 'request', 'Invalid request', args) + + // Based on the zod error, we can determine the type of error and reject accordingly + if (error instanceof ZodError) { + return reject( + serializeError( + rpcErrors.invalidRequest({ + message: messages.errors.invalidRequestArgs(), + data: args, + }), + ), + ) + } + + return reject( + serializeError( + rpcErrors.invalidRequest({ + message: messages.errors.invalidRequestGeneric(), + data: args, + }), + ), + ) + } + }) + } + + private handleResponse(response: ExtensionResponse): boolean { + const { requestId, result, error } = response + const promise = this.pendingRequests[requestId] + if (!promise) { + logger.debug('WindowEthereumProxy.ts', 'handleResponse', 'No promise found for request id:', requestId) + return false + } + + if (error) { + promise.reject(error) + delete this.pendingRequests[requestId] + return true + } + + promise.resolve(result) + + // Clean up after handling the response + delete this.pendingRequests[requestId] + return true + } + // Utility function representing connectivity status for RPC requests to the current chain (as opposed to user accounts). + // Method itself created by MetaMask and not in EIP spec. Necessary since some dapps supporting EIP-6963 require it. + // TODO(EXT-1255): Currently faking real status, replace with actual implementation + isConnected = (): boolean => true +} diff --git a/apps/extension/src/contentScript/WindowEthereumRequestTypes.ts b/apps/extension/src/contentScript/WindowEthereumRequestTypes.ts new file mode 100644 index 00000000..b5f12c18 --- /dev/null +++ b/apps/extension/src/contentScript/WindowEthereumRequestTypes.ts @@ -0,0 +1,376 @@ +import { EthersTransactionRequestSchema } from 'src/app/features/dappRequests/types/EthersTypes' +import { HexadecimalNumberSchema } from 'src/app/features/dappRequests/types/utilityTypes' +import { HomeTabs } from 'src/app/navigation/constants' +import { GetCallsStatusParamsSchema, SendCallsParamsSchema } from 'wallet/src/features/dappRequests/types' +import { z } from 'zod' + +/** + * Schemas + types for requests that come via `window.ethereum.request` + * e.g.: {"jsonrpc":"2.0","method":"personal_sign","params": ["0x295a70b2de5e3953354a6a8344e616ed314d7251", "0xasdfasdfasdfasdfasdfasdfa"],"id":1}' + * @see https://eips.ethereum.org/EIPS/eip-1193 + * @see https://docs.metamask.io/guide/ethereum-provider.html#ethereum-request + * @see https://docs.metamask.io/wallet/reference/json-rpc-api/ + * + * Note: Our schemas include transformations to make it easier to work with the data + */ + +export const BaseEthereumRequestSchema = z.object({ + method: z.string(), + params: z.union([z.array(z.unknown()), z.record(z.string(), z.unknown())]).optional(), +}) + +const EthereumRequestWithIdSchema = BaseEthereumRequestSchema.extend({ + requestId: z.string().uuid(), +}) + +export type BaseEthereumRequest = z.infer + +export const EthChainIdRequestSchema = EthereumRequestWithIdSchema.extend({ + method: z.literal('eth_chainId'), +}) +export type EthChainIdRequest = z.infer + +export const EthRequestAccountsRequestSchema = EthereumRequestWithIdSchema.extend({ + method: z.literal('eth_requestAccounts'), +}) +export type EthRequestAccountsRequest = z.infer + +export const EthAccountsRequestSchema = EthereumRequestWithIdSchema.extend({ + method: z.literal('eth_accounts'), +}) +export type EthAccountsRequest = z.infer +export const EthSendTransactionRequestSchema = EthereumRequestWithIdSchema.extend({ + requestId: z.string(), + method: z.literal('eth_sendTransaction'), + params: z.array(z.unknown()), +}).transform((data) => { + const { requestId, method, params } = data + if (params.length < 1) { + throw new Error('Params array must contain at least one element') + } + + const parseResult = EthersTransactionRequestSchema.safeParse(params[0]) + + if (!parseResult.success) { + throw new Error('First element of the array must match EthersTransactionRequestSchema') + } + + const transaction = parseResult.data + + return { + requestId, + method, + params, + transaction, + } +}) +export type EthSendTransactionRequest = z.infer + +export const PersonalSignRequestSchema = EthereumRequestWithIdSchema.extend({ + method: z.literal('personal_sign'), + params: z.array(z.unknown()), +}).transform((data) => { + const { requestId, method, params } = data + + if (params.length < 2) { + throw new z.ZodError([ + { + message: 'Params array must contain at least two elements', + path: ['params'], + code: 'custom', + input: undefined, + }, + ]) + } + + const messageHex = z.string().parse(params[0]) + const address = z.string().parse(params[1]) + + return { + requestId, + method, + params, + messageHex, + address, + } +}) + +export type PersonalSignRequest = z.infer + +export const EthSignTypedDataV4RequestSchema = EthereumRequestWithIdSchema.extend({ + method: z.literal('eth_signTypedData_v4'), + params: z.array(z.unknown()), +}).transform((data) => { + const { requestId, method, params } = data + + if (params.length < 2) { + throw new z.ZodError([ + { + message: 'Params array must contain at least two elements', + path: ['params'], + code: 'custom', + input: undefined, + }, + ]) + } + + const address = z.string().parse(params[0]) + const typedData = z.string().parse(params[1]) + + const chainId = JSON.parse(typedData)?.domain?.chainId + const formattedChainId = HexadecimalNumberSchema.parse(chainId) + if (!formattedChainId) { + throw new z.ZodError([ + { + message: 'Typed data must contain a chainId', + path: ['params', '1'], + code: 'custom', + input: undefined, + }, + ]) + } + return { + requestId, + method, + params, + address, + typedData, + } +}) +export type EthSignTypedDataV4Request = z.infer + +const SwitchEthereumChainParameterSchema = z.object({ + chainId: z.string(), +}) + +export const WalletSwitchEthereumChainRequestSchema = EthereumRequestWithIdSchema.extend({ + method: z.literal('wallet_switchEthereumChain'), + params: z.array(z.unknown()), +}).transform((data) => { + const { requestId, method, params } = data + if (params.length < 1) { + throw new z.ZodError([ + { + message: 'Params array must contain at least one element', + path: ['params'], + code: 'custom', + input: undefined, + }, + ]) + } + + const parseResult = SwitchEthereumChainParameterSchema.safeParse(params[0]) + if (!parseResult.success) { + throw new z.ZodError([ + { + message: 'Chain id should be specified as a hexadecimal string within object', + path: ['params', '0'], + code: 'custom', + input: undefined, + }, + ]) + } + + const { chainId } = parseResult.data + + return { + requestId, + method, + params, + chainId, + } +}) +export type WalletSwitchEthereumChainRequest = z.infer + +// oxlint-disable-next-line no-restricted-syntax +export const PermissionRequestSchema = z.record(z.string(), z.record(z.string(), z.any())) + +const CaveatSchema = z.object({ + type: z.string(), + // oxlint-disable-next-line no-restricted-syntax + value: z.any(), +}) + +export const PermissionSchema = z.object({ + invoker: z.string(), + parentCapability: z.string(), + caveats: z.array(CaveatSchema), +}) +export type Permission = z.infer + +export const WalletRequestPermissionsRequestSchema = EthereumRequestWithIdSchema.extend({ + method: z.literal('wallet_requestPermissions'), + params: z.array(z.unknown()), +}).transform((data) => { + const { requestId, method, params } = data + if (params.length < 1) { + throw new z.ZodError([ + { + message: 'Params array must contain at least one element', + path: ['params'], + code: 'custom', + input: undefined, + }, + ]) + } + + const permissions = PermissionRequestSchema.parse(params[0]) + + return { + requestId, + method, + params, + permissions, + } +}) + +export type WalletRequestPermissionsRequest = z.infer + +export const WalletRevokePermissionsRequestSchema = EthereumRequestWithIdSchema.extend({ + method: z.literal('wallet_revokePermissions'), + params: z.array(z.unknown()), +}).transform((data) => { + const { requestId, method, params } = data + if (params.length < 1) { + throw new z.ZodError([ + { + message: 'Params array must contain at least one element', + path: ['params'], + code: 'custom', + input: undefined, + }, + ]) + } + + const permissions = PermissionRequestSchema.parse(params[0]) + + return { + requestId, + method, + params, + permissions, + } +}) + +export type WalletRevokePermissionsRequest = z.infer + +export const WalletGetPermissionsRequestSchema = EthereumRequestWithIdSchema.extend({ + method: z.literal('wallet_getPermissions'), +}) +export type WalletGetPermissionsRequest = z.infer + +export const WalletGetCapabilitiesRequestSchema = EthereumRequestWithIdSchema.extend({ + method: z.literal('wallet_getCapabilities'), + params: z.array(z.unknown()), +}).transform((data) => { + const { requestId, method, params } = data + + if (params.length < 1) { + throw new z.ZodError([ + { + message: 'Params array must contain at least one element (address)', + path: ['params'], + code: 'custom', + input: undefined, + }, + ]) + } + + const address = z.string().parse(params[0]) + const chainIds = params.length > 1 ? z.array(z.string()).parse(params[1]) : undefined + + return { + requestId, + method, + address, + chainIds, + } +}) + +export type WalletGetCapabilitiesRequest = z.infer + +export const UniswapOpenSidebarRequestSchema = EthereumRequestWithIdSchema.extend({ + method: z.literal('uniswap_openSidebar'), + params: z.array(z.unknown()), +}).transform((data) => { + const tab = z.nativeEnum(HomeTabs).optional().parse(data.params[0]) + return { + ...data, + tab, + } +}) + +export type UniswapOpenSidebarRequest = z.infer + +export const WalletSendCallsRequestSchema = EthereumRequestWithIdSchema.extend({ + method: z.literal('wallet_sendCalls'), + params: z.array(z.unknown()), +}).transform((data) => { + const { requestId, method, params } = data + + if (params.length < 1) { + throw new z.ZodError([ + { + message: 'Params array must contain at least one element', + path: ['params'], + code: 'custom', + input: undefined, + }, + ]) + } + + const parseResult = SendCallsParamsSchema.safeParse(params[0]) + + if (!parseResult.success) { + throw new Error('First element of the array must match SendCallsParamsSchema') + } + + if (!parseResult.data.from) { + throw new Error('From address must be specified') + } + + const { calls, chainId, from, id, capabilities, version } = parseResult.data + + return { + requestId, + method, + params, + calls, + chainId, + from, + id, + capabilities, + version, + } +}) + +export type WalletSendCallsRequest = z.infer + +export const WalletGetCallsStatusRequestSchema = EthereumRequestWithIdSchema.extend({ + method: z.literal('wallet_getCallsStatus'), + params: z.array(z.unknown()), +}).transform((data) => { + const { requestId, method, params } = data + + if (params.length < 1) { + throw new z.ZodError([ + { + message: 'Params array must contain at least one element', + path: ['params'], + code: 'custom', + input: undefined, + }, + ]) + } + + const batchId = GetCallsStatusParamsSchema.parse(params[0]) + + return { + requestId, + method, + params, + batchId, + } +}) + +export type WalletGetCallsStatusRequest = z.infer diff --git a/apps/extension/src/contentScript/ethereum.test.ts b/apps/extension/src/contentScript/ethereum.test.ts new file mode 100644 index 00000000..e1cd0911 --- /dev/null +++ b/apps/extension/src/contentScript/ethereum.test.ts @@ -0,0 +1,113 @@ +let mockIsSandboxed = false + +jest.mock('src/contentScript/isSandboxedFrame', () => ({ + isSandboxedFrame: jest.fn(() => mockIsSandboxed), +})) + +jest.mock('wxt/utils/define-content-script', () => ({ + defineContentScript: jest.fn((definition) => definition), +})) + +jest.mock('uuid', () => ({ + v4: jest.fn(() => 'mock-uuid'), +})) + +jest.mock('src/background/messagePassing/messageUtils', () => ({ + addWindowMessageListener: jest.fn(), + removeWindowMessageListener: jest.fn(), +})) + +jest.mock('utilities/src/logger/logger', () => ({ + logger: { + debug: jest.fn(), + error: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + }, +})) + +jest.mock('src/contentScript/WindowEthereumProxy', () => ({ + WindowEthereumProxy: jest.fn().mockImplementation(() => ({ + emit: jest.fn(), + isMetaMask: false, + })), +})) + +describe('ethereum.content', () => { + let definition: { main: () => void } + let postMessageSpy: jest.SpyInstance + + beforeEach(() => { + jest.resetModules() + // Reset the mock flag before re-requiring + mockIsSandboxed = false + // Clear window.ethereum + Object.defineProperty(window, 'ethereum', { value: undefined, writable: true, configurable: true }) + // jsdom's postMessage requires a targetOrigin; spy to prevent the call from throwing + postMessageSpy = jest.spyOn(window, 'postMessage').mockImplementation(jest.fn()) + }) + + afterEach(() => { + postMessageSpy.mockRestore() + }) + + describe('normal frame', () => { + beforeEach(() => { + mockIsSandboxed = false + // eslint-disable-next-line @typescript-eslint/no-var-requires + definition = require('../entrypoints/ethereum.content').default + }) + + it('assigns window.ethereum after main()', () => { + const eip6963Listener = jest.fn() + window.addEventListener('eip6963:announceProvider', eip6963Listener) + + definition.main() + + expect(window.ethereum).toBeDefined() + + window.removeEventListener('eip6963:announceProvider', eip6963Listener) + }) + + it('fires EIP-6963 announceProvider event', () => { + const eip6963Listener = jest.fn() + window.addEventListener('eip6963:announceProvider', eip6963Listener) + + definition.main() + + expect(eip6963Listener).toHaveBeenCalled() + + window.removeEventListener('eip6963:announceProvider', eip6963Listener) + }) + }) + + describe('sandboxed frame', () => { + beforeEach(() => { + mockIsSandboxed = true + // eslint-disable-next-line @typescript-eslint/no-var-requires + definition = require('../entrypoints/ethereum.content').default + }) + + it('does NOT assign window.ethereum', () => { + const eip6963Listener = jest.fn() + window.addEventListener('eip6963:announceProvider', eip6963Listener) + + definition.main() + + expect(window.ethereum).toBeUndefined() + + window.removeEventListener('eip6963:announceProvider', eip6963Listener) + }) + + it('does NOT fire EIP-6963 announceProvider event', () => { + const eip6963Listener = jest.fn() + window.addEventListener('eip6963:announceProvider', eip6963Listener) + + definition.main() + + expect(eip6963Listener).not.toHaveBeenCalled() + + window.removeEventListener('eip6963:announceProvider', eip6963Listener) + }) + }) +}) diff --git a/apps/extension/src/contentScript/index.tsx b/apps/extension/src/contentScript/index.tsx new file mode 100644 index 00000000..96d0a1de --- /dev/null +++ b/apps/extension/src/contentScript/index.tsx @@ -0,0 +1,9 @@ +import React from 'react' +import { createRoot } from 'react-dom/client' + +const container = document.createElement('div') +container.id = 'crx-root' +document.body.append(container) + +const root = createRoot(container) +root.render() diff --git a/apps/extension/src/contentScript/injected.test.ts b/apps/extension/src/contentScript/injected.test.ts new file mode 100644 index 00000000..0425eabc --- /dev/null +++ b/apps/extension/src/contentScript/injected.test.ts @@ -0,0 +1,50 @@ +jest.mock('src/background/messagePassing/messageChannels') +jest.mock('wxt/utils/define-content-script', () => ({ + defineContentScript: jest.fn((definition) => definition), +})) +jest.mock('src/contentScript/isSandboxedFrame', () => ({ + isSandboxedFrame: jest.fn(() => false), +})) + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { isSandboxedFrame } = require('src/contentScript/isSandboxedFrame') as { + isSandboxedFrame: jest.Mock +} + +describe('injected', () => { + it('should run without throwing an error', () => { + // This does not exist in the extension execution environment for content scripts + Object.defineProperty(document, 'head', { value: undefined, writable: true }) + + // oxlint-disable-next-line typescript/no-var-requires + const injected = require('../entrypoints/injected.content') + expect(injected).toBeTruthy() + }) +}) + +describe('injected - sandboxed frame', () => { + beforeEach(() => { + isSandboxedFrame.mockReturnValue(true) + }) + + afterEach(() => { + isSandboxedFrame.mockReturnValue(false) + }) + + it('should load without error in sandbox mode', () => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { addWindowMessageListener } = require('src/background/messagePassing/messageUtils') as { + addWindowMessageListener: jest.Mock + } + + // eslint-disable-next-line @typescript-eslint/no-var-requires + const injected = require('../entrypoints/injected.content') + expect(injected).toBeTruthy() + + // In sandbox mode, isSandboxedFrame() returns true and makeInjected() bails out early, + // so addWindowMessageListener should NOT have been called for request handling. + // Note: since the module was already required above, this test verifies the module + // loads without error when the sandbox check is active. + expect(addWindowMessageListener).toBeDefined() + }) +}) diff --git a/apps/extension/src/contentScript/isSandboxedFrame.test.ts b/apps/extension/src/contentScript/isSandboxedFrame.test.ts new file mode 100644 index 00000000..ee9e0b82 --- /dev/null +++ b/apps/extension/src/contentScript/isSandboxedFrame.test.ts @@ -0,0 +1,33 @@ +import { isSandboxedFrame } from 'src/contentScript/isSandboxedFrame' + +describe('isSandboxedFrame', () => { + const originalOrigin = window.origin + + afterEach(() => { + Object.defineProperty(window, 'origin', { + value: originalOrigin, + writable: true, + configurable: true, + }) + }) + + it('returns true when window.origin is "null" (sandboxed without allow-same-origin)', () => { + Object.defineProperty(window, 'origin', { value: 'null', writable: true, configurable: true }) + expect(isSandboxedFrame()).toBe(true) + }) + + it('returns false when window.origin is a normal https URL', () => { + Object.defineProperty(window, 'origin', { value: 'https://example.com', writable: true, configurable: true }) + expect(isSandboxedFrame()).toBe(false) + }) + + it('returns false when window.origin is localhost', () => { + Object.defineProperty(window, 'origin', { value: 'http://localhost', writable: true, configurable: true }) + expect(isSandboxedFrame()).toBe(false) + }) + + it('returns false when window.origin is empty string', () => { + Object.defineProperty(window, 'origin', { value: '', writable: true, configurable: true }) + expect(isSandboxedFrame()).toBe(false) + }) +}) diff --git a/apps/extension/src/contentScript/isSandboxedFrame.ts b/apps/extension/src/contentScript/isSandboxedFrame.ts new file mode 100644 index 00000000..24653aff --- /dev/null +++ b/apps/extension/src/contentScript/isSandboxedFrame.ts @@ -0,0 +1,13 @@ +/** + * Detects whether the current frame is sandboxed without the `allow-same-origin` flag. + * + * Browsers set `window.origin` to the string `"null"` for frames with a `sandbox` + * attribute that does not include `allow-same-origin`. These frames cannot be trusted + * because an attacker can embed malicious scripts inside them on trusted domains + * (e.g., OpenSea NFT embeds) and trigger wallet prompts attributed to the parent domain. + * + * See bug bounty finding #621. + */ +export function isSandboxedFrame(): boolean { + return typeof window !== 'undefined' && window.origin === 'null' +} diff --git a/apps/extension/src/contentScript/methodHandlers/BaseMethodHandler.ts b/apps/extension/src/contentScript/methodHandlers/BaseMethodHandler.ts new file mode 100644 index 00000000..bee2872d --- /dev/null +++ b/apps/extension/src/contentScript/methodHandlers/BaseMethodHandler.ts @@ -0,0 +1,16 @@ +import { JsonRpcProvider } from '@ethersproject/providers' +import { WindowEthereumRequest } from 'src/contentScript/types' + +export abstract class BaseMethodHandler { + // oxlint-disable-next-line max-params + constructor( + protected readonly getChainId: () => string | undefined, + protected readonly getProvider: () => JsonRpcProvider | undefined, + protected readonly getConnectedAddresses: () => Address[] | undefined, + protected readonly setChainIdAndMaybeEmit: (newChainId: string) => void, + protected readonly setProvider: (newProvider: JsonRpcProvider) => void, + protected readonly setConnectedAddressesAndMaybeEmit: (newConnectedAddresses: Address[]) => void, + ) {} + + handleRequest(_request: T, _source: MessageEventSource | null): void {} +} diff --git a/apps/extension/src/contentScript/methodHandlers/ExtensionEthMethodHandler.ts b/apps/extension/src/contentScript/methodHandlers/ExtensionEthMethodHandler.ts new file mode 100644 index 00000000..59fefc1f --- /dev/null +++ b/apps/extension/src/contentScript/methodHandlers/ExtensionEthMethodHandler.ts @@ -0,0 +1,690 @@ +/* oxlint-disable max-lines */ +import { JsonRpcProvider } from '@ethersproject/providers' +import { getPermissions } from 'src/app/features/dappRequests/permissions' +import { SendTransactionRequest } from 'src/app/features/dappRequests/types/DappRequestTypes' +import { + contentScriptToBackgroundMessageChannel, + dappResponseMessageChannel, +} from 'src/background/messagePassing/messageChannels' +import getCalldataInfoFromTransaction from 'src/background/utils/getCalldataInfoFromTransaction' +import { BaseMethodHandler } from 'src/contentScript/methodHandlers/BaseMethodHandler' +import { PendingResponseInfo } from 'src/contentScript/methodHandlers/types' +import { + getPendingResponseInfo, + postUnauthorizedError, + rejectSelfCallWithData, +} from 'src/contentScript/methodHandlers/utils' +import { WindowEthereumRequest } from 'src/contentScript/types' +import { + EthAccountsRequest, + EthAccountsRequestSchema, + EthChainIdRequest, + EthChainIdRequestSchema, + EthRequestAccountsRequest, + EthRequestAccountsRequestSchema, + EthSendTransactionRequest, + EthSendTransactionRequestSchema, + EthSignTypedDataV4Request, + EthSignTypedDataV4RequestSchema, + PersonalSignRequest, + PersonalSignRequestSchema, + WalletGetCallsStatusRequest, + WalletGetCallsStatusRequestSchema, + WalletGetCapabilitiesRequest, + WalletGetCapabilitiesRequestSchema, + WalletGetPermissionsRequest, + WalletGetPermissionsRequestSchema, + WalletRequestPermissionsRequest, + WalletRequestPermissionsRequestSchema, + WalletRevokePermissionsRequest, + WalletRevokePermissionsRequestSchema, + WalletSendCallsRequest, + WalletSendCallsRequestSchema, + WalletSwitchEthereumChainRequest, + WalletSwitchEthereumChainRequestSchema, +} from 'src/contentScript/WindowEthereumRequestTypes' +import { UniverseChainId } from 'uniswap/src/features/chains/types' +import { chainIdToHexadecimalString, toSupportedChainId } from 'uniswap/src/features/chains/utils' +import { DappRequestType, DappResponseType, EthMethod } from 'uniswap/src/features/dappRequests/types' +import { isSelfCallWithData } from 'uniswap/src/features/dappRequests/utils' +import { Platform } from 'uniswap/src/features/platforms/types/Platform' +import { InstrumentedJsonRpcProvider } from 'uniswap/src/features/providers/observability/InstrumentedJsonRpcProvider' +import { getRpcObserver } from 'uniswap/src/features/providers/observability/rpcObserver' +import { areAddressesEqual } from 'uniswap/src/utils/addresses' +import { extractBaseUrl } from 'utilities/src/format/urls' + +export class ExtensionEthMethodHandler extends BaseMethodHandler { + private readonly requestIdToSourceMap: Map = new Map() + + constructor({ + getChainId, + getProvider, + getConnectedAddresses, + setChainIdAndMaybeEmit, + setProvider, + setConnectedAddressesAndMaybeEmit, + }: { + getChainId: () => string | undefined + getProvider: () => JsonRpcProvider | undefined + getConnectedAddresses: () => Address[] | undefined + setChainIdAndMaybeEmit: (newChainId: string) => void + setProvider: (newProvider: JsonRpcProvider) => void + setConnectedAddressesAndMaybeEmit: (newConnectedAddresses: Address[]) => void + }) { + super( + getChainId, + getProvider, + getConnectedAddresses, + setChainIdAndMaybeEmit, + setProvider, + setConnectedAddressesAndMaybeEmit, + ) + + dappResponseMessageChannel.addMessageListener(DappResponseType.AccountResponse, (message) => { + const source = getPendingResponseInfo({ + requestIdToSourceMap: this.requestIdToSourceMap, + requestId: message.requestId, + type: DappResponseType.AccountResponse, + })?.source + + this.handleDappUpdate({ + connectedAddresses: message.connectedAddresses, + chainId: message.chainId, + providerUrl: message.providerUrl, + }) + source?.postMessage({ + requestId: message.requestId, + result: message.connectedAddresses, + }) + }) + + dappResponseMessageChannel.addMessageListener(DappResponseType.ChainIdResponse, (message) => { + const source = getPendingResponseInfo({ + requestIdToSourceMap: this.requestIdToSourceMap, + requestId: message.requestId, + type: DappResponseType.ChainIdResponse, + })?.source + + source?.postMessage({ + requestId: message.requestId, + result: message.chainId, + }) + + const chainId = this.getChainId() + if (!chainId) { + window.postMessage({ + emitKey: 'connect', + emitValue: { + chainId: message.chainId, + }, + }) + } + + this.setChainIdAndMaybeEmit(message.chainId) + }) + + dappResponseMessageChannel.addMessageListener(DappResponseType.ChainChangeResponse, (message) => { + const source = getPendingResponseInfo({ + requestIdToSourceMap: this.requestIdToSourceMap, + requestId: message.requestId, + type: DappResponseType.ChainChangeResponse, + })?.source + + this.setChainIdAndMaybeEmit(message.chainId) + this.setProvider( + new InstrumentedJsonRpcProvider({ + url: message.providerUrl, + chainIdOrNetwork: parseInt(message.chainId), + observer: getRpcObserver(), + }), + ) + source?.postMessage({ + requestId: message.requestId, + result: message.chainId, + }) + }) + + dappResponseMessageChannel.addMessageListener(DappResponseType.SendTransactionResponse, (message) => { + const source = getPendingResponseInfo({ + requestIdToSourceMap: this.requestIdToSourceMap, + requestId: message.requestId, + type: DappResponseType.SendTransactionResponse, + })?.source + + source?.postMessage({ + requestId: message.requestId, + result: message.transactionHash, + }) + }) + + dappResponseMessageChannel.addMessageListener(DappResponseType.SignMessageResponse, (message) => { + const source = getPendingResponseInfo({ + requestIdToSourceMap: this.requestIdToSourceMap, + requestId: message.requestId, + type: DappResponseType.SignMessageResponse, + })?.source + + source?.postMessage({ + requestId: message.requestId, + result: message.signature, + }) + }) + + dappResponseMessageChannel.addMessageListener(DappResponseType.SignTransactionResponse, (message) => { + const source = getPendingResponseInfo({ + requestIdToSourceMap: this.requestIdToSourceMap, + requestId: message.requestId, + type: DappResponseType.SignTransactionResponse, + })?.source + + source?.postMessage({ + requestId: message.requestId, + result: message.signedTransactionHash, + }) + }) + + dappResponseMessageChannel.addMessageListener(DappResponseType.SignTypedDataResponse, (message) => { + const source = getPendingResponseInfo({ + requestIdToSourceMap: this.requestIdToSourceMap, + requestId: message.requestId, + type: DappResponseType.SignTypedDataResponse, + })?.source + + source?.postMessage({ + requestId: message.requestId, + result: message.signature, + }) + }) + + dappResponseMessageChannel.addMessageListener(DappResponseType.RequestPermissionsResponse, (message) => { + if (message.accounts) { + const { connectedAddresses, chainId, providerUrl } = message.accounts + this.handleDappUpdate({ connectedAddresses, chainId, providerUrl }) + } + const source = getPendingResponseInfo({ + requestIdToSourceMap: this.requestIdToSourceMap, + requestId: message.requestId, + type: DappResponseType.RequestPermissionsResponse, + })?.source + + source?.postMessage({ + requestId: message.requestId, + result: message.permissions, + }) + }) + + dappResponseMessageChannel.addMessageListener(DappResponseType.RevokePermissionsResponse, (message) => { + const source = getPendingResponseInfo({ + requestIdToSourceMap: this.requestIdToSourceMap, + requestId: message.requestId, + type: DappResponseType.RevokePermissionsResponse, + })?.source + + source?.postMessage({ + requestId: message.requestId, + result: null, + }) + }) + + dappResponseMessageChannel.addMessageListener(DappResponseType.ErrorResponse, (message) => { + const source = getPendingResponseInfo({ + requestIdToSourceMap: this.requestIdToSourceMap, + requestId: message.requestId, + type: DappResponseType.ErrorResponse, + })?.source + + source?.postMessage(message) + }) + + dappResponseMessageChannel.addMessageListener(DappResponseType.SendCallsResponse, (message) => { + const source = getPendingResponseInfo({ + requestIdToSourceMap: this.requestIdToSourceMap, + requestId: message.requestId, + type: DappResponseType.SendCallsResponse, + })?.source + + source?.postMessage({ + requestId: message.requestId, + result: message.response, + }) + }) + + dappResponseMessageChannel.addMessageListener(DappResponseType.GetCallsStatusResponse, (message) => { + const source = getPendingResponseInfo({ + requestIdToSourceMap: this.requestIdToSourceMap, + requestId: message.requestId, + type: DappResponseType.GetCallsStatusResponse, + })?.source + + source?.postMessage({ + requestId: message.requestId, + result: message.response, + }) + }) + + dappResponseMessageChannel.addMessageListener(DappResponseType.GetCapabilitiesResponse, (message) => { + const source = getPendingResponseInfo({ + requestIdToSourceMap: this.requestIdToSourceMap, + requestId: message.requestId, + type: DappResponseType.GetCapabilitiesResponse, + })?.source + + source?.postMessage({ + requestId: message.requestId, + result: message.response, + }) + }) + } + + private isAuthorized(): boolean { + const connectedAddresses = this.getConnectedAddresses() + return !!connectedAddresses?.length + } + + private isConnectedToDapp(): boolean { + // Fields that should be populated for connected dapps + return Boolean(this.getConnectedAddresses()?.length && this.getChainId() && this.getProvider()) + } + + private handleDappUpdate({ + connectedAddresses, + chainId, + providerUrl, + }: { + connectedAddresses: string[] + chainId: string + providerUrl: string + }): void { + this.setConnectedAddressesAndMaybeEmit(connectedAddresses) + this.setChainIdAndMaybeEmit(chainId) + this.setProvider( + new InstrumentedJsonRpcProvider({ + url: providerUrl, + chainIdOrNetwork: parseInt(chainId), + observer: getRpcObserver(), + }), + ) + } + + // oxlint-disable-next-line complexity + async handleRequest(request: WindowEthereumRequest, source: MessageEventSource | null): Promise { + switch (request.method) { + case EthMethod.EthChainId: { + const ethChainIdRequest = EthChainIdRequestSchema.parse(request) + await this.handleEthChainIdRequest(ethChainIdRequest, source) + break + } + case EthMethod.EthRequestAccounts: { + const parsedRequest = EthRequestAccountsRequestSchema.parse(request) + await this.handleEthRequestAccounts(parsedRequest, source) + break + } + case EthMethod.EthAccounts: { + const parsedRequest = EthAccountsRequestSchema.parse(request) + await this.handleEthAccounts(parsedRequest, source) + break + } + case EthMethod.EthSendTransaction: { + if (!this.isAuthorized()) { + postUnauthorizedError(source, request.requestId) + return + } + const parsedRequest = EthSendTransactionRequestSchema.parse(request) + await this.handleEthSendTransaction(parsedRequest, source) + break + } + case EthMethod.WalletGetCapabilities: { + if (!this.isAuthorized()) { + postUnauthorizedError(source, request.requestId) + return + } + const parsedRequest = WalletGetCapabilitiesRequestSchema.parse(request) + if (!this.isValidRequestAddress(parsedRequest.address)) { + postUnauthorizedError(source, request.requestId) + return + } + await this.handleWalletGetCapabilities(parsedRequest, source) + break + } + case EthMethod.WalletSwitchEthereumChain: { + if (!this.isAuthorized()) { + postUnauthorizedError(source, request.requestId) + return + } + const parsedRequest = WalletSwitchEthereumChainRequestSchema.parse(request) + await this.handleWalletSwitchEthereumChain(parsedRequest, source) + break + } + case EthMethod.WalletGetPermissions: { + const parsedRequest = WalletGetPermissionsRequestSchema.parse(request) + await this.handleWalletGetPermissions(parsedRequest, source) + break + } + + case EthMethod.WalletRequestPermissions: { + const parsedRequest = WalletRequestPermissionsRequestSchema.parse(request) + await this.handleWalletRequestPermissions(parsedRequest, source) + break + } + case EthMethod.WalletRevokePermissions: { + const parsedRequest = WalletRevokePermissionsRequestSchema.parse(request) + await this.handleWalletRevokePermissions(parsedRequest, source) + break + } + case EthMethod.PersonalSign: { + if (!this.isAuthorized()) { + postUnauthorizedError(source, request.requestId) + return + } + + const parsedRequest = PersonalSignRequestSchema.parse(request) + if (!this.isValidRequestAddress(parsedRequest.address)) { + postUnauthorizedError(source, request.requestId) + return + } + + await this.handlePersonalSign(parsedRequest, source) + break + } + case EthMethod.SignTypedDataV4: { + if (!this.isAuthorized()) { + postUnauthorizedError(source, request.requestId) + return + } + + const parsedRequest = EthSignTypedDataV4RequestSchema.parse(request) + if (!this.isValidRequestAddress(parsedRequest.address)) { + postUnauthorizedError(source, request.requestId) + return + } + + await this.handleEthSignTypedData(parsedRequest, source) + break + } + case EthMethod.WalletSendCalls: { + if (!this.isAuthorized()) { + postUnauthorizedError(source, request.requestId) + return + } + const parsedRequest = WalletSendCallsRequestSchema.parse(request) + await this.handleWalletSendCalls(parsedRequest, source) + break + } + case EthMethod.WalletGetCallsStatus: { + if (!this.isAuthorized()) { + postUnauthorizedError(source, request.requestId) + return + } + const parsedRequest = WalletGetCallsStatusRequestSchema.parse(request) + await this.handleWalletGetCallsStatus(parsedRequest, source) + break + } + } + } + + async handleEthChainIdRequest(request: EthChainIdRequest, source: MessageEventSource | null): Promise { + // TODO: WALL-4919: Remove hardcoded Mainnet + // Defaults to mainnet for unconnected dapps + const chainId = this.getChainId() ?? chainIdToHexadecimalString(UniverseChainId.Mainnet) + + source?.postMessage({ + requestId: request.requestId, + result: chainId, + }) + return + } + + async handleEthRequestAccounts(request: EthRequestAccountsRequest, source: MessageEventSource | null): Promise { + const connectedAddresses = this.getConnectedAddresses() + + if (connectedAddresses?.length && this.isConnectedToDapp()) { + source?.postMessage({ + requestId: request.requestId, + result: connectedAddresses, + }) + return + } + + this.requestIdToSourceMap.set(request.requestId, { + type: DappResponseType.AccountResponse, + source, + }) + + await contentScriptToBackgroundMessageChannel.sendMessage({ + type: DappRequestType.RequestAccount, + requestId: request.requestId, + }) + } + + async handleEthAccounts(request: EthAccountsRequest, source: MessageEventSource | null): Promise { + const connectedAddresses = this.getConnectedAddresses() + + if (connectedAddresses?.length && this.isConnectedToDapp()) { + source?.postMessage({ + requestId: request.requestId, + result: connectedAddresses, + }) + return + } + + postUnauthorizedError(source, request.requestId) + } + + async handleEthSendTransaction(request: EthSendTransactionRequest, source: MessageEventSource | null): Promise { + // Reject transactions where from === to and data !== undefined (self-calls with data) + if ( + isSelfCallWithData({ + from: request.transaction.from, + to: request.transaction.to, + data: request.transaction.data, + chainId: toSupportedChainId(request.transaction.chainId) ?? undefined, + }) + ) { + rejectSelfCallWithData(request.requestId, source) + return + } + + this.requestIdToSourceMap.set(request.requestId, { + type: DappResponseType.SendTransactionResponse, + source, + }) + + const sendTransactionRequest: SendTransactionRequest = { + type: DappRequestType.SendTransaction, + requestId: request.requestId, + transaction: adaptTransactionForEthers(request.transaction), + } + + // native transactions like native send will not have populated data field + if (request.transaction.data && request.transaction.data !== '0x') { + Object.assign( + sendTransactionRequest, + getCalldataInfoFromTransaction({ + data: request.transaction.data, + to: request.transaction.to, + chainId: request.transaction.chainId, + }), + ) + } + + await contentScriptToBackgroundMessageChannel.sendMessage(sendTransactionRequest) + } + + async handlePersonalSign(request: PersonalSignRequest, source: MessageEventSource | null): Promise { + this.requestIdToSourceMap.set(request.requestId, { + type: DappResponseType.SignMessageResponse, + source, + }) + + await contentScriptToBackgroundMessageChannel.sendMessage({ + type: DappRequestType.SignMessage, + requestId: request.requestId, + messageHex: request.messageHex, + address: request.address, + }) + } + + async handleEthSignTypedData(request: EthSignTypedDataV4Request, source: MessageEventSource | null): Promise { + this.requestIdToSourceMap.set(request.requestId, { + type: DappResponseType.SignTypedDataResponse, + source, + }) + + await contentScriptToBackgroundMessageChannel.sendMessage({ + type: DappRequestType.SignTypedData, + requestId: request.requestId, + typedData: request.typedData, + address: request.address, + }) + } + + async handleWalletSwitchEthereumChain( + request: WalletSwitchEthereumChainRequest, + source: MessageEventSource | null, + ): Promise { + this.requestIdToSourceMap.set(request.requestId, { + type: DappResponseType.ChainChangeResponse, + source, + }) + + await contentScriptToBackgroundMessageChannel.sendMessage({ + type: DappRequestType.ChangeChain, + requestId: request.requestId, + chainId: request.chainId, + }) + } + + async handleWalletGetPermissions( + request: WalletGetPermissionsRequest, + source: MessageEventSource | null, + ): Promise { + const dappUrl = extractBaseUrl(window.origin) + const connectedAddresses = this.getConnectedAddresses() + + const permissions = getPermissions(dappUrl, connectedAddresses) + + source?.postMessage({ + requestId: request.requestId, + result: permissions, + }) + } + + async handleWalletRequestPermissions( + request: WalletRequestPermissionsRequest, + source: MessageEventSource | null, + ): Promise { + this.requestIdToSourceMap.set(request.requestId, { + type: DappResponseType.RequestPermissionsResponse, + source, + }) + + await contentScriptToBackgroundMessageChannel.sendMessage({ + type: DappRequestType.RequestPermissions, + requestId: request.requestId, + permissions: request.permissions, + }) + } + + async handleWalletRevokePermissions( + request: WalletRevokePermissionsRequest, + source: MessageEventSource | null, + ): Promise { + this.requestIdToSourceMap.set(request.requestId, { + type: DappResponseType.RevokePermissionsResponse, + source, + }) + + await contentScriptToBackgroundMessageChannel.sendMessage({ + type: DappRequestType.RevokePermissions, + requestId: request.requestId, + permissions: request.permissions, + }) + } + + private isValidRequestAddress(address: string): boolean { + return (this.getConnectedAddresses() ?? []).some((connectedAddress) => + areAddressesEqual({ + addressInput1: { address: connectedAddress, platform: Platform.EVM }, + addressInput2: { address, platform: Platform.EVM }, + }), + ) + } + + /** + * Handle wallet_getCapabilities request + * This returns the capabilities supported by the wallet for specific chains + */ + async handleWalletGetCapabilities( + request: WalletGetCapabilitiesRequest, + source: MessageEventSource | null, + ): Promise { + this.requestIdToSourceMap.set(request.requestId, { + type: DappResponseType.GetCapabilitiesResponse, + source, + }) + + await contentScriptToBackgroundMessageChannel.sendMessage({ + type: DappRequestType.GetCapabilities, + ...request, + }) + } + + /** + * Handle wallet_sendCalls request + * This method allows dapps to send a batch of calls to the wallet + */ + async handleWalletSendCalls(request: WalletSendCallsRequest, source: MessageEventSource | null): Promise { + // Reject if any calls have from === to and data !== undefined + const hasSelfCallWithData = request.calls.some((call) => + isSelfCallWithData({ + from: request.from, + to: call.to, + data: call.data, + chainId: toSupportedChainId(request.chainId) ?? undefined, + }), + ) + + if (hasSelfCallWithData) { + rejectSelfCallWithData(request.requestId, source) + return + } + + this.requestIdToSourceMap.set(request.requestId, { + type: DappResponseType.SendCallsResponse, + source, + }) + + await contentScriptToBackgroundMessageChannel.sendMessage({ + type: DappRequestType.SendCalls, + ...request, + }) + } + + /** + * Handle wallet_getCallsStatus request + * This method returns the status of a call batch that was sent via wallet_sendCalls + */ + async handleWalletGetCallsStatus( + request: WalletGetCallsStatusRequest, + source: MessageEventSource | null, + ): Promise { + this.requestIdToSourceMap.set(request.requestId, { + type: DappResponseType.GetCallsStatusResponse, + source, + }) + + await contentScriptToBackgroundMessageChannel.sendMessage({ + type: DappRequestType.GetCallsStatus, + requestId: request.requestId, + batchId: request.batchId, + }) + } +} + +// oxlint-disable-next-line typescript/no-explicit-any -- Transaction object from dapp can have various shapes requiring flexible typing +function adaptTransactionForEthers(transaction: any): any { + if (typeof transaction.chainId === 'string') { + transaction.chainId = parseInt(transaction.chainId, 16) + } + return transaction +} diff --git a/apps/extension/src/contentScript/methodHandlers/LuxMethodHandler.ts b/apps/extension/src/contentScript/methodHandlers/LuxMethodHandler.ts new file mode 100644 index 00000000..66a82cba --- /dev/null +++ b/apps/extension/src/contentScript/methodHandlers/LuxMethodHandler.ts @@ -0,0 +1,89 @@ +import { JsonRpcProvider } from '@ethersproject/providers' +import { + contentScriptToBackgroundMessageChannel, + dappResponseMessageChannel, +} from 'src/background/messagePassing/messageChannels' +import { BaseMethodHandler } from 'src/contentScript/methodHandlers/BaseMethodHandler' +import { LuxMethods } from 'src/contentScript/methodHandlers/requestMethods' +import { PendingResponseInfo } from 'src/contentScript/methodHandlers/types' +import { getPendingResponseInfo } from 'src/contentScript/methodHandlers/utils' +import { WindowEthereumRequest } from 'src/contentScript/types' +import { + LuxOpenSidebarRequest, + LuxOpenSidebarRequestSchema, +} from 'src/contentScript/WindowEthereumRequestTypes' +import { DappRequestType, DappResponseType } from '@l.x/lx/src/features/dappRequests/types' +import { logger } from '@l.x/utils/src/logger/logger' + +/** + * Handles all lux-specific requests + */ + +export class LuxMethodHandler extends BaseMethodHandler { + private readonly requestIdToSourceMap: Map = new Map() + + // eslint-disable-next-line max-params + constructor({ + getChainId, + getProvider, + getConnectedAddresses, + setChainIdAndMaybeEmit, + setProvider, + setConnectedAddressesAndMaybeEmit, + }: { + getChainId: () => string | undefined + getProvider: () => JsonRpcProvider | undefined + getConnectedAddresses: () => Address[] | undefined + setChainIdAndMaybeEmit: (newChainId: string) => void + setProvider: (newProvider: JsonRpcProvider) => void + setConnectedAddressesAndMaybeEmit: (newConnectedAddresses: Address[]) => void + }) { + super( + getChainId, + getProvider, + getConnectedAddresses, + setChainIdAndMaybeEmit, + setProvider, + setConnectedAddressesAndMaybeEmit, + ) + + dappResponseMessageChannel.addMessageListener(DappResponseType.LuxOpenSidebarResponse, (message) => { + const source = getPendingResponseInfo({ + requestIdToSourceMap: this.requestIdToSourceMap, + requestId: message.requestId, + type: DappResponseType.LuxOpenSidebarResponse, + })?.source + + source?.postMessage({ + requestId: message.requestId, + }) + }) + } + + async handleRequest(request: WindowEthereumRequest, source: MessageEventSource | null): Promise { + switch (request.method) { + case LuxMethods.lux_openSidebar: { + logger.debug("Handling 'lux_openSidebar' request", request.method, request.toString()) + const luxOpenTokensRequest = LuxOpenSidebarRequestSchema.parse(request) + await this.handleLuxOpenSidebarRequest(luxOpenTokensRequest, source) + break + } + } + } + + private async handleLuxOpenSidebarRequest( + request: LuxOpenSidebarRequest, + source: MessageEventSource | null, + ): Promise { + this.requestIdToSourceMap.set(request.requestId, { + source, + type: DappResponseType.LuxOpenSidebarResponse, + }) + + await contentScriptToBackgroundMessageChannel.sendMessage({ + type: DappRequestType.LuxOpenSidebar, + requestId: request.requestId, + tab: request.tab, + }) + } +} diff --git a/apps/extension/src/contentScript/methodHandlers/ProviderDirectMethodHandler.ts b/apps/extension/src/contentScript/methodHandlers/ProviderDirectMethodHandler.ts new file mode 100644 index 00000000..e0472622 --- /dev/null +++ b/apps/extension/src/contentScript/methodHandlers/ProviderDirectMethodHandler.ts @@ -0,0 +1,127 @@ +import { BigNumber } from '@ethersproject/bignumber' +import { JsonRpcProvider } from '@ethersproject/providers' +import { BaseMethodHandler } from 'src/contentScript/methodHandlers/BaseMethodHandler' +import { ProviderDirectMethods } from 'src/contentScript/methodHandlers/requestMethods' +import { WindowEthereumRequest } from 'src/contentScript/types' +import { logContentScriptError } from 'src/contentScript/utils' + +/** + * Handles all provider direct requests + * Maps Ethereum JSON-RPC methods to their corresponding ethers.js provider method calls. + */ + +export class ProviderDirectMethodHandler extends BaseMethodHandler { + private methodHandlers: { + // oxlint-disable-next-line typescript/no-explicit-any -- Provider method handlers accept varied parameter types from JSON-RPC calls + [key: string]: (provider: JsonRpcProvider, params: any[]) => Promise + } + + constructor({ + getChainId, + getProvider, + getConnectedAddresses, + setChainIdAndMaybeEmit, + setProvider, + setConnectedAddressesAndMaybeEmit, + }: { + getChainId: () => string | undefined + getProvider: () => JsonRpcProvider | undefined + getConnectedAddresses: () => Address[] | undefined + setChainIdAndMaybeEmit: (newChainId: string) => void + setProvider: (newProvider: JsonRpcProvider) => void + setConnectedAddressesAndMaybeEmit: (newConnectedAddresses: Address[]) => void + }) { + super( + getChainId, + getProvider, + getConnectedAddresses, + setChainIdAndMaybeEmit, + setProvider, + setConnectedAddressesAndMaybeEmit, + ) + + this.methodHandlers = { + /* oxlint-disable typescript/explicit-function-return-type */ + [ProviderDirectMethods.eth_getBalance]: (provider, params) => provider.getBalance(params[0]), + [ProviderDirectMethods.eth_getCode]: (provider, params) => provider.getCode(params[0]), + [ProviderDirectMethods.eth_getStorageAt]: (provider, params) => provider.getStorageAt(params[0], params[1]), + [ProviderDirectMethods.eth_getTransactionCount]: (provider, params) => provider.getTransactionCount(params[0]), + [ProviderDirectMethods.eth_blockNumber]: (provider, _params) => provider.getBlockNumber(), + [ProviderDirectMethods.eth_getBlockByNumber]: (provider, params) => provider.getBlock(params[0]), + [ProviderDirectMethods.eth_call]: (provider, params) => provider.call(params[0]), + [ProviderDirectMethods.eth_gasPrice]: (provider, _params) => provider.getGasPrice(), + [ProviderDirectMethods.eth_estimateGas]: (provider, params) => provider.estimateGas(params[0]), + [ProviderDirectMethods.eth_getTransactionByHash]: (provider, params) => provider.getTransaction(params[0]), + [ProviderDirectMethods.eth_getTransactionReceipt]: (provider, params) => + provider.getTransactionReceipt(params[0]), + [ProviderDirectMethods.net_version]: async (provider, params) => provider.send('net_version', params), + [ProviderDirectMethods.web3_clientVersion]: async (provider, params) => + provider.send('web3_clientVersion', params), + } + } + + handleRequest(request: WindowEthereumRequest, source: MessageEventSource | null): void { + const handler = this.methodHandlers[request.method] + if (handler) { + const provider = this.getProvider() + if (!provider) { + // TODO: Handle error for disconnection + return + } + const response = handler(provider, request.params) + this.handleResponse({ response, source, requestId: request.requestId }) + } else { + // We shouldn't end up here because injected.ts checks that the method is supported before calling this function + logContentScriptError({ + errorMessage: 'Unexpected method requested', + fileName: 'ProviderDirectMethodHandler.ts', + functionName: 'handleRequest', + extra: { + method: request.method, + dapp: window.origin, + }, + }) + } + } + + private handleResponse({ + response, + source, + requestId, + }: { + // oxlint-disable-next-line typescript/no-explicit-any -- JSON-RPC response can contain arbitrary data structures + response: Promise + source: MessageEventSource | null + requestId: string + }): void { + response + .then((result) => { + source?.postMessage({ + requestId, + result: JSON.parse( + JSON.stringify(result, (_key, value) => { + if (!value) { + // oxlint-disable-next-line typescript/no-unsafe-return + return value + } else if (BigNumber.isBigNumber(value)) { + return value.toHexString() + } else if (value.type === 'BigNumber' && value.hex) { + // Unsure of why but sometimes the provider has converted the BigNumber with BigNumber.toJSON() e.g. eth_getBlockByNumber + // which is a format not currently accepted by some dapps e.g. Morpho + // oxlint-disable-next-line typescript/no-unsafe-return + return value.hex + } + // oxlint-disable-next-line typescript/no-unsafe-return + return value + }), + ), + }) + }) + .catch((error) => { + source?.postMessage({ + requestId, + error, + }) + }) + } +} diff --git a/apps/extension/src/contentScript/methodHandlers/UniswapMethodHandler.ts b/apps/extension/src/contentScript/methodHandlers/UniswapMethodHandler.ts new file mode 100644 index 00000000..d2d48b0d --- /dev/null +++ b/apps/extension/src/contentScript/methodHandlers/UniswapMethodHandler.ts @@ -0,0 +1,89 @@ +import { JsonRpcProvider } from '@ethersproject/providers' +import { + contentScriptToBackgroundMessageChannel, + dappResponseMessageChannel, +} from 'src/background/messagePassing/messageChannels' +import { BaseMethodHandler } from 'src/contentScript/methodHandlers/BaseMethodHandler' +import { UniswapMethods } from 'src/contentScript/methodHandlers/requestMethods' +import { PendingResponseInfo } from 'src/contentScript/methodHandlers/types' +import { getPendingResponseInfo } from 'src/contentScript/methodHandlers/utils' +import { WindowEthereumRequest } from 'src/contentScript/types' +import { + UniswapOpenSidebarRequest, + UniswapOpenSidebarRequestSchema, +} from 'src/contentScript/WindowEthereumRequestTypes' +import { DappRequestType, DappResponseType } from 'uniswap/src/features/dappRequests/types' +import { logger } from 'utilities/src/logger/logger' + +/** + * Handles all uniswap-specific requests + */ + +export class UniswapMethodHandler extends BaseMethodHandler { + private readonly requestIdToSourceMap: Map = new Map() + + // oxlint-disable-next-line max-params + constructor({ + getChainId, + getProvider, + getConnectedAddresses, + setChainIdAndMaybeEmit, + setProvider, + setConnectedAddressesAndMaybeEmit, + }: { + getChainId: () => string | undefined + getProvider: () => JsonRpcProvider | undefined + getConnectedAddresses: () => Address[] | undefined + setChainIdAndMaybeEmit: (newChainId: string) => void + setProvider: (newProvider: JsonRpcProvider) => void + setConnectedAddressesAndMaybeEmit: (newConnectedAddresses: Address[]) => void + }) { + super( + getChainId, + getProvider, + getConnectedAddresses, + setChainIdAndMaybeEmit, + setProvider, + setConnectedAddressesAndMaybeEmit, + ) + + dappResponseMessageChannel.addMessageListener(DappResponseType.UniswapOpenSidebarResponse, (message) => { + const source = getPendingResponseInfo({ + requestIdToSourceMap: this.requestIdToSourceMap, + requestId: message.requestId, + type: DappResponseType.UniswapOpenSidebarResponse, + })?.source + + source?.postMessage({ + requestId: message.requestId, + }) + }) + } + + async handleRequest(request: WindowEthereumRequest, source: MessageEventSource | null): Promise { + switch (request.method) { + case UniswapMethods.uniswap_openSidebar: { + logger.debug("Handling 'uniswap_openSidebar' request", request.method, request.toString()) + const uniswapOpenTokensRequest = UniswapOpenSidebarRequestSchema.parse(request) + await this.handleUniswapOpenSidebarRequest(uniswapOpenTokensRequest, source) + break + } + } + } + + private async handleUniswapOpenSidebarRequest( + request: UniswapOpenSidebarRequest, + source: MessageEventSource | null, + ): Promise { + this.requestIdToSourceMap.set(request.requestId, { + source, + type: DappResponseType.UniswapOpenSidebarResponse, + }) + + await contentScriptToBackgroundMessageChannel.sendMessage({ + type: DappRequestType.UniswapOpenSidebar, + requestId: request.requestId, + tab: request.tab, + }) + } +} diff --git a/apps/extension/src/contentScript/methodHandlers/emitUtils.ts b/apps/extension/src/contentScript/methodHandlers/emitUtils.ts new file mode 100644 index 00000000..b830f3c3 --- /dev/null +++ b/apps/extension/src/contentScript/methodHandlers/emitUtils.ts @@ -0,0 +1,14 @@ +export function emitChainChanged(newChainId: string): void { + // oxlint-disable-next-line typescript/no-unnecessary-condition + window?.postMessage({ + emitKey: 'chainChanged', + emitValue: newChainId, + }) +} +export function emitAccountsChanged(newConnectedAddresses: Address[]): void { + // oxlint-disable-next-line typescript/no-unnecessary-condition + window?.postMessage({ + emitKey: 'accountsChanged', + emitValue: newConnectedAddresses, + }) +} diff --git a/apps/extension/src/contentScript/methodHandlers/requestMethods.ts b/apps/extension/src/contentScript/methodHandlers/requestMethods.ts new file mode 100644 index 00000000..1a377392 --- /dev/null +++ b/apps/extension/src/contentScript/methodHandlers/requestMethods.ts @@ -0,0 +1,74 @@ +// Custom Uniswap methods that the extension will handle +/* oxlint-disable typescript/naming-convention */ +export enum UniswapMethods { + uniswap_openSidebar = 'uniswap_openSidebar', +} + +// Methods that are not supported by the extension because they are deprecated +/* oxlint-disable typescript/naming-convention */ +export enum DeprecatedEthMethods { + eth_sign = 'eth_sign', // Security risk + eth_signTypedData_v3 = 'eth_signTypedData_v3', + eth_signTypedData_v1 = 'eth_signTypedData_v1', + eth_decrypt = 'eth_decrypt', + eth_getEncryptionPublicKey = 'eth_getEncryptionPublicKey', +} + +// Methods that are handled by Metamask but not by the extension. These are logged +// so we can either display an error to the user or track frequency. +// Depending on the frequency with which we see these methods we could show an error +// in the sidebar for users. +// The methods come from: https://docs.metamask.io/wallet/reference/json-rpc-api/ +/* oxlint-disable typescript/naming-convention */ +export enum UnsupportedEthMethods { + wallet_addEthereumChain = 'wallet_addEthereumChain', + wallet_registerOnboarding = 'wallet_registerOnboarding', + wallet_watchAsset = 'wallet_watchAsset', + wallet_scanQRCode = 'wallet_scanQRCode', + wallet_getSnaps = 'wallet_getSnaps', + wallet_requestSnaps = 'wallet_requestSnaps', + wallet_snap = 'wallet_snap', + wallet_invokeSnap = 'wallet_invokeSnap', + eth_subscribe = 'eth_subscribe', + eth_unsubscribe = 'eth_unsubscribe', + eth_blobBaseFee = 'eth_blobBaseFee', + eth_coinbase = 'eth_coinbase', + eth_feeHistory = 'eth_feeHistory', + eth_getBlockByHash = 'eth_getBlockByHash', + eth_getBlockTransactionCountByHash = 'eth_getBlockTransactionCountByHash', + eth_getBlockTransactionCountByNumber = 'eth_getBlockTransactionCountByNumber', + eth_getFilterChanges = 'eth_getFilterChanges', + eth_getFilterLogs = 'eth_getFilterLogs', + eth_getLogs = 'eth_getLogs', + eth_getProof = 'eth_getProof', + eth_getStorageAt = 'eth_getStorageAt', + eth_getTransactionByBlockHashAndIndex = 'eth_getTransactionByBlockHashAndIndex', + eth_getTransactionByBlockNumberAndIndex = 'eth_getTransactionByBlockNumberAndIndex', + eth_getTransactionCount = 'eth_getTransactionCount', + eth_getUncleCountByBlockHash = 'eth_getUncleCountByBlockHash', + eth_getUncleCountByBlockNumber = 'eth_getUncleCountByBlockNumber', + eth_maxPriorityFeePerGas = 'eth_maxPriorityFeePerGas', + eth_newBlockFilter = 'eth_newBlockFilter', + eth_newFilter = 'eth_newFilter', + eth_newPendingTransactionFilter = 'eth_newPendingTransactionFilter', + eth_sendRawTransaction = 'eth_sendRawTransaction', + eth_syncing = 'eth_syncing', + eth_uninstallFilter = 'eth_uninstallFilter', + eth_signTransaction = 'eth_signTransaction', +} + +export enum ProviderDirectMethods { + eth_getBalance = 'eth_getBalance', + eth_getCode = 'eth_getCode', + eth_getStorageAt = 'eth_getStorageAt', + eth_getTransactionCount = 'eth_getTransactionCount', + eth_blockNumber = 'eth_blockNumber', + eth_getBlockByNumber = 'eth_getBlockByNumber', + eth_call = 'eth_call', + eth_gasPrice = 'eth_gasPrice', + eth_estimateGas = 'eth_estimateGas', + eth_getTransactionByHash = 'eth_getTransactionByHash', + eth_getTransactionReceipt = 'eth_getTransactionReceipt', + net_version = 'net_version', + web3_clientVersion = 'web3_clientVersion', +} diff --git a/apps/extension/src/contentScript/methodHandlers/types.ts b/apps/extension/src/contentScript/methodHandlers/types.ts new file mode 100644 index 00000000..4871ada4 --- /dev/null +++ b/apps/extension/src/contentScript/methodHandlers/types.ts @@ -0,0 +1,6 @@ +import { DappResponseType } from 'uniswap/src/features/dappRequests/types' + +export type PendingResponseInfo = { + type: DappResponseType + source: MessageEventSource | null +} diff --git a/apps/extension/src/contentScript/methodHandlers/utils.ts b/apps/extension/src/contentScript/methodHandlers/utils.ts new file mode 100644 index 00000000..a9c59443 --- /dev/null +++ b/apps/extension/src/contentScript/methodHandlers/utils.ts @@ -0,0 +1,140 @@ +import { providerErrors, serializeError } from '@metamask/rpc-errors' +import { + DeprecatedEthMethods, + ProviderDirectMethods, + UniswapMethods, + UnsupportedEthMethods, +} from 'src/contentScript/methodHandlers/requestMethods' +import { PendingResponseInfo } from 'src/contentScript/methodHandlers/types' +import { logContentScriptError } from 'src/contentScript/utils' +import { DappResponseType, EthMethod, ExtensionEthMethod } from 'uniswap/src/features/dappRequests/types' + +export function isProviderDirectMethod(method: string): boolean { + return Object.keys(ProviderDirectMethods).includes(method) +} + +export function isUniswapMethod(method: string): boolean { + return Object.keys(UniswapMethods).includes(method) +} + +// Since ExtensionEthMethod is a TypeScript type that doesn't exist at runtime, +// we need to explicitly list its values here for string comparison +const extensionEthMethodValues: ExtensionEthMethod[] = [ + EthMethod.EthChainId, + EthMethod.EthRequestAccounts, + EthMethod.EthAccounts, + EthMethod.EthSendTransaction, + EthMethod.PersonalSign, + EthMethod.WalletSwitchEthereumChain, + EthMethod.WalletGetPermissions, + EthMethod.WalletRequestPermissions, + EthMethod.WalletRevokePermissions, + EthMethod.WalletGetCapabilities, + EthMethod.WalletSendCalls, + EthMethod.WalletGetCallsStatus, + EthMethod.SignTypedDataV4, +] + +export function isExtensionEthMethod(method: string): boolean { + return extensionEthMethodValues.some((enumValue) => enumValue === method) +} + +export function isDeprecatedMethod(method: string): boolean { + return Object.keys(DeprecatedEthMethods).includes(method) +} + +export function isUnsupportedMethod(method: string): boolean { + return Object.keys(UnsupportedEthMethods).includes(method) +} + +export function postDeprecatedMethodError({ + source, + requestId, + method, +}: { + source: MessageEventSource | null + requestId: string + method: string +}): void { + source?.postMessage({ + requestId, + error: serializeError( + providerErrors.unsupportedMethod(`Uniswap Wallet does not support ${method} as it is deprecated`), + ), + }) +} + +export function postUnknownMethodError({ + source, + requestId, + method, +}: { + source: MessageEventSource | null + requestId: string + method: string +}): void { + source?.postMessage({ + requestId, + error: serializeError(providerErrors.unsupportedMethod(`Uniswap Wallet does not support ${method}`)), + }) +} + +export function postUnauthorizedError(source: MessageEventSource | null, requestId: string): void { + source?.postMessage({ + requestId, + error: serializeError(providerErrors.unauthorized()), + }) +} + +export function postParsingError({ + source, + requestId, + method, +}: { + source: MessageEventSource | null + requestId: string + method: string +}): void { + source?.postMessage({ + requestId, + error: serializeError( + providerErrors.unsupportedMethod(`Uniswap Wallet could not parse the ${method} request properly`), + ), + }) +} + +/** + * Reject a request with an invalid params error + */ +export function rejectSelfCallWithData(requestId: string, source: MessageEventSource | null): void { + source?.postMessage({ + requestId, + error: serializeError(providerErrors.unsupportedMethod(`Self-calls with data are not supported`)), + }) +} + +export function getPendingResponseInfo({ + requestIdToSourceMap, + requestId, + type, +}: { + requestIdToSourceMap: Map + requestId: string + type: DappResponseType +}): PendingResponseInfo | undefined { + const pendingResponseInfo = requestIdToSourceMap.get(requestId) + if (pendingResponseInfo) { + requestIdToSourceMap.delete(requestId) + + if (type !== DappResponseType.ErrorResponse && type !== pendingResponseInfo.type) { + logContentScriptError({ + errorMessage: `Response type doesn't match expected type, expected: ${pendingResponseInfo.type}, actual: ${type}`, + fileName: 'methodHandlers/utils.ts', + functionName: 'validateResponse', + }) + } + return pendingResponseInfo + } + + return undefined +} diff --git a/apps/extension/src/contentScript/types.ts b/apps/extension/src/contentScript/types.ts new file mode 100644 index 00000000..e02253e9 --- /dev/null +++ b/apps/extension/src/contentScript/types.ts @@ -0,0 +1,63 @@ +import { z } from 'zod' + +export enum ETH_PROVIDER_CONFIG { + REQUEST = 'ETHEREUM_PROVIDER_SCHEMA_REQUEST', + RESPONSE = 'ETHEREUM_PROVIDER_SCHEMA_RESPONSE', +} + +/* oxlint-disable no-restricted-syntax */ +const ExtensionResponseSchema = z + .object({ + requestId: z.string(), + result: z.any().optional(), + error: z.any().optional(), + }) + .refine((data) => data.result !== undefined || data.error !== undefined, { + message: 'Either result or error must be defined', + }) + +export type ExtensionResponse = z.infer + +export const isValidExtensionResponse = (response: unknown): response is ExtensionResponse => + ExtensionResponseSchema.safeParse(response).success + +const WindowEthereumRequestSchema = z.object({ + method: z.string(), + params: z.any(), + requestId: z.string(), +}) +export type WindowEthereumRequest = z.infer + +export const isValidWindowEthereumRequest = (request: unknown): request is WindowEthereumRequest => + WindowEthereumRequestSchema.safeParse(request).success + +const ContentScriptToProxyEmissionSchema = z.object({ + emitKey: z.string(), + emitValue: z.any(), +}) + +type ContentScriptToProxyEmission = z.infer + +export const isValidContentScriptToProxyEmission = (request: unknown): request is ContentScriptToProxyEmission => + ContentScriptToProxyEmissionSchema.safeParse(request).success + +const WindowEthereumConfigRequestSchema = z.object({ + type: z.literal(ETH_PROVIDER_CONFIG.REQUEST), +}) + +export type WindowEthereumConfigRequest = z.infer + +export const isValidWindowEthereumConfigRequest = (request: unknown): request is WindowEthereumConfigRequest => + WindowEthereumConfigRequestSchema.safeParse(request).success + +const WindowEthereumConfigResponseSchema = z.object({ + type: z.literal(ETH_PROVIDER_CONFIG.RESPONSE), + config: z.object({ + isDefaultProvider: z.boolean(), + }), +}) + +export type WindowEthereumConfigResponse = z.infer + +export const isValidWindowEthereumConfigResponse = (request: unknown): request is WindowEthereumConfigResponse => + WindowEthereumConfigResponseSchema.safeParse(request).success diff --git a/apps/extension/src/contentScript/utils.ts b/apps/extension/src/contentScript/utils.ts new file mode 100644 index 00000000..6225b19a --- /dev/null +++ b/apps/extension/src/contentScript/utils.ts @@ -0,0 +1,40 @@ +import { contentScriptUtilityMessageChannel } from 'src/background/messagePassing/messageChannels' +import { ContentScriptUtilityMessageType, ErrorLog } from 'src/background/messagePassing/types/requests' +import { logger } from 'utilities/src/logger/logger' + +export async function logContentScriptError({ + errorMessage, + fileName, + functionName, + tags, + extra, +}: { + errorMessage: string + fileName: string + functionName: string + tags?: Record + extra?: Record +}): Promise { + const message: ErrorLog = { + type: ContentScriptUtilityMessageType.ErrorLog, + message: errorMessage, + fileName, + functionName, + tags, + extra, + } + + if (__DEV__) { + // oxlint-disable-next-line no-restricted-syntax + logger.error(new Error(errorMessage), { + tags: { + file: fileName, + function: functionName, + ...tags, + }, + extra, + }) + } + + await contentScriptUtilityMessageChannel.sendMessage(message) +} diff --git a/apps/extension/src/declarations.d.ts b/apps/extension/src/declarations.d.ts new file mode 100644 index 00000000..d08db6d0 --- /dev/null +++ b/apps/extension/src/declarations.d.ts @@ -0,0 +1,7 @@ +declare module '*.svg' { + import React from 'react' + import { SvgProps } from 'react-native-svg' + const content: React.FC + // oxlint-disable-next-line import/no-unused-modules + export default content +} diff --git a/apps/extension/src/entrypoints/background.ts b/apps/extension/src/entrypoints/background.ts new file mode 100644 index 00000000..4df77bc0 --- /dev/null +++ b/apps/extension/src/entrypoints/background.ts @@ -0,0 +1,200 @@ +import 'symbol-observable' // Needed by `reduxed-chrome-storage` as polyfill, order matters +>>>>>>> upstream/main +import { AUTO_LOCK_ALARM_NAME } from 'src/app/components/AutoLockProvider' +import { initStatSigForBrowserScripts } from 'src/app/core/initStatSigForBrowserScripts' +import { focusOrCreateOnboardingTab } from 'src/app/navigation/focusOrCreateOnboardingTab' +import { initExtensionAnalytics } from 'src/app/utils/analytics' +import { initMessageBridge } from 'src/background/backgroundDappRequests' +import { backgroundStore } from 'src/background/backgroundStore' +import { + backgroundToSidePanelMessageChannel, + contentScriptUtilityMessageChannel, +} from 'src/background/messagePassing/messageChannels' +import { + BackgroundToSidePanelRequestType, + ContentScriptUtilityMessageType, +} from 'src/background/messagePassing/types/requests' +import { setSidePanelBehavior, setSidePanelOptions } from 'src/background/utils/chromeSidePanelUtils' +import { + readDeviceAccessTimeoutMinutesFromStorage, + readIsOnboardedFromStorage, +} from 'src/background/utils/persistedStateUtils' +<<<<<<< HEAD +import { lxUrls } from '@l.x/lx/src/constants/urls' +import { ExtensionEventName } from '@l.x/lx/src/features/telemetry/constants' +import { sendAnalyticsEvent } from '@l.x/lx/src/features/telemetry/send' +import { logger } from '@l.x/utils/src/logger/logger' +import { Keyring } from '@luxfi/wallet/src/features/wallet/Keyring/Keyring' +======= +import { uniswapUrls } from 'uniswap/src/constants/urls' +import { ExtensionEventName } from 'uniswap/src/features/telemetry/constants' +import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { logger } from 'utilities/src/logger/logger' +import { Keyring } from 'wallet/src/features/wallet/Keyring/Keyring' +import { defineBackground } from 'wxt/utils/define-background' + +async function enableSidebar(): Promise { + await setSidePanelOptions({ enabled: true }) + await setSidePanelBehavior({ openPanelOnActionClick: true }) +} + +async function disableSidebar(): Promise { + await setSidePanelOptions({ enabled: false }) + await setSidePanelBehavior({ openPanelOnActionClick: false }) +} + +async function setSidebarState(isOnboarded: boolean): Promise { + if (isOnboarded) { + await enableSidebar() + } else { + await disableSidebar() + } +} + +async function initApp(): Promise { + await initStatSigForBrowserScripts() + await initExtensionAnalytics() + + // Enables or disables sidebar based on onboarding status + // Injected script will reject any requests if not onboarded + backgroundStore.addOnboardingChangedListener(async (isOnboarded) => { + await setSidebarState(isOnboarded) + }) + + // Sets uninstall URL + chrome.runtime.setUninstallURL(uniswapUrls.chromeExtensionUninstallUrl) + + await backgroundStore.init() +} + +function makeBackground(): void { + let isArcBrowser = false + + initMessageBridge() + + chrome.tabs.onActivated.addListener(onTabChange) + chrome.tabs.onUpdated.addListener(onTabChange) + + chrome.action.onClicked.addListener(async () => { + await checkAndHandleOnboarding() + }) + + chrome.runtime.onInstalled.addListener(async () => { + await checkAndHandleOnboarding() + }) + + // Auto-lock alarm listener + chrome.alarms.onAlarm.addListener((alarm) => { + if (alarm.name === AUTO_LOCK_ALARM_NAME) { + Keyring.lock() + .then(() => { + sendAnalyticsEvent(ExtensionEventName.ChangeLockedState, { + locked: true, + location: 'background', + }) + }) + .catch((error) => { + logger.error(error, { + tags: { + file: 'background.ts', + function: 'alarms.onAlarm', + }, + }) + }) + } + }) + + // Listen for sidebar port disconnects to schedule auto-lock alarm + chrome.runtime.onConnect.addListener((port) => { + if (port.name === AUTO_LOCK_ALARM_NAME) { + port.onDisconnect.addListener(async () => { + try { + // Get timeout setting from Redux state + const delayInMinutes = await readDeviceAccessTimeoutMinutesFromStorage() + if (delayInMinutes === undefined) { + return + } + + // Schedule alarm + chrome.alarms.create(AUTO_LOCK_ALARM_NAME, { delayInMinutes }) + logger.debug('background', 'port.onDisconnect', `Scheduled auto-lock alarm for ${delayInMinutes} minutes`) + } catch (error) { + logger.error(error, { + tags: { + file: 'background.ts', + function: 'port.onDisconnect', + }, + }) + } + }) + } + }) + + // on arc browser, show unsupported browser page (lives on onboarding flow) + // this is because arc doesn't support the sidebar + contentScriptUtilityMessageChannel.addMessageListener( + ContentScriptUtilityMessageType.ArcBrowserCheck, + async (message) => { + isArcBrowser = !!message.isArcBrowser + + if (message.isArcBrowser) { + await disableSidebar() + } else { + // ensure that we reenable the sidebar if arc styles are not detected + // but ONLY if the user is actually onboarded + const isOnboarded = await readIsOnboardedFromStorage() + if (isOnboarded) { + await enableSidebar() + } else { + await disableSidebar() + } + } + }, + ) + + // Utility Functions + async function checkAndHandleOnboarding(): Promise { + if (isArcBrowser) { + await focusOrCreateOnboardingTab() + return + } + + const isOnboarded = await readIsOnboardedFromStorage() + + if (isOnboarded) { + await enableSidebar() + } else { + await disableSidebar() + // Always open onboarding tab when not onboarded + await focusOrCreateOnboardingTab() + } + } + + /** Fires an event whenever a tab is changed so the sidebar can reflect the current connection status properly. */ + async function onTabChange(): Promise { + try { + await backgroundToSidePanelMessageChannel.sendMessage({ + type: BackgroundToSidePanelRequestType.TabActivated, + }) + } catch { + // an error will be thrown if the sidebar is not open. This is expected and in this case there is no action to be taken anyways so ignore. + } + } + + initApp().catch((error) => { + logger.error(error, { + tags: { + file: 'background/background.ts', + function: 'initApp', + }, + }) + }) +} + +// oxlint-disable-next-line import/no-unused-modules +export default defineBackground({ + type: 'module', + main() { + makeBackground() + }, +}) diff --git a/apps/extension/src/entrypoints/ethereum.content.ts b/apps/extension/src/entrypoints/ethereum.content.ts new file mode 100644 index 00000000..5d981bfb --- /dev/null +++ b/apps/extension/src/entrypoints/ethereum.content.ts @@ -0,0 +1,172 @@ +import { addWindowMessageListener } from 'src/background/messagePassing/messageUtils' +import { isSandboxedFrame } from 'src/contentScript/isSandboxedFrame' +import { + ETH_PROVIDER_CONFIG, + isValidContentScriptToProxyEmission, + isValidWindowEthereumConfigResponse, + WindowEthereumConfigResponse, +} from 'src/contentScript/types' +import { WindowEthereumProxy } from 'src/contentScript/WindowEthereumProxy' +import { logger } from 'utilities/src/logger/logger' +import { v4 as uuid } from 'uuid' +import { defineContentScript } from 'wxt/utils/define-content-script' + +declare global { + interface Window { + isStretchInstalled?: boolean + // We declare this as readonly to force the use of `assignWindowEthereum` to override it. + readonly ethereum?: unknown + } +} + +function makeEthereum(): void { + // Guard against running in Node environment during WXT prepare + if (typeof window === 'undefined') { + return + } + + // Do not inject the provider into sandboxed frames without allow-same-origin. + if (isSandboxedFrame()) { + return + } + // TODO(xtine): Get this working by importing the svg file directly. The svg text comes from packages/ui/src/assets/icons/uniswap-logo.svg + const UNISWAP_LOGO = `data:image/svg+xml,${encodeURIComponent(` + + + + + + + + + + + + +`)}` + const UNISWAP_NAME = 'Uniswap Extension' + const UNISWAP_RDNS = 'org.uniswap.app' + + enum EIP6963EventNames { + Announce = 'eip6963:announceProvider', + Request = 'eip6963:requestProvider', + } + + interface EIP6963ProviderInfo { + uuid: string + name: string + icon: string + rdns: string + } + + function assignWindowEthereum(provider: unknown): void { + try { + // We need to try/catch this because some sneaky wallet extensions set `window.ethereum` to a getter, + // which throws an error when trying to override it. + // In these cases, our wallet will only work with dapps that suppport EIP-6963. + // @ts-expect-error: we're intentionally trying to override this. + window.ethereum = provider + } catch (error) { + if (__DEV__) { + // Only log in dev env for debugging purposes to avoid spamming DD with these errors. + // oxlint-disable-next-line no-restricted-syntax + logger.error(error, { tags: { file: 'ethereum.ts', function: 'assignWindowEthereum' } }) + } + } + } + + const oldProvider = window.ethereum + + const uniswapProvider = new WindowEthereumProxy() + assignWindowEthereum(uniswapProvider) + + addWindowMessageListener({ + validator: isValidContentScriptToProxyEmission, + handler: (message) => { + logger.debug('ethereum.ts', `Emitting ${message.emitKey} via WindowEthereumProxy`, message.emitValue) + uniswapProvider.emit(message.emitKey, message.emitValue) + }, + }) + + const providerUuid = uuid() + + function announceProvider(): void { + const info: EIP6963ProviderInfo = { + uuid: providerUuid, + name: UNISWAP_NAME, + icon: UNISWAP_LOGO, + rdns: UNISWAP_RDNS, + } + + window.dispatchEvent( + new CustomEvent(EIP6963EventNames.Announce, { + detail: Object.freeze({ info, provider: uniswapProvider }), + }), + ) + } + + const handle6963Request = (event: Event): void => { + if (!isValidRequestProviderEvent(event)) { + throw new Error( + `Invalid EIP-6963 RequestProviderEvent object received from ${EIP6963EventNames.Request} event. See https://eips.ethereum.org/EIPS/eip-6963 for requirements.`, + ) + } + + announceProvider() + } + + const create6963Listener = (): void => { + // remove the old listener if present + window.removeEventListener(EIP6963EventNames.Request, handle6963Request) + + window.addEventListener(EIP6963EventNames.Request, handle6963Request) + + announceProvider() + } + + create6963Listener() + + // override logic impl details in src/app/utils/provider.ts + // get config from sister content script + addWindowMessageListener({ + validator: isValidWindowEthereumConfigResponse, + handler: async (request) => { + const isDefaultProvider = request.config.isDefaultProvider + + // if default provider is false, restore old provider for 1193 and unspoof 6963 provider + if (isDefaultProvider === false) { + uniswapProvider.isMetaMask = false + if (oldProvider) { + assignWindowEthereum(oldProvider) + create6963Listener() + } + } + }, + options: { removeAfterHandled: true }, + }) + + window.postMessage({ type: ETH_PROVIDER_CONFIG.REQUEST }) + + type EIP6963RequestProviderEvent = Event & { + type: EIP6963EventNames.Request + } + + function isValidRequestProviderEvent(event: unknown): event is EIP6963RequestProviderEvent { + return event instanceof Event && event.type === EIP6963EventNames.Request + } +} + +// oxlint-disable-next-line import/no-unused-modules +export default defineContentScript({ + matches: + __DEV__ || process.env.BUILD_ENV === 'dev' + ? ['http://127.0.0.1/*', 'http://localhost/*', 'https://*/*'] + : ['https://*/*'], + runAt: 'document_start', + // TODO(INFRA-1010): not supported by firefox + world: 'MAIN', + allFrames: true, + main() { + makeEthereum() + }, +}) diff --git a/apps/extension/src/entrypoints/fallback-popup/index.html b/apps/extension/src/entrypoints/fallback-popup/index.html new file mode 100644 index 00000000..50000e2f --- /dev/null +++ b/apps/extension/src/entrypoints/fallback-popup/index.html @@ -0,0 +1,68 @@ + + + + + + + + + + + + + Uniswap Extension + + + +
+ + + + diff --git a/apps/extension/src/entrypoints/fallback-popup/main.tsx b/apps/extension/src/entrypoints/fallback-popup/main.tsx new file mode 100644 index 00000000..581a8527 --- /dev/null +++ b/apps/extension/src/entrypoints/fallback-popup/main.tsx @@ -0,0 +1,30 @@ +// oxlint-disable-next-line typescript/triple-slash-reference +/// +// oxlint-disable-next-line typescript/triple-slash-reference +/// + +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import PopupApp from 'src/app/core/PopupApp' +import { initializeReduxStore } from 'src/store/store' +// oxlint-disable-next-line typescript/no-explicit-any -- Global polyfill cleanup requires any type for runtime modification +;(globalThis as any).regeneratorRuntime = undefined + +function makeFallbackPopup(): void { + function initFallbackPopup() { + // oxlint-disable-next-line typescript/no-non-null-assertion -- popup root element guaranteed to exist in extension + const container = document.getElementById('fallback-popup-root')! + const root = createRoot(container) + + root.render( + + + , + ) + } + + initializeReduxStore({ readOnly: true }) + initFallbackPopup() +} + +makeFallbackPopup() diff --git a/apps/extension/src/entrypoints/injected.content.ts b/apps/extension/src/entrypoints/injected.content.ts new file mode 100644 index 00000000..77e9d9d8 --- /dev/null +++ b/apps/extension/src/entrypoints/injected.content.ts @@ -0,0 +1,320 @@ +import { JsonRpcProvider } from '@ethersproject/providers' +import { providerErrors, serializeError } from '@metamask/rpc-errors' +import { dappStore } from 'src/app/features/dapp/store' +import { getOrderedConnectedAddresses } from 'src/app/features/dapp/utils' +import { isArcBrowser } from 'src/app/utils/chrome' +import { getIsDefaultProviderFromStorage } from 'src/app/utils/provider' +import { backgroundStore } from 'src/background/backgroundStore' +import { + contentScriptUtilityMessageChannel, + externalDappMessageChannel, +} from 'src/background/messagePassing/messageChannels' +import { addWindowMessageListener } from 'src/background/messagePassing/messageUtils' +import { + AnalyticsLog, + ContentScriptUtilityMessageType, + ExtensionToDappRequestType, +} from 'src/background/messagePassing/types/requests' +import { isSandboxedFrame } from 'src/contentScript/isSandboxedFrame' +import { emitAccountsChanged, emitChainChanged } from 'src/contentScript/methodHandlers/emitUtils' +import { ExtensionEthMethodHandler } from 'src/contentScript/methodHandlers/ExtensionEthMethodHandler' +import { ProviderDirectMethodHandler } from 'src/contentScript/methodHandlers/ProviderDirectMethodHandler' +import { UniswapMethodHandler } from 'src/contentScript/methodHandlers/UniswapMethodHandler' +import { + isDeprecatedMethod, + isExtensionEthMethod, + isProviderDirectMethod, + isUniswapMethod, + isUnsupportedMethod, + postDeprecatedMethodError, + postParsingError, + postUnknownMethodError, +} from 'src/contentScript/methodHandlers/utils' +import { + ETH_PROVIDER_CONFIG, + isValidWindowEthereumConfigRequest, + isValidWindowEthereumRequest, + WindowEthereumConfigRequest, + WindowEthereumRequest, +} from 'src/contentScript/types' +import { logContentScriptError } from 'src/contentScript/utils' +import { chainIdToHexadecimalString } from 'uniswap/src/features/chains/utils' +import { EthMethod } from 'uniswap/src/features/dappRequests/types' +import { Platform } from 'uniswap/src/features/platforms/types/Platform' +import { ExtensionEventName } from 'uniswap/src/features/telemetry/constants' +import { getValidAddress } from 'uniswap/src/utils/addresses' +import { HexString } from 'utilities/src/addresses/hex' +import { logger } from 'utilities/src/logger/logger' +import { arraysAreEqual } from 'utilities/src/primitives/array' +import { ONE_SECOND_MS } from 'utilities/src/time/time' +import { walletContextValue } from 'wallet/src/features/wallet/context' +import { defineContentScript } from 'wxt/utils/define-content-script' +import { ZodError } from 'zod' + +function makeInjected(): void { + // Do not inject into sandboxed frames without allow-same-origin. + if (isSandboxedFrame()) { + return + } + + // arc styles aren't available on load + const ARC_STYLE_INJECTION_DELAY = ONE_SECOND_MS + + let _provider: JsonRpcProvider | undefined + let _chainId: string | undefined + let connectedAddresses: Address[] | undefined + const dappUrl = window.origin + + const getChainId = (): string | undefined => { + const storedChainId = dappStore.getDappInfo(dappUrl)?.lastChainId + + if (_chainId === undefined && storedChainId) { + _chainId = chainIdToHexadecimalString(storedChainId) + } + + return _chainId + } + + const getProvider = (): JsonRpcProvider | undefined => _provider + const getConnectedAddresses = (): Address[] | undefined => { + const storedDappInfo = dappStore.getDappInfo(dappUrl) + const storedConnectedAddresses = + storedDappInfo && + getOrderedConnectedAddresses(storedDappInfo.connectedAccounts, storedDappInfo.activeConnectedAddress) + return connectedAddresses ?? storedConnectedAddresses + } + + const setProvider = (newProvider: JsonRpcProvider): void => { + _provider = newProvider + } + const setChainIdAndMaybeEmit = (newChainId: string): void => { + // Only emit if the chain have changed, and it's not the first time + if (_chainId !== undefined && _chainId !== newChainId) { + emitChainChanged(newChainId) + } + _chainId = newChainId + } + + const setConnectedAddressesAndMaybeEmit = (newConnectedAddresses: Address[]): void => { + // Only emit if the addresses have changed, and it's not the first time + const normalizedNewAddresses: HexString[] = newConnectedAddresses + .map((address) => getValidAddress({ address, platform: Platform.EVM })) + .filter((normalizedAddress): normalizedAddress is HexString => normalizedAddress !== null) + + if (!connectedAddresses || !arraysAreEqual(connectedAddresses, normalizedNewAddresses)) { + emitAccountsChanged(normalizedNewAddresses) + } + connectedAddresses = normalizedNewAddresses + } + + const extensionEthMethodHandler = new ExtensionEthMethodHandler({ + getChainId, + getProvider, + getConnectedAddresses, + setChainIdAndMaybeEmit, + setProvider, + setConnectedAddressesAndMaybeEmit, + }) + + const providerDirectMethodHandler = new ProviderDirectMethodHandler({ + getChainId, + getProvider, + getConnectedAddresses, + setChainIdAndMaybeEmit, + setProvider, + setConnectedAddressesAndMaybeEmit, + }) + + const uniswapMethodHandler = new UniswapMethodHandler({ + getChainId, + getProvider, + getConnectedAddresses, + setChainIdAndMaybeEmit, + setProvider, + setConnectedAddressesAndMaybeEmit, + }) + + addWindowMessageListener({ + validator: isValidWindowEthereumRequest, + handler: async (request, source) => { + logger.debug('injected.ts', 'Request received for method', JSON.stringify(request), _provider) + + if (!backgroundStore.state.isOnboarded) { + rejectRequestNotOnboarded(request, source).catch((error) => + logContentScriptError({ + errorMessage: error?.message ?? 'Error rejecting request when not onboarded', + fileName: 'injected.ts', + functionName: 'WindowEthereumRequestListener', + }), + ) + return + } + + if (isProviderDirectMethod(request.method)) { + // Provider methods are handled directly by the provider instance + // (avoiding roundtrip to background service worker) + providerDirectMethodHandler.handleRequest(request, source) + return + } + + if (isUniswapMethod(request.method)) { + try { + await uniswapMethodHandler.handleRequest(request, source) + } catch (e) { + if (e instanceof ZodError) { + postParsingError({ source, requestId: request.requestId, method: request.method }) + } + const errorMessage = e instanceof Error ? e.message : 'Unknown error' + await logContentScriptError({ + errorMessage, + fileName: 'injected.ts', + functionName: 'WindowEthereumRequestListener', + }) + } + return + } + + if (isExtensionEthMethod(request.method)) { + try { + await extensionEthMethodHandler.handleRequest(request, source) + } catch (e) { + if (e instanceof ZodError) { + postParsingError({ source, requestId: request.requestId, method: request.method }) + } + const errorMessage = e instanceof Error ? e.message : 'Unknown error' + await logContentScriptError({ + errorMessage, + fileName: 'injected.ts', + functionName: 'WindowEthereumRequestListener', + }) + } + return + } + + if (isDeprecatedMethod(request.method)) { + postDeprecatedMethodError({ source, requestId: request.requestId, method: request.method }) + await passAnalytics(ExtensionEventName.DeprecatedMethodRequest, { + method: request.method, + dappUrl, + }) + return + } + + if (isUnsupportedMethod(request.method)) { + postUnknownMethodError({ source, requestId: request.requestId, method: request.method }) + await passAnalytics(ExtensionEventName.UnsupportedMethodRequest, { + method: request.method, + dappUrl, + }) + return + } + + // Handle any methods we don't know how to handle and are not in the metamask API + await passAnalytics(ExtensionEventName.UnrecognizedMethodRequest, { + method: request.method, + dappUrl, + }) + postUnknownMethodError({ source, requestId: request.requestId, method: request.method }) + }, + }) + + externalDappMessageChannel.addMessageListener(ExtensionToDappRequestType.SwitchChain, (message) => { + setChainIdAndMaybeEmit(message.chainId) + setProvider(new JsonRpcProvider(message.providerUrl, parseInt(message.chainId))) + }) + + externalDappMessageChannel.addMessageListener(ExtensionToDappRequestType.UpdateConnections, (message) => { + setConnectedAddressesAndMaybeEmit(message.addresses) + }) + + async function init(): Promise { + try { + await Promise.all([backgroundStore.init(), dappStore.init()]) + + const chainId = getChainId() + const provider = getProvider() + + if (chainId && !provider) { + const chainIdNum = parseInt(chainId, 16) + const defaultProvider = walletContextValue.providers.getProvider(chainIdNum) + setProvider(defaultProvider) + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error' + await logContentScriptError({ + errorMessage, + fileName: 'injected.ts', + functionName: 'init', + }) + } + } + + /** Helper function to reject all requests from dapps when the extension is not onboarded. */ + async function rejectRequestNotOnboarded( + request: WindowEthereumRequest, + source: MessageEventSource | null, + ): Promise { + if (request.method === EthMethod.EthRequestAccounts || request.method === EthMethod.WalletRequestPermissions) { + await contentScriptUtilityMessageChannel.sendMessage({ + type: ContentScriptUtilityMessageType.FocusOnboardingTab, + }) + } + + source?.postMessage({ + requestId: request.requestId, + error: serializeError(providerErrors.userRejectedRequest()), + }) + } + + init().catch(() => {}) + + // These go to Amplitude instead of Datadog since they are informational + async function passAnalytics(message: string, tags: Record): Promise { + const logMessage: AnalyticsLog = { + type: ContentScriptUtilityMessageType.AnalyticsLog, + message, + tags, + } + await contentScriptUtilityMessageChannel.sendMessage(logMessage) + } + + addWindowMessageListener({ + validator: isValidWindowEthereumConfigRequest, + handler: async () => { + const isDefaultProvider = await getIsDefaultProviderFromStorage() + window.postMessage({ type: ETH_PROVIDER_CONFIG.RESPONSE, config: { isDefaultProvider } }) + }, + options: { removeAfterHandled: true }, + }) + + // check for arc stylesheet properties on load + // notify background script if arc browser detected so we can disable the extension + window.addEventListener('load', () => { + // if styles aren't available at all, then we cannot check for the arc styles + // oxlint-disable-next-line typescript/no-unnecessary-condition + const isStylesAvailable = document.documentElement && !!getComputedStyle(document.documentElement).length + if (!isStylesAvailable) { + return + } + + setTimeout(async () => { + await contentScriptUtilityMessageChannel.sendMessage({ + type: ContentScriptUtilityMessageType.ArcBrowserCheck, + isArcBrowser: isArcBrowser(), + }) + }, ARC_STYLE_INJECTION_DELAY) + }) +} + +// oxlint-disable-next-line import/no-unused-modules +export default defineContentScript({ + matches: + __DEV__ || process.env.BUILD_ENV === 'dev' + ? ['http://127.0.0.1/*', 'http://localhost/*', 'https://*/*'] + : ['https://*/*'], + runAt: 'document_start', + allFrames: true, + main() { + makeInjected() + }, +}) diff --git a/apps/extension/src/entrypoints/onboarding/index.html b/apps/extension/src/entrypoints/onboarding/index.html new file mode 100644 index 00000000..d46c9c7f --- /dev/null +++ b/apps/extension/src/entrypoints/onboarding/index.html @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + Uniswap Extension + + + +
+ + + + diff --git a/apps/extension/src/entrypoints/onboarding/main.tsx b/apps/extension/src/entrypoints/onboarding/main.tsx new file mode 100644 index 00000000..9f561581 --- /dev/null +++ b/apps/extension/src/entrypoints/onboarding/main.tsx @@ -0,0 +1,30 @@ +// oxlint-disable-next-line typescript/triple-slash-reference +/// +// oxlint-disable-next-line typescript/triple-slash-reference +/// + +import React from 'react' +import { createRoot } from 'react-dom/client' +import OnboardingApp from 'src/app/core/OnboardingApp' +import { ExtensionAppLocation, StoreSynchronization } from 'src/store/storeSynchronization' +// oxlint-disable-next-line typescript/no-explicit-any -- Global polyfill cleanup requires any type for runtime modification +;(globalThis as any).regeneratorRuntime = undefined + +function makeOnboarding(): void { + function initOnboarding() { + // oxlint-disable-next-line typescript/no-non-null-assertion -- DOM onboarding root element guaranteed to exist in extension + const container = document.getElementById('onboarding-root')! + const root = createRoot(container) + + root.render( + + + , + ) + } + + StoreSynchronization.init(ExtensionAppLocation.Tab) + initOnboarding() +} + +makeOnboarding() diff --git a/apps/extension/src/entrypoints/sidepanel/index.html b/apps/extension/src/entrypoints/sidepanel/index.html new file mode 100644 index 00000000..be59579a --- /dev/null +++ b/apps/extension/src/entrypoints/sidepanel/index.html @@ -0,0 +1,92 @@ + + + + + + + + + + + + + Uniswap Extension + + +
+ + + diff --git a/apps/extension/src/entrypoints/sidepanel/loadSidebar.ts b/apps/extension/src/entrypoints/sidepanel/loadSidebar.ts new file mode 100644 index 00000000..6ebd6982 --- /dev/null +++ b/apps/extension/src/entrypoints/sidepanel/loadSidebar.ts @@ -0,0 +1,18 @@ +/** + * IMPORTANT: we should keep this file very light. Do not import anything here. + * + * The browser was taking too long to interpret the react JS bundle and initialize the react app, + * so we're now splitting this up and slightly delaying the react bundle execution. + * By doing this, the first render happens faster and there's no longer a flash of a different color background (the default "no background" color). + * Instead, the HTML is now rendered immediately, with the right background color from the inline style. + * + * For video comparison of the before and after, check out https://github.com/Uniswap/universe/pull/9294 + */ + +function makeLoadSidebar(): void { + setTimeout(() => { + import('./main') + }, 10) +} + +makeLoadSidebar() diff --git a/apps/extension/src/entrypoints/sidepanel/main.tsx b/apps/extension/src/entrypoints/sidepanel/main.tsx new file mode 100644 index 00000000..fe698fd7 --- /dev/null +++ b/apps/extension/src/entrypoints/sidepanel/main.tsx @@ -0,0 +1,60 @@ +// oxlint-disable-next-line typescript/triple-slash-reference +/// +// oxlint-disable-next-line typescript/triple-slash-reference +/// + +import 'src/app/utils/devtools' +import 'symbol-observable' // Needed by `reduxed-chrome-storage` as polyfill, order matters +>>>>>>> upstream/main +import React from 'react' +import { createRoot } from 'react-dom/client' +import SidebarApp from 'src/app/core/SidebarApp' +import { onboardingMessageChannel } from 'src/background/messagePassing/messageChannels' +import { OnboardingMessageType } from 'src/background/messagePassing/types/ExtensionMessages' +import { getReduxStore } from 'src/store/store' +import { ExtensionAppLocation, StoreSynchronization } from 'src/store/storeSynchronization' +<<<<<<< HEAD +import { initializeScrollWatcher } from '@l.x/lx/src/components/modals/ScrollLock' +import { initializePortfolioQueryOverrides } from '@l.x/lx/src/data/rest/portfolioBalanceOverrides' +import { logger } from '@l.x/utils/src/logger/logger' +// biome-ignore lint/suspicious/noExplicitAny: Global polyfill cleanup requires any type for runtime modification +======= +import { initializeScrollWatcher } from 'uniswap/src/components/modals/ScrollLock' +import { initializePortfolioQueryOverrides } from 'uniswap/src/data/rest/portfolioBalanceOverrides' +import { logger } from 'utilities/src/logger/logger' +// oxlint-disable-next-line typescript/no-explicit-any -- Global polyfill cleanup requires any type for runtime modification +;(globalThis as any).regeneratorRuntime = undefined + +export function makeSidebar(): void { + function initSidebar(): void { + onboardingMessageChannel + .sendMessage({ + type: OnboardingMessageType.SidebarOpened, + }) + .catch((error) => { + logger.error(error, { + tags: { + file: 'sidebar.ts', + function: 'onboardingMessageChannel.sendMessage', + }, + }) + }) + + // oxlint-disable-next-line typescript/no-non-null-assertion -- DOM root element guaranteed to exist in extension context + const container = window.document.querySelector('#root')! + const root = createRoot(container) + + root.render( + + + , + ) + } + + StoreSynchronization.init(ExtensionAppLocation.SidePanel) + initializePortfolioQueryOverrides({ store: getReduxStore() }) + initSidebar() + initializeScrollWatcher() +} + +makeSidebar() diff --git a/apps/extension/src/entrypoints/unitagClaim/index.html b/apps/extension/src/entrypoints/unitagClaim/index.html new file mode 100644 index 00000000..fc19e980 --- /dev/null +++ b/apps/extension/src/entrypoints/unitagClaim/index.html @@ -0,0 +1,68 @@ + + + + + + + + + + + + + Uniswap Extension + + + +
+ + + + diff --git a/apps/extension/src/entrypoints/unitagClaim/main.tsx b/apps/extension/src/entrypoints/unitagClaim/main.tsx new file mode 100644 index 00000000..cbc5887b --- /dev/null +++ b/apps/extension/src/entrypoints/unitagClaim/main.tsx @@ -0,0 +1,30 @@ +// oxlint-disable-next-line typescript/triple-slash-reference +/// +// oxlint-disable-next-line typescript/triple-slash-reference +/// + +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import UnitagClaimApp from 'src/app/core/UnitagClaimApp' +import { initializeReduxStore } from 'src/store/store' +// oxlint-disable-next-line typescript/no-explicit-any -- Global polyfill cleanup requires any type for runtime modification +;(globalThis as any).regeneratorRuntime = undefined + +function makeUnitagClaim(): void { + function initUnitagClaim(): void { + // oxlint-disable-next-line typescript/no-non-null-assertion -- DOM unitag claim root element guaranteed to exist in extension + const container = document.getElementById('unitag-claim-root')! + const root = createRoot(container) + + root.render( + + + , + ) + } + + initializeReduxStore({ readOnly: true }) + initUnitagClaim() +} + +makeUnitagClaim() diff --git a/apps/extension/src/env.d.ts b/apps/extension/src/env.d.ts new file mode 100644 index 00000000..a7c89fb9 --- /dev/null +++ b/apps/extension/src/env.d.ts @@ -0,0 +1,12 @@ +import { config, TamaguiGroupNames } from 'ui/src/tamagui.config' + +type Conf = typeof config + +declare module 'tamagui' { + // oxlint-disable-next-line typescript/no-empty-interface + interface TamaguiCustomConfig extends Conf {} + + interface TypeOverride { + groupNames(): TamaguiGroupNames + } +} diff --git a/apps/extension/src/features/address-book/addressBookService.ts b/apps/extension/src/features/address-book/addressBookService.ts new file mode 100644 index 00000000..7e63ae07 --- /dev/null +++ b/apps/extension/src/features/address-book/addressBookService.ts @@ -0,0 +1,244 @@ +/** + * Address Book Service + * Manages contacts and address aliases + * Ported from xwallet/Rabby contactBook service + */ + +import { + Contact, + isValidAddress, + MAX_RECENT_ADDRESSES, + normalizeAddress, +} from './types' + +const STORAGE_KEY = 'lux_address_book' +const RECENT_KEY = 'lux_recent_addresses' + +class AddressBookService { + private contacts: Map = new Map() + private recentAddresses: string[] = [] + private initialized = false + + async init(): Promise { + if (this.initialized) return + + try { + const stored = await chrome.storage.local.get([STORAGE_KEY, RECENT_KEY]) + + if (stored[STORAGE_KEY]) { + const contacts = JSON.parse(stored[STORAGE_KEY]) as Record + Object.entries(contacts).forEach(([address, contact]) => { + this.contacts.set(address, contact) + }) + } + + if (stored[RECENT_KEY]) { + this.recentAddresses = JSON.parse(stored[RECENT_KEY]) + } + + this.initialized = true + } catch (error) { + console.error('Failed to load address book:', error) + this.initialized = true + } + } + + private async persist(): Promise { + const contactsObj: Record = {} + this.contacts.forEach((contact, address) => { + contactsObj[address] = contact + }) + + await chrome.storage.local.set({ + [STORAGE_KEY]: JSON.stringify(contactsObj), + [RECENT_KEY]: JSON.stringify(this.recentAddresses), + }) + } + + async addContact( + address: string, + name: string, + options?: { note?: string; isAlias?: boolean } + ): Promise { + await this.init() + + if (!isValidAddress(address)) { + throw new Error(`Invalid address: ${address}`) + } + + if (!name || name.trim().length === 0) { + throw new Error('Name is required') + } + + const normalizedAddress = normalizeAddress(address) + const now = Date.now() + + const contact: Contact = { + address: normalizedAddress, + name: name.trim(), + note: options?.note?.trim(), + isAlias: options?.isAlias ?? false, + createdAt: now, + updatedAt: now, + } + + this.contacts.set(normalizedAddress, contact) + await this.persist() + + return contact + } + + async updateContact( + address: string, + updates: Partial> + ): Promise { + await this.init() + + const normalizedAddress = normalizeAddress(address) + const existing = this.contacts.get(normalizedAddress) + + if (!existing) { + return undefined + } + + const updated: Contact = { + ...existing, + ...updates, + updatedAt: Date.now(), + } + + if (updates.name !== undefined) { + updated.name = updates.name.trim() + } + if (updates.note !== undefined) { + updated.note = updates.note.trim() || undefined + } + + this.contacts.set(normalizedAddress, updated) + await this.persist() + + return updated + } + + async removeContact(address: string): Promise { + await this.init() + + const normalizedAddress = normalizeAddress(address) + if (!this.contacts.has(normalizedAddress)) { + return false + } + + this.contacts.delete(normalizedAddress) + await this.persist() + return true + } + + async getContact(address: string): Promise { + await this.init() + return this.contacts.get(normalizeAddress(address)) + } + + async getContactName(address: string): Promise { + const contact = await this.getContact(address) + return contact?.name + } + + async getAllContacts(): Promise { + await this.init() + return Array.from(this.contacts.values()) + } + + async getAliases(): Promise { + await this.init() + return Array.from(this.contacts.values()).filter((c) => c.isAlias) + } + + async getExternalContacts(): Promise { + await this.init() + return Array.from(this.contacts.values()).filter((c) => !c.isAlias) + } + + async searchContacts(query: string): Promise { + await this.init() + + const lowerQuery = query.toLowerCase() + return Array.from(this.contacts.values()).filter( + (contact) => + contact.name.toLowerCase().includes(lowerQuery) || + contact.address.includes(lowerQuery) || + contact.note?.toLowerCase().includes(lowerQuery) + ) + } + + async hasContact(address: string): Promise { + await this.init() + return this.contacts.has(normalizeAddress(address)) + } + + // Recent addresses management + async addRecentAddress(address: string): Promise { + await this.init() + + if (!isValidAddress(address)) return + + const normalizedAddress = normalizeAddress(address) + + // Remove if already exists + const index = this.recentAddresses.indexOf(normalizedAddress) + if (index > -1) { + this.recentAddresses.splice(index, 1) + } + + // Add to front + this.recentAddresses.unshift(normalizedAddress) + + // Trim to max + if (this.recentAddresses.length > MAX_RECENT_ADDRESSES) { + this.recentAddresses = this.recentAddresses.slice(0, MAX_RECENT_ADDRESSES) + } + + await this.persist() + } + + async getRecentAddresses(): Promise { + await this.init() + return [...this.recentAddresses] + } + + async clearRecentAddresses(): Promise { + await this.init() + this.recentAddresses = [] + await this.persist() + } + + // Bulk operations for import/export + async importContacts(contacts: Contact[]): Promise { + await this.init() + + let imported = 0 + for (const contact of contacts) { + if (!isValidAddress(contact.address)) continue + + const normalizedAddress = normalizeAddress(contact.address) + if (!this.contacts.has(normalizedAddress)) { + this.contacts.set(normalizedAddress, { + ...contact, + address: normalizedAddress, + }) + imported++ + } + } + + if (imported > 0) { + await this.persist() + } + + return imported + } + + async exportContacts(): Promise { + return this.getAllContacts() + } +} + +export const addressBookService = new AddressBookService() diff --git a/apps/extension/src/features/address-book/index.ts b/apps/extension/src/features/address-book/index.ts new file mode 100644 index 00000000..27428bf5 --- /dev/null +++ b/apps/extension/src/features/address-book/index.ts @@ -0,0 +1,7 @@ +/** + * Address Book Feature + * Contact and address management + */ + +export * from './types' +export * from './addressBookService' diff --git a/apps/extension/src/features/address-book/types.ts b/apps/extension/src/features/address-book/types.ts new file mode 100644 index 00000000..27add672 --- /dev/null +++ b/apps/extension/src/features/address-book/types.ts @@ -0,0 +1,32 @@ +/** + * Address Book Types + * Ported from xwallet/Rabby contactBook service + */ + +export interface Contact { + address: string + name: string + note?: string + createdAt: number + updatedAt: number + isAlias?: boolean // True if this is an account alias vs external contact +} + +export interface AddressBookState { + contacts: Record // Keyed by lowercase address + recentAddresses: string[] // Recently used addresses for quick access + isLoading: boolean + error: string | null +} + +// Max recent addresses to track +export const MAX_RECENT_ADDRESSES = 10 + +// Validation +export function isValidAddress(address: string): boolean { + return /^0x[a-fA-F0-9]{40}$/.test(address) +} + +export function normalizeAddress(address: string): string { + return address.toLowerCase() +} diff --git a/apps/extension/src/features/custom-networks/customNetworkService.ts b/apps/extension/src/features/custom-networks/customNetworkService.ts new file mode 100644 index 00000000..baddfd57 --- /dev/null +++ b/apps/extension/src/features/custom-networks/customNetworkService.ts @@ -0,0 +1,206 @@ +/** + * Custom Network Service + * Handles adding, removing, and validating custom EVM networks + * Implements wallet_addEthereumChain RPC method support + */ + +import { + AddEthereumChainParameter, + CustomNetwork, + isBuiltInChain, + isValidChainId, + normalizeChainId, +} from './types' + +const STORAGE_KEY = 'lux_custom_networks' + +class CustomNetworkService { + private networks: Map = new Map() + private initialized = false + + async init(): Promise { + if (this.initialized) return + + try { + const stored = await chrome.storage.local.get(STORAGE_KEY) + if (stored[STORAGE_KEY]) { + const networks = JSON.parse(stored[STORAGE_KEY]) as Record + Object.entries(networks).forEach(([chainId, network]) => { + this.networks.set(chainId, network) + }) + } + this.initialized = true + } catch (error) { + console.error('Failed to load custom networks:', error) + this.initialized = true + } + } + + private async persist(): Promise { + const networksObj: Record = {} + this.networks.forEach((network, chainId) => { + networksObj[chainId] = network + }) + await chrome.storage.local.set({ [STORAGE_KEY]: JSON.stringify(networksObj) }) + } + + async addNetwork(params: AddEthereumChainParameter): Promise { + await this.init() + + // Validate chain ID + if (!isValidChainId(params.chainId)) { + throw new Error(`Invalid chain ID: ${params.chainId}`) + } + + const normalizedChainId = normalizeChainId(params.chainId) + + // Validate RPC URLs + if (!params.rpcUrls || params.rpcUrls.length === 0) { + throw new Error('At least one RPC URL is required') + } + + for (const url of params.rpcUrls) { + try { + new URL(url) + } catch { + throw new Error(`Invalid RPC URL: ${url}`) + } + } + + // Validate native currency + if (!params.nativeCurrency) { + throw new Error('Native currency is required') + } + if (!params.nativeCurrency.symbol || params.nativeCurrency.symbol.length > 6) { + throw new Error('Native currency symbol must be 1-6 characters') + } + if (params.nativeCurrency.decimals !== 18) { + console.warn('Non-standard decimals for native currency:', params.nativeCurrency.decimals) + } + + // Validate block explorer URLs if provided + if (params.blockExplorerUrls) { + for (const url of params.blockExplorerUrls) { + try { + new URL(url) + } catch { + throw new Error(`Invalid block explorer URL: ${url}`) + } + } + } + + // Verify the RPC is reachable and returns correct chain ID + const verifiedChainId = await this.verifyRPC(params.rpcUrls[0], normalizedChainId) + if (verifiedChainId !== normalizedChainId) { + throw new Error( + `Chain ID mismatch: expected ${normalizedChainId}, got ${verifiedChainId}` + ) + } + + const network: CustomNetwork = { + chainId: normalizedChainId, + chainName: params.chainName, + nativeCurrency: params.nativeCurrency, + rpcUrls: params.rpcUrls, + blockExplorerUrls: params.blockExplorerUrls, + iconUrls: params.iconUrls, + isTestnet: this.detectTestnet(params.chainName), + addedAt: Date.now(), + } + + this.networks.set(normalizedChainId, network) + await this.persist() + + return network + } + + async removeNetwork(chainId: string): Promise { + await this.init() + + const normalizedChainId = normalizeChainId(chainId) + + if (isBuiltInChain(normalizedChainId)) { + throw new Error('Cannot remove built-in network') + } + + if (!this.networks.has(normalizedChainId)) { + return false + } + + this.networks.delete(normalizedChainId) + await this.persist() + return true + } + + async getNetwork(chainId: string): Promise { + await this.init() + return this.networks.get(normalizeChainId(chainId)) + } + + async getAllNetworks(): Promise { + await this.init() + return Array.from(this.networks.values()) + } + + async hasNetwork(chainId: string): Promise { + await this.init() + return this.networks.has(normalizeChainId(chainId)) + } + + async updateNetwork( + chainId: string, + updates: Partial> + ): Promise { + await this.init() + + const normalizedChainId = normalizeChainId(chainId) + const existing = this.networks.get(normalizedChainId) + + if (!existing) { + return undefined + } + + const updated: CustomNetwork = { + ...existing, + ...updates, + } + + this.networks.set(normalizedChainId, updated) + await this.persist() + + return updated + } + + private async verifyRPC(rpcUrl: string, expectedChainId: string): Promise { + try { + const response = await fetch(rpcUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + id: 1, + }), + }) + + const data = await response.json() + + if (data.error) { + throw new Error(data.error.message || 'RPC error') + } + + return normalizeChainId(data.result) + } catch (error) { + throw new Error(`Failed to verify RPC: ${(error as Error).message}`) + } + } + + private detectTestnet(chainName: string): boolean { + const testnetKeywords = ['testnet', 'test', 'sepolia', 'goerli', 'mumbai', 'fuji', 'dev'] + const lowerName = chainName.toLowerCase() + return testnetKeywords.some((keyword) => lowerName.includes(keyword)) + } +} + +export const customNetworkService = new CustomNetworkService() diff --git a/apps/extension/src/features/custom-networks/index.ts b/apps/extension/src/features/custom-networks/index.ts new file mode 100644 index 00000000..1aaca82a --- /dev/null +++ b/apps/extension/src/features/custom-networks/index.ts @@ -0,0 +1,7 @@ +/** + * Custom Networks Feature + * Support for wallet_addEthereumChain and custom network management + */ + +export * from './types' +export * from './customNetworkService' diff --git a/apps/extension/src/features/custom-networks/types.ts b/apps/extension/src/features/custom-networks/types.ts new file mode 100644 index 00000000..f213234e --- /dev/null +++ b/apps/extension/src/features/custom-networks/types.ts @@ -0,0 +1,82 @@ +/** + * Custom Network Types + * Support for wallet_addEthereumChain RPC method + */ + +export interface CustomNetwork { + chainId: string // Hex string (e.g., "0x1") + chainName: string + nativeCurrency: { + name: string + symbol: string + decimals: number + } + rpcUrls: string[] + blockExplorerUrls?: string[] + iconUrls?: string[] + isTestnet?: boolean + addedAt: number // Timestamp +} + +export interface AddEthereumChainParameter { + chainId: string + chainName: string + nativeCurrency: { + name: string + symbol: string + decimals: number + } + rpcUrls: string[] + blockExplorerUrls?: string[] + iconUrls?: string[] +} + +export interface CustomNetworkState { + networks: Record // Keyed by chainId + isLoading: boolean + error: string | null +} + +export interface SwitchEthereumChainParameter { + chainId: string +} + +// Validation helpers +export function isValidChainId(chainId: string): boolean { + if (!chainId.startsWith('0x')) return false + const parsed = parseInt(chainId, 16) + return !isNaN(parsed) && parsed > 0 +} + +export function normalizeChainId(chainId: string | number): string { + if (typeof chainId === 'number') { + return '0x' + chainId.toString(16) + } + if (chainId.startsWith('0x')) { + return chainId.toLowerCase() + } + return '0x' + parseInt(chainId, 10).toString(16) +} + +export function chainIdToNumber(chainId: string): number { + return parseInt(chainId, 16) +} + +// Built-in networks that cannot be removed +export const BUILT_IN_CHAIN_IDS = new Set([ + '0x1', // Ethereum Mainnet + '0x89', // Polygon + '0xa4b1', // Arbitrum One + '0xa', // Optimism + '0x2105', // Base + '0x38', // BNB Chain + '0xa86a', // Avalanche + '0xfa', // Fantom + // Lux Network chains + '0x1d51', // Lux Mainnet (7505) + '0x1d52', // Lux Testnet (7506) +]) + +export function isBuiltInChain(chainId: string): boolean { + return BUILT_IN_CHAIN_IDS.has(normalizeChainId(chainId)) +} diff --git a/apps/extension/src/features/hardware-wallets/index.ts b/apps/extension/src/features/hardware-wallets/index.ts new file mode 100644 index 00000000..e197c8aa --- /dev/null +++ b/apps/extension/src/features/hardware-wallets/index.ts @@ -0,0 +1,12 @@ +/** + * Hardware Wallets Feature + * Exports all hardware wallet integrations + */ + +export * from './types' +export * from './ledger/LedgerBridge' + +// Future exports: +// export * from './trezor/TrezorBridge' +// export * from './keystone/KeystoneBridge' +// export * from './onekey/OneKeyBridge' diff --git a/apps/extension/src/features/hardware-wallets/ledger/LedgerBridge.ts b/apps/extension/src/features/hardware-wallets/ledger/LedgerBridge.ts new file mode 100644 index 00000000..af96a73e --- /dev/null +++ b/apps/extension/src/features/hardware-wallets/ledger/LedgerBridge.ts @@ -0,0 +1,324 @@ +/** + * Ledger Hardware Wallet Bridge + * Adapted from xwallet/Rabby for WXT/React architecture + */ + +import { + HardwareAccount, + HardwareWalletBridge, + HardwareWalletError, + HardwareWalletErrorCodes, + HardwareWalletType, + LEDGER_HD_PATHS, + LedgerHDPathType, + SignMessageRequest, + SignTransactionRequest, + SignTypedDataRequest, +} from '../types' + +// We'll dynamically import Ledger packages to handle WebHID requirements +let Transport: typeof import('@ledgerhq/hw-transport').default | null = null +let TransportWebHID: typeof import('@ledgerhq/hw-transport-webhid').default | null = null +let Eth: typeof import('@ledgerhq/hw-app-eth').default | null = null + +async function loadLedgerDependencies() { + if (!Transport) { + const [transportModule, webHidModule, ethModule] = await Promise.all([ + import('@ledgerhq/hw-transport'), + import('@ledgerhq/hw-transport-webhid'), + import('@ledgerhq/hw-app-eth'), + ]) + Transport = transportModule.default + TransportWebHID = webHidModule.default + Eth = ethModule.default + } +} + +export class LedgerBridge implements HardwareWalletBridge { + private transport: InstanceType | null = null + private eth: InstanceType | null = null + private hdPathType: LedgerHDPathType = LedgerHDPathType.LedgerLive + private _isConnected = false + + constructor(hdPathType?: LedgerHDPathType) { + if (hdPathType) { + this.hdPathType = hdPathType + } + } + + setHDPathType(type: LedgerHDPathType): void { + this.hdPathType = type + } + + getHDPathType(): LedgerHDPathType { + return this.hdPathType + } + + async connect(): Promise { + try { + await loadLedgerDependencies() + + if (!TransportWebHID) { + throw new HardwareWalletError( + 'Ledger transport not available', + HardwareWalletErrorCodes.TRANSPORT_ERROR, + HardwareWalletType.Ledger + ) + } + + // Check if WebHID is available + if (!navigator.hid) { + throw new HardwareWalletError( + 'WebHID is not supported in this browser', + HardwareWalletErrorCodes.TRANSPORT_ERROR, + HardwareWalletType.Ledger + ) + } + + this.transport = await TransportWebHID.create() + this.eth = new Eth!(this.transport) + this._isConnected = true + } catch (error) { + this._isConnected = false + if (error instanceof HardwareWalletError) { + throw error + } + throw new HardwareWalletError( + `Failed to connect to Ledger: ${(error as Error).message}`, + HardwareWalletErrorCodes.CONNECTION_FAILED, + HardwareWalletType.Ledger + ) + } + } + + async disconnect(): Promise { + if (this.transport) { + await this.transport.close() + this.transport = null + this.eth = null + } + this._isConnected = false + } + + isConnected(): boolean { + return this._isConnected && this.eth !== null + } + + private getDerivationPath(index: number): string { + const basePath = LEDGER_HD_PATHS[this.hdPathType].value + + switch (this.hdPathType) { + case LedgerHDPathType.LedgerLive: + // m/44'/60'/0'/0/0 -> m/44'/60'/index'/0/0 + return `m/44'/60'/${index}'/0/0` + case LedgerHDPathType.BIP44: + // m/44'/60'/0'/0 -> m/44'/60'/0'/0/index + return `m/44'/60'/0'/0/${index}` + case LedgerHDPathType.Legacy: + // m/44'/60'/0' -> m/44'/60'/index' + return `m/44'/60'/${index}'` + default: + return basePath.replace(/\/0$/, `/${index}`) + } + } + + async getAccounts(page = 0, perPage = 5): Promise { + if (!this.eth) { + throw new HardwareWalletError( + 'Ledger not connected', + HardwareWalletErrorCodes.NOT_CONNECTED, + HardwareWalletType.Ledger + ) + } + + const accounts: HardwareAccount[] = [] + const startIndex = page * perPage + + for (let i = 0; i < perPage; i++) { + const index = startIndex + i + const path = this.getDerivationPath(index) + + try { + const result = await this.eth.getAddress(path) + accounts.push({ + address: result.address, + index, + path, + }) + } catch (error) { + // Handle user rejection or device errors + const errorMessage = (error as Error).message || '' + if ( + errorMessage.includes('denied') || + errorMessage.includes('rejected') || + errorMessage.includes('0x6985') + ) { + throw new HardwareWalletError( + 'User rejected the request', + HardwareWalletErrorCodes.USER_REJECTED, + HardwareWalletType.Ledger + ) + } + if (errorMessage.includes('locked') || errorMessage.includes('0x6b0c')) { + throw new HardwareWalletError( + 'Device is locked. Please unlock your Ledger.', + HardwareWalletErrorCodes.DEVICE_LOCKED, + HardwareWalletType.Ledger + ) + } + if (errorMessage.includes('0x6d00') || errorMessage.includes('CLA_NOT_SUPPORTED')) { + throw new HardwareWalletError( + 'Please open the Ethereum app on your Ledger device', + HardwareWalletErrorCodes.APP_NOT_OPEN, + HardwareWalletType.Ledger + ) + } + throw new HardwareWalletError( + `Failed to get account: ${errorMessage}`, + HardwareWalletErrorCodes.UNKNOWN_ERROR, + HardwareWalletType.Ledger + ) + } + } + + return accounts + } + + async signTransaction(request: SignTransactionRequest): Promise { + if (!this.eth) { + throw new HardwareWalletError( + 'Ledger not connected', + HardwareWalletErrorCodes.NOT_CONNECTED, + HardwareWalletType.Ledger + ) + } + + // Build raw unsigned transaction + // This requires ethers.js or similar for RLP encoding + const { Transaction } = await import('ethers') + + const tx = Transaction.from({ + to: request.to, + value: request.value, + data: request.data, + gasLimit: request.gasLimit, + gasPrice: request.gasPrice, + maxFeePerGas: request.maxFeePerGas, + maxPriorityFeePerGas: request.maxPriorityFeePerGas, + nonce: request.nonce, + chainId: request.chainId, + }) + + const unsignedTx = tx.unsignedSerialized.slice(2) // Remove 0x prefix + + // For now, we need to get the path from context + // In a full implementation, we'd track which account is being used + const path = this.getDerivationPath(0) + + try { + const signature = await this.eth.signTransaction(path, unsignedTx) + + // Combine signature with transaction + const signedTx = Transaction.from({ + ...tx.toJSON(), + signature: { + r: '0x' + signature.r, + s: '0x' + signature.s, + v: parseInt(signature.v, 16), + }, + }) + + return signedTx.serialized + } catch (error) { + const errorMessage = (error as Error).message || '' + if (errorMessage.includes('denied') || errorMessage.includes('rejected')) { + throw new HardwareWalletError( + 'User rejected the transaction', + HardwareWalletErrorCodes.USER_REJECTED, + HardwareWalletType.Ledger + ) + } + throw new HardwareWalletError( + `Failed to sign transaction: ${errorMessage}`, + HardwareWalletErrorCodes.UNKNOWN_ERROR, + HardwareWalletType.Ledger + ) + } + } + + async signMessage(request: SignMessageRequest): Promise { + if (!this.eth) { + throw new HardwareWalletError( + 'Ledger not connected', + HardwareWalletErrorCodes.NOT_CONNECTED, + HardwareWalletType.Ledger + ) + } + + const path = this.getDerivationPath(0) + + try { + // Remove 0x prefix if present and convert to buffer + const messageHex = request.message.startsWith('0x') + ? request.message.slice(2) + : Buffer.from(request.message).toString('hex') + + const signature = await this.eth.signPersonalMessage(path, messageHex) + + // Combine r, s, v into signature + const v = parseInt(signature.v.toString(), 10) + return '0x' + signature.r + signature.s + (v < 27 ? v + 27 : v).toString(16) + } catch (error) { + const errorMessage = (error as Error).message || '' + if (errorMessage.includes('denied') || errorMessage.includes('rejected')) { + throw new HardwareWalletError( + 'User rejected the message signing', + HardwareWalletErrorCodes.USER_REJECTED, + HardwareWalletType.Ledger + ) + } + throw new HardwareWalletError( + `Failed to sign message: ${errorMessage}`, + HardwareWalletErrorCodes.UNKNOWN_ERROR, + HardwareWalletType.Ledger + ) + } + } + + async signTypedData(request: SignTypedDataRequest): Promise { + if (!this.eth) { + throw new HardwareWalletError( + 'Ledger not connected', + HardwareWalletErrorCodes.NOT_CONNECTED, + HardwareWalletType.Ledger + ) + } + + const path = this.getDerivationPath(0) + const typedData = JSON.parse(request.data) + + try { + // Use EIP-712 signing + const signature = await this.eth.signEIP712Message(path, typedData) + + const v = parseInt(signature.v.toString(), 10) + return '0x' + signature.r + signature.s + (v < 27 ? v + 27 : v).toString(16) + } catch (error) { + const errorMessage = (error as Error).message || '' + if (errorMessage.includes('denied') || errorMessage.includes('rejected')) { + throw new HardwareWalletError( + 'User rejected typed data signing', + HardwareWalletErrorCodes.USER_REJECTED, + HardwareWalletType.Ledger + ) + } + throw new HardwareWalletError( + `Failed to sign typed data: ${errorMessage}`, + HardwareWalletErrorCodes.UNKNOWN_ERROR, + HardwareWalletType.Ledger + ) + } + } +} + +export const ledgerBridge = new LedgerBridge() diff --git a/apps/extension/src/features/hardware-wallets/types.ts b/apps/extension/src/features/hardware-wallets/types.ts new file mode 100644 index 00000000..fb368f26 --- /dev/null +++ b/apps/extension/src/features/hardware-wallets/types.ts @@ -0,0 +1,116 @@ +/** + * Hardware Wallet Types + * Ported from xwallet/Rabby with adaptations for WXT/React architecture + */ + +export enum HardwareWalletType { + Ledger = 'ledger', + Trezor = 'trezor', + Keystone = 'keystone', + OneKey = 'onekey', + Lattice = 'lattice', + BitBox02 = 'bitbox02', + ImKey = 'imkey', +} + +export enum LedgerHDPathType { + LedgerLive = 'LedgerLive', + BIP44 = 'BIP44', + Legacy = 'Legacy', +} + +export interface HDPathInfo { + label: string + value: string + description: string +} + +export const LEDGER_HD_PATHS: Record = { + [LedgerHDPathType.LedgerLive]: { + label: 'Ledger Live', + value: "m/44'/60'/0'/0/0", + description: 'Default path used by Ledger Live', + }, + [LedgerHDPathType.BIP44]: { + label: 'BIP44 Standard', + value: "m/44'/60'/0'/0", + description: 'Standard Ethereum path', + }, + [LedgerHDPathType.Legacy]: { + label: 'Legacy (MEW)', + value: "m/44'/60'/0'", + description: 'Legacy path used by older wallets', + }, +} + +export interface HardwareAccount { + address: string + index: number + balance?: string + path: string +} + +export interface HardwareWalletState { + connected: boolean + type: HardwareWalletType | null + accounts: HardwareAccount[] + selectedAccount: string | null + error: string | null + isLoading: boolean +} + +export interface SignTransactionRequest { + to: string + value: string + data: string + gasLimit: string + gasPrice?: string + maxFeePerGas?: string + maxPriorityFeePerGas?: string + nonce: number + chainId: number +} + +export interface SignMessageRequest { + message: string + address: string +} + +export interface SignTypedDataRequest { + address: string + data: string // JSON stringified typed data + version: 'V3' | 'V4' +} + +export interface HardwareWalletBridge { + connect(): Promise + disconnect(): Promise + isConnected(): boolean + getAccounts(page?: number, perPage?: number): Promise + signTransaction(request: SignTransactionRequest): Promise + signMessage(request: SignMessageRequest): Promise + signTypedData(request: SignTypedDataRequest): Promise +} + +// Error types +export class HardwareWalletError extends Error { + constructor( + message: string, + public code: string, + public deviceType?: HardwareWalletType + ) { + super(message) + this.name = 'HardwareWalletError' + } +} + +export const HardwareWalletErrorCodes = { + NOT_CONNECTED: 'NOT_CONNECTED', + CONNECTION_FAILED: 'CONNECTION_FAILED', + USER_REJECTED: 'USER_REJECTED', + DEVICE_LOCKED: 'DEVICE_LOCKED', + APP_NOT_OPEN: 'APP_NOT_OPEN', + UNKNOWN_ERROR: 'UNKNOWN_ERROR', + TRANSPORT_ERROR: 'TRANSPORT_ERROR', + INVALID_PATH: 'INVALID_PATH', +} as const diff --git a/apps/extension/src/manifest.json b/apps/extension/src/manifest.json new file mode 100644 index 00000000..7f5801a1 --- /dev/null +++ b/apps/extension/src/manifest.json @@ -0,0 +1,43 @@ +{ + "manifest_version": 3, + "name": "Uniswap Extension", + "description": "The Uniswap Extension is a self-custody crypto wallet that's built for swapping.", + "version": "1.69.0", + "minimum_chrome_version": "116", + "icons": { + "16": "assets/icon16.png", + "32": "assets/icon32.png", + "48": "assets/icon48.png", + "128": "assets/icon128.png" + }, + "action": { + "default_icon": { + "16": "assets/icon16.png", + "32": "assets/icon32.png", + "48": "assets/icon48.png", + "128": "assets/icon128.png" + } + }, + "side_panel": { + "default_path": "sidepanel.html" + }, + "background": { + "service_worker": "background.js", + "type": "module" + }, + "permissions": ["alarms", "notifications", "sidePanel", "storage", "tabs"], + "host_permissions": ["https://*.uniswap.org/*"], + "externally_connectable": { + "ids": [], + "matches": ["THIS WILL BE OVERWRITTEN DURING THE BUILD PROCESS - See webpack.config.js"] + }, + "commands": { + "_execute_action": { + "suggested_key": { + "default": "Ctrl+Shift+U", + "mac": "Command+Shift+U" + }, + "description": "Toggles the sidebar" + } + } +} diff --git a/apps/extension/src/notification-service/ExtensionNotificationService.tsx b/apps/extension/src/notification-service/ExtensionNotificationService.tsx new file mode 100644 index 00000000..842b2770 --- /dev/null +++ b/apps/extension/src/notification-service/ExtensionNotificationService.tsx @@ -0,0 +1,225 @@ +import { queryOptions } from '@tanstack/react-query' +import { PlatformType } from '@uniswap/client-notification-service/dist/uniswap/notificationservice/v1/api_pb' +import { + createFetchClient, + createNotificationsApiClient, + getEntryGatewayUrl, + provideSessionService, + SharedQueryClient, +} from '@universe/api' +import { SESSION_INIT_QUERY_KEY } from '@universe/api/src/components/ApiInit' +import { getIsSessionServiceEnabled } from '@universe/gating' +import { + createApiNotificationTracker, + createBaseNotificationProcessor, + createNotificationService, + createPollingNotificationDataSource, + createReactiveDataSource, + getNotificationQueryOptions, + type NotificationService, +} from '@universe/notifications' +import ms from 'ms' +import { UnitagClaimRoutes } from 'src/app/navigation/constants' +import { focusOrCreateUniswapInterfaceTab, focusOrCreateUnitagTab } from 'src/app/navigation/utils' +import { createChromeStorageAdapter } from 'src/notification-service/createChromeStorageAdapter' +import { createExtensionLegacyBannersNotificationDataSource } from 'src/notification-service/data-sources/createExtensionLegacyBannersNotificationDataSource' +import { createStorageWarningCondition } from 'src/notification-service/data-sources/reactive/storageWarningCondition' +import { createExtensionNotificationRenderer } from 'src/notification-service/notification-renderer/createExtensionNotificationRenderer' +import { extensionNotificationStore } from 'src/notification-service/notification-renderer/notificationStore' +import { getNotificationTelemetry } from 'src/notification-service/notification-telemetry/getNotificationTelemetry' +import { createExtensionLocalTriggerDataSource } from 'src/notification-service/triggers/createExtensionLocalTriggerDataSource' +import { getReduxStore } from 'src/store/store' +import { uniswapUrls } from 'uniswap/src/constants/urls' +import { mapLocaleToBackendLocale } from 'uniswap/src/features/language/constants' +import { getLocale } from 'uniswap/src/features/language/navigatorLocale' +import { selectCurrentLanguage } from 'uniswap/src/features/settings/selectors' +import { getLogger } from 'utilities/src/logger/logger' +import { REQUEST_SOURCE } from 'utilities/src/platform/requestSource' +import { ReactQueryCacheKey } from 'utilities/src/reactQuery/cache' +import { type QueryOptionsResult } from 'utilities/src/reactQuery/queryOptions' + +/** + * Checks if the session has been initialized by looking at the React Query cache. + * Returns true if the session initialization query has completed successfully. + */ +function getIsSessionInitialized(): boolean { + const sessionState = SharedQueryClient.getQueryState(SESSION_INIT_QUERY_KEY) + return sessionState?.status === 'success' +} + +/** + * Creates the notification service with all necessary dependencies + */ +function provideExtensionNotificationService(ctx: { + navigate: (path: string) => void + getIsApiDataSourceEnabled: () => boolean + getReduxStore: () => ReturnType +}): NotificationService { + const isApiDataSourceEnabled = ctx.getIsApiDataSourceEnabled() + const notifApiBaseUrl = getEntryGatewayUrl() + + const fetchClient = createFetchClient({ + baseUrl: notifApiBaseUrl, + getHeaders: () => { + const currentLanguage = selectCurrentLanguage(getReduxStore().getState()) + const locale = getLocale(currentLanguage) + const backendLocale = mapLocaleToBackendLocale(locale) + + return { + 'Content-Type': 'application/json', + 'x-request-source': REQUEST_SOURCE, + 'x-uniswap-locale': backendLocale, + 'x-app-version': (process.env.VERSION ?? '').split('.').slice(0, 3).join('.'), + } + }, + getSessionService: () => + provideSessionService({ + getBaseUrl: () => getEntryGatewayUrl(), + getIsSessionServiceEnabled, + }), + }) + + const apiClient = createNotificationsApiClient({ + fetchClient, + getApiPathPrefix: () => '', // Empty prefix if the full path is in the base URL + }) + + const notifQueryOptions = getNotificationQueryOptions({ + apiClient, + getPlatformType: () => PlatformType.EXTENSION, + pollIntervalMs: 120000, // Poll every 2 minutes + getIsSessionInitialized, // Check session state before making API calls + }) + + const backendDataSource = createPollingNotificationDataSource({ + queryClient: SharedQueryClient, + queryOptions: notifQueryOptions, + }) + + const tracker = createApiNotificationTracker({ + notificationsApiClient: apiClient, + queryClient: SharedQueryClient, + storage: createChromeStorageAdapter(), + }) + + const bannersDataSource = createExtensionLegacyBannersNotificationDataSource({ + tracker, + pollIntervalMs: ms('10s'), + }) + + const localTriggersDataSource = createExtensionLocalTriggerDataSource({ + getState: () => ctx.getReduxStore().getState(), + dispatch: ctx.getReduxStore().dispatch, + tracker, + pollIntervalMs: ms('5s'), + }) + + // Reactive data source for storage warning - shows when storage is low + // Note: isOnboarding=false for the main app (onboarding has its own context) + const storageWarningDataSource = createReactiveDataSource({ + condition: createStorageWarningCondition({ isOnboarding: false }), + tracker, + source: 'system_alerts', + logFileTag: 'storageWarningCondition', + }) + + const processor = createBaseNotificationProcessor(tracker) + + const renderer = createExtensionNotificationRenderer({ + store: extensionNotificationStore, + }) + + const telemetry = getNotificationTelemetry() + + const dataSources = isApiDataSourceEnabled + ? [backendDataSource, bannersDataSource, localTriggersDataSource, storageWarningDataSource] + : [bannersDataSource, localTriggersDataSource, storageWarningDataSource] + + const onNavigate = (url: string) => { + // Handle explore paths by opening in web interface + if (url.startsWith('/explore/')) { + focusOrCreateUniswapInterfaceTab({ + url: `${uniswapUrls.requestOriginUrl}${url}`, + }).catch((error) => { + getLogger().error(error, { + tags: { + file: 'ExtensionNotificationService', + function: 'onNavigate', + }, + extra: { url }, + }) + }) + return + } + + // Handle internal navigation (paths starting with /) + if (url.startsWith('/')) { + ctx.navigate(url) + return + } + + // Handle special unitag:// protocol for opening unitag claim tabs + if (url.startsWith('unitag://claim/')) { + const route = url.replace('unitag://claim/', '') + const state = ctx.getReduxStore().getState() + const activeAddress = state.wallet.activeAccountAddress + if (activeAddress) { + focusOrCreateUnitagTab(activeAddress, route as UnitagClaimRoutes).catch((error) => { + getLogger().error(error, { + tags: { + file: 'ExtensionNotificationService', + function: 'onNavigate', + }, + extra: { url }, + }) + }) + } + return + } + + // All other URLs are external - open in new tab + window.open(url, '_blank') + } + + const notificationService = createNotificationService({ + dataSources, + tracker, + processor, + renderer, + telemetry, + onNavigate, + }) + + return notificationService +} + +/** + * Query options factory for the notification service. + * + * NOTE: The query key includes isApiDataSourceEnabled to ensure a new service + * is created when the feature flag changes. Without this, the cached service + * would continue using stale data sources. + */ +export function getNotificationServiceQueryOptions(ctx: { + navigate: (path: string) => void + getIsEnabled: () => boolean + getIsApiDataSourceEnabled: () => boolean + getReduxStore: () => ReturnType +}): QueryOptionsResult< + NotificationService, + Error, + NotificationService, + [ReactQueryCacheKey.NotificationService, { isApiDataSourceEnabled: boolean }] +> { + const isApiDataSourceEnabled = ctx.getIsApiDataSourceEnabled() + const isEnabled = ctx.getIsEnabled() + + return queryOptions({ + // Include feature flag in query key so service is recreated when flag changes + queryKey: [ReactQueryCacheKey.NotificationService, { isApiDataSourceEnabled }], + queryFn: () => provideExtensionNotificationService(ctx), + enabled: isEnabled, + staleTime: Infinity, // Never refetch while mounted + gcTime: 0, // Don't persist in cache - NotificationService has methods that can't be serialized + }) +} diff --git a/apps/extension/src/notification-service/ExtensionNotificationServiceManager.tsx b/apps/extension/src/notification-service/ExtensionNotificationServiceManager.tsx new file mode 100644 index 00000000..eca325e5 --- /dev/null +++ b/apps/extension/src/notification-service/ExtensionNotificationServiceManager.tsx @@ -0,0 +1,57 @@ +import { useQuery } from '@tanstack/react-query' +import { FeatureFlags, useFeatureFlag } from '@universe/gating' +import { getIsNotificationServiceLocalOverrideEnabled } from '@universe/notifications' +import React, { useEffect } from 'react' +import { navigate } from 'src/app/navigation/state' +import { getNotificationServiceQueryOptions } from 'src/notification-service/ExtensionNotificationService' +import { NotificationContainer } from 'src/notification-service/notification-renderer/NotificationContainer' +import { getReduxStore } from 'src/store/store' +import { getLogger } from 'utilities/src/logger/logger' + +/** + * Manages the lifecycle of the notification service in the extension. + */ +export function ExtensionNotificationServiceManager(): React.JSX.Element | null { + const isNotificationServiceEnabledFlag = useFeatureFlag(FeatureFlags.NotificationService) + const isNotificationServiceEnabled = + getIsNotificationServiceLocalOverrideEnabled() || isNotificationServiceEnabledFlag + const isApiDataSourceEnabledFlag = useFeatureFlag(FeatureFlags.NotificationApiDataSource) + + const { data: notificationService } = useQuery( + getNotificationServiceQueryOptions({ + navigate: (path: string) => navigate({ pathname: path }), + getIsEnabled: () => isNotificationServiceEnabled, + getIsApiDataSourceEnabled: () => isApiDataSourceEnabledFlag, + getReduxStore: () => getReduxStore(), + }), + ) + + useEffect(() => { + if (!notificationService) { + return undefined + } + + notificationService.initialize().catch((error) => { + getLogger().error(error, { + tags: { file: 'ExtensionNotificationServiceManager', function: 'initialize' }, + extra: { message: 'Failed to initialize notification service' }, + }) + }) + + return () => { + notificationService.destroy() + } + }, [notificationService]) + + if (!isNotificationServiceEnabled || !notificationService) { + return null + } + + return ( + + ) +} diff --git a/apps/extension/src/notification-service/createChromeStorageAdapter.ts b/apps/extension/src/notification-service/createChromeStorageAdapter.ts new file mode 100644 index 00000000..5f94c4b7 --- /dev/null +++ b/apps/extension/src/notification-service/createChromeStorageAdapter.ts @@ -0,0 +1,87 @@ +import type { ApiNotificationTrackerContext } from '@universe/notifications' +import { getLogger } from 'utilities/src/logger/logger' +import { z } from 'zod' + +const NOTIFICATION_STORAGE_KEY = 'uniswap_notifications_processed' + +const NotificationStorageSchema = z.record( + z.string(), + z.object({ + timestamp: z.number(), + }), +) + +type NotificationStorage = z.infer + +/** + * Parses and validates notification storage data from chrome.storage.local + * @param functionName - Name of the calling function for error logging + * @returns Validated storage data or empty object if parsing fails + */ +async function parseNotificationStorage(functionName: string): Promise { + try { + const result = await chrome.storage.local.get(NOTIFICATION_STORAGE_KEY) + const stored = result[NOTIFICATION_STORAGE_KEY] + + if (!stored) { + return {} + } + + const parsed = NotificationStorageSchema.safeParse(stored) + if (!parsed.success) { + getLogger().error(parsed.error, { + tags: { file: 'createChromeStorageAdapter', function: functionName }, + }) + return {} + } + return parsed.data + } catch (error) { + getLogger().error(error, { + tags: { file: 'createChromeStorageAdapter', function: functionName }, + }) + return {} + } +} + +/** + * Creates a chrome.storage.local adapter that implements the storage interface + * required by the API notification tracker. + * + * This adapter stores notification IDs with timestamps to enable: + * - Offline tracking (when API calls fail) + * - Deduplication (avoid sending duplicate ACKs) + * - Cleanup of old entries + */ +export function createChromeStorageAdapter(): NonNullable { + return { + has: async (notificationId: string): Promise => { + const processedIds = await parseNotificationStorage('has') + return notificationId in processedIds + }, + + add: async (notificationId: string, metadata?: { timestamp: number }): Promise => { + const processedIds = await parseNotificationStorage('add') + processedIds[notificationId] = { timestamp: metadata?.timestamp ?? Date.now() } + await chrome.storage.local.set({ [NOTIFICATION_STORAGE_KEY]: processedIds }) + }, + + getAll: async (): Promise> => { + const processedIds = await parseNotificationStorage('getAll') + return new Set(Object.keys(processedIds)) + }, + + deleteOlderThan: async (timestamp: number): Promise => { + try { + const processedIds = await parseNotificationStorage('deleteOlderThan') + const filtered = Object.fromEntries( + Object.entries(processedIds).filter(([, value]) => value.timestamp > timestamp), + ) + await chrome.storage.local.set({ [NOTIFICATION_STORAGE_KEY]: filtered }) + } catch (error) { + getLogger().error(error, { + tags: { file: 'createChromeStorageAdapter', function: 'deleteOlderThan' }, + }) + } + }, + } +} diff --git a/apps/extension/src/notification-service/data-sources/createExtensionLegacyBannersNotificationDataSource.ts b/apps/extension/src/notification-service/data-sources/createExtensionLegacyBannersNotificationDataSource.ts new file mode 100644 index 00000000..360ed8f1 --- /dev/null +++ b/apps/extension/src/notification-service/data-sources/createExtensionLegacyBannersNotificationDataSource.ts @@ -0,0 +1,290 @@ +import { + Background, + Content, + Notification, + NotificationVersion, + OnClick, +} from '@uniswap/client-notification-service/dist/uniswap/notificationservice/v1/api_pb' +import { BackgroundType, ContentStyle, type InAppNotification, OnClickAction, SharedQueryClient } from '@universe/api' +import { + createNotificationDataSource, + type NotificationDataSource, + type NotificationTracker, +} from '@universe/notifications' +import { AppRoutes, SettingsRoutes, UnitagClaimRoutes } from 'src/app/navigation/constants' +import { getReduxStore } from 'src/store/store' +import { AccountType } from 'uniswap/src/features/accounts/types' +import { Platform } from 'uniswap/src/features/platforms/types/Platform' +import { UNITAG_SUFFIX_NO_LEADING_DOT } from 'uniswap/src/features/unitags/constants' +import i18n from 'uniswap/src/i18n' +import { getValidAddress } from 'uniswap/src/utils/addresses' +import { logger } from 'utilities/src/logger/logger' +import { ReactQueryCacheKey } from 'utilities/src/reactQuery/cache' +import { selectHasSkippedUnitagPrompt } from 'wallet/src/features/behaviorHistory/selectors' +import { hasExternalBackup } from 'wallet/src/features/wallet/accounts/utils' + +// Using 'local:' prefix to indicate these are client-only notifications +// This prevents the API tracker from sending AckNotification calls to the backend +const RECOVERY_BACKUP_BANNER_ID = 'local:recovery_backup_banner' +const UNITAG_CLAIM_BANNER_ID = 'local:unitag_claim_banner' + +interface CreateExtensionLegacyBannersNotificationDataSourceContext { + tracker: NotificationTracker + pollIntervalMs?: number +} + +/** + * Creates a notification data source that converts HomeIntroCardStack cards + * into InAppNotifications compatible with the notification system. + * + * This replaces the legacy HomeIntroCardStack with notification system equivalents: + * - Recovery backup reminder + * - Unitag claim prompt + * + * **Migration Logic:** + * This data source checks for legacy dismissal state in Redux and automatically + * migrates it to the notification system on first run. + */ +export function createExtensionLegacyBannersNotificationDataSource( + ctx: CreateExtensionLegacyBannersNotificationDataSourceContext, +): NotificationDataSource { + const { tracker, pollIntervalMs = 5000 } = ctx + + let intervalId: NodeJS.Timeout | null = null + let currentCallback: ((notifications: InAppNotification[], source: string) => void) | null = null + let hasMigratedLegacyState = false + + /** + * Migrates legacy dismissal state from Redux to the notification system. + * This runs once on the first poll to ensure users who dismissed cards in the old system + * don't see them again. + */ + const migrateLegacyDismissalState = async (): Promise => { + if (hasMigratedLegacyState) { + return + } + + try { + const state = getReduxStore().getState() + + // Migrate Unitag skip state + const hasSkippedUnitag = selectHasSkippedUnitagPrompt(state) + if (hasSkippedUnitag) { + logger.info( + 'createExtensionLegacyBannersNotificationDataSource', + 'migrateLegacyDismissalState', + 'Migrating Unitag skip from legacy Redux state', + ) + await tracker.track(UNITAG_CLAIM_BANNER_ID, { timestamp: Date.now() }) + } + + hasMigratedLegacyState = true + } catch (error) { + logger.error(error, { + tags: { + file: 'createExtensionLegacyBannersNotificationDataSource', + function: 'migrateLegacyDismissalState', + }, + }) + } + } + + const pollForNotifications = async (): Promise => { + if (!currentCallback) { + return + } + + try { + // Run migration on first poll + await migrateLegacyDismissalState() + + const notifications = await fetchNotifications() + currentCallback(notifications, 'legacy_intro_cards') + } catch (error) { + logger.error(error, { + tags: { file: 'createExtensionLegacyBannersNotificationDataSource', function: 'pollForNotifications' }, + }) + } + } + + const start = (onNotifications: (notifications: InAppNotification[], source: string) => void): void => { + if (intervalId) { + return + } + + currentCallback = onNotifications + + pollForNotifications().catch((error) => { + logger.error(error, { + tags: { file: 'createExtensionLegacyBannersNotificationDataSource', function: 'start' }, + }) + }) + + intervalId = setInterval(() => { + pollForNotifications().catch((error) => { + logger.error(error, { + tags: { file: 'createExtensionLegacyBannersNotificationDataSource', function: 'setInterval' }, + }) + }) + }, pollIntervalMs) + } + + const stop = async (): Promise => { + if (intervalId) { + clearInterval(intervalId) + intervalId = null + } + currentCallback = null + } + + return createNotificationDataSource({ start, stop }) +} + +/** + * Fetches all notifications based on current conditions. + * The processor will handle filtering based on tracked/processed state. + * + * Priority order (matches useSharedIntroCards): + * 1. Recovery backup (if no external backup) + * 2. Unitag claim (if eligible) + */ +async function fetchNotifications(): Promise { + const notifications: InAppNotification[] = [] + + // Priority 1: Recovery backup reminder + const backupNotification = await checkRecoveryBackup() + if (backupNotification) { + notifications.push(backupNotification) + } + + // Priority 2: Unitag claim + const unitagNotification = await checkUnitagClaim() + if (unitagNotification) { + notifications.push(unitagNotification) + } + + return notifications +} + +/** + * Check if recovery backup reminder should be shown. + */ +async function checkRecoveryBackup(): Promise { + const state = getReduxStore().getState() + const activeAccount = state.wallet.accounts[state.wallet.activeAccountAddress ?? ''] + + if (!activeAccount || activeAccount.type !== AccountType.SignerMnemonic) { + return null + } + + const hasBackup = hasExternalBackup(activeAccount) + if (hasBackup) { + return null + } + + return createRecoveryBackupBanner() +} + +/** + * Check if Unitag claim prompt should be shown. + */ +async function checkUnitagClaim(): Promise { + const state = getReduxStore().getState() + const activeAccount = state.wallet.accounts[state.wallet.activeAccountAddress ?? ''] + + if (!activeAccount || activeAccount.type !== AccountType.SignerMnemonic) { + return null + } + + // Check if any account has a unitag by reading from React Query cache + // Unitags are fetched via API and cached, not stored in Redux + const accounts = Object.values(state.wallet.accounts) as Array<{ address?: string }> + const hasAnyUnitag = accounts.some((account) => { + if (!account.address) { + return false + } + // Normalize the address the same way useUnitagsAddressQuery does + // This ensures we match the exact query key format used when caching + const validatedAddress = getValidAddress({ address: account.address, platform: Platform.EVM }) + if (!validatedAddress) { + return false + } + + // Read from React Query cache using the same query key as useUnitagsAddressQuery + const queryKey = [ReactQueryCacheKey.UnitagsApi, 'address', { address: validatedAddress }] + const cachedUnitag = SharedQueryClient.getQueryData<{ username?: string }>(queryKey) + + return !!cachedUnitag?.username + }) + + if (hasAnyUnitag) { + return null + } + + // Note: We can't check canClaimUnitag here since it requires an async query + // The notification will be shown and processor will handle dismissal if already claimed + return createUnitagClaimBanner() +} + +/** + * Create recovery backup banner notification + */ +function createRecoveryBackupBanner(): InAppNotification { + return new Notification({ + id: RECOVERY_BACKUP_BANNER_ID, + content: new Content({ + version: NotificationVersion.V0, + style: ContentStyle.LOWER_LEFT_BANNER, + title: i18n.t('onboarding.home.intro.backup.title'), + subtitle: i18n.t('onboarding.home.intro.backup.description.extension'), + background: new Background({ + backgroundType: BackgroundType.UNSPECIFIED, + backgroundOnClick: new OnClick({ + // No ACK here - required notifications should reappear until the user completes the backup + // The notification will stop showing once hasExternalBackup() returns true + onClick: [OnClickAction.EXTERNAL_LINK, OnClickAction.DISMISS], + onClickLink: `/${AppRoutes.Settings}/${SettingsRoutes.BackupRecoveryPhrase}`, + }), + }), + // No onDismissClick - required cards cannot be dismissed + buttons: [], + iconLink: 'custom:shield-check-$accent1', + // Encode cardType in extra field for IntroCard rendering + extra: JSON.stringify({ cardType: 'required', graphicType: 'icon' }), + }), + }) +} + +/** + * Create Unitag claim banner notification + */ +function createUnitagClaimBanner(): InAppNotification { + // We need to construct a special action that will call focusOrCreateUnitagTab + // Since we can't directly call functions from notification actions, we'll use a special internal link + // that the navigation handler will recognize and handle specially + const unitagClaimLink = `unitag://claim/${UnitagClaimRoutes.ClaimIntro}` + + return new Notification({ + id: UNITAG_CLAIM_BANNER_ID, + content: new Content({ + version: NotificationVersion.V0, + style: ContentStyle.LOWER_LEFT_BANNER, + title: i18n.t('onboarding.home.intro.unitag.title', { unitagDomain: UNITAG_SUFFIX_NO_LEADING_DOT }), + subtitle: i18n.t('onboarding.home.intro.unitag.description'), + background: new Background({ + backgroundType: BackgroundType.UNSPECIFIED, + backgroundOnClick: new OnClick({ + onClick: [OnClickAction.EXTERNAL_LINK, OnClickAction.DISMISS, OnClickAction.ACK], + onClickLink: unitagClaimLink, + }), + }), + onDismissClick: new OnClick({ + onClick: [OnClickAction.DISMISS, OnClickAction.ACK], + }), + buttons: [], + iconLink: 'custom:person-$accent1', + // Encode cardType in extra field for IntroCard rendering + extra: JSON.stringify({ cardType: 'dismissible', graphicType: 'icon' }), + }), + }) +} diff --git a/apps/extension/src/notification-service/data-sources/reactive/storageWarningCondition.ts b/apps/extension/src/notification-service/data-sources/reactive/storageWarningCondition.ts new file mode 100644 index 00000000..aa92fe14 --- /dev/null +++ b/apps/extension/src/notification-service/data-sources/reactive/storageWarningCondition.ts @@ -0,0 +1,140 @@ +import { + Content, + Metadata, + Notification, + OnClick, +} from '@uniswap/client-notification-service/dist/uniswap/notificationservice/v1/api_pb' +import { ContentStyle, type InAppNotification, OnClickAction } from '@universe/api' +import { type ReactiveCondition } from '@universe/notifications' +import { GlobalErrorEvent } from 'src/app/events/constants' +import { globalEventEmitter } from 'src/app/events/global' +import { logger } from 'utilities/src/logger/logger' + +/** + * Storage threshold in bytes (500KB). + * When remaining storage quota falls below this, show warning. + */ +const REMAINING_STORAGE_THRESHOLD_BYTES = 500_000 + +/** + * Unique ID for the storage warning notification. + * Uses 'local:' prefix to distinguish from backend-generated notifications. + */ +const STORAGE_WARNING_NOTIFICATION_ID = 'local:session:storage_warning' + +/** + * State tracked by the storage warning condition. + */ +interface StorageWarningConditionState { + isLowStorage: boolean + /** Whether the user is in onboarding flow (affects modal UI) */ + isOnboarding: boolean +} + +/** + * Context required to create the storage warning condition. + */ +interface CreateStorageWarningConditionContext { + /** Whether this is being used in the onboarding context */ + isOnboarding: boolean +} + +/** + * Creates a reactive condition for the storage warning modal. + * + * The warning will show when: + * - Remaining storage (quota - usage) is below 500KB (checked on mount) + * - OR a ReduxStorageExceeded event is emitted + * + * The warning only shows once per session (like the original implementation). + * + * @see useCheckLowStorage for the original implementation + */ +export function createStorageWarningCondition( + ctx: CreateStorageWarningConditionContext, +): ReactiveCondition { + const { isOnboarding } = ctx + + // Track if we've already shown the warning this session + let hasShownWarning = false + + return { + notificationId: STORAGE_WARNING_NOTIFICATION_ID, + + subscribe: (onStateChange) => { + // Check storage on initial subscription (if not onboarding) + if (!isOnboarding) { + navigator.storage + .estimate() + .then(({ quota, usage }) => { + const remaining = (quota ?? 0) - (usage ?? 0) + if (remaining < REMAINING_STORAGE_THRESHOLD_BYTES && !hasShownWarning) { + hasShownWarning = true + logger.info('storageWarningCondition', 'subscribe', 'Low storage warning triggered by quota check') + onStateChange({ isLowStorage: true, isOnboarding }) + } + }) + .catch(() => { + // Silently ignore storage estimation errors + }) + } + + // Listen for Redux storage exceeded events + const listener = (): void => { + if (!hasShownWarning) { + hasShownWarning = true + logger.info('storageWarningCondition', 'subscribe', 'Low storage warning triggered by ReduxStorageExceeded') + onStateChange({ isLowStorage: true, isOnboarding }) + } + } + + globalEventEmitter.addListener(GlobalErrorEvent.ReduxStorageExceeded, listener) + + // Return unsubscribe function + return () => { + globalEventEmitter.removeListener(GlobalErrorEvent.ReduxStorageExceeded, listener) + } + }, + + shouldShow: (state) => { + return state.isLowStorage + }, + + createNotification: (state): InAppNotification => { + return new Notification({ + id: STORAGE_WARNING_NOTIFICATION_ID, + content: new Content({ + // Use SYSTEM_BANNER style for system alerts + style: ContentStyle.SYSTEM_BANNER, + title: '', // Title is rendered by the custom renderer using i18n + version: 0, + buttons: [], + onDismissClick: new OnClick({ + onClick: [OnClickAction.DISMISS, OnClickAction.ACK], + }), + }), + // Store isOnboarding in metadata business field for the renderer + metadata: new Metadata({ + owner: 'local', + business: state.isOnboarding ? 'storage_warning_onboarding' : 'storage_warning', + }), + }) + }, + } +} + +/** + * Type guard to check if a notification is the storage warning notification. + * Used by NotificationContainer to route to the correct renderer. + */ +export function isStorageWarningNotification(notification: InAppNotification): boolean { + return notification.id === STORAGE_WARNING_NOTIFICATION_ID +} + +/** + * Extract isOnboarding flag from notification metadata. + * Uses the business field to determine if this is an onboarding notification. + */ +export function getIsOnboardingFromNotification(notification: InAppNotification): boolean { + return notification.metadata?.business === 'storage_warning_onboarding' +} diff --git a/apps/extension/src/notification-service/notification-renderer/NotificationContainer.tsx b/apps/extension/src/notification-service/notification-renderer/NotificationContainer.tsx new file mode 100644 index 00000000..a2ba8cab --- /dev/null +++ b/apps/extension/src/notification-service/notification-renderer/NotificationContainer.tsx @@ -0,0 +1,229 @@ +import { ContentStyle, type InAppNotification } from '@universe/api' +import { type NotificationClickTarget } from '@universe/notifications' +import { InlineBannerNotification } from '@universe/notifications/src/notification-renderer/components/InlineBannerNotification' +import { memo, useEffect, useMemo } from 'react' +import { isStorageWarningNotification } from 'src/notification-service/data-sources/reactive/storageWarningCondition' +import { + extensionNotificationStore, + type NotificationState, +} from 'src/notification-service/notification-renderer/notificationStore' +import { AppRatingModalRenderer } from 'src/notification-service/renderers/AppRatingModalRenderer' +import { StorageWarningModalRenderer } from 'src/notification-service/renderers/StorageWarningModalRenderer' +import { isAppRatingNotification } from 'src/notification-service/triggers/appRatingTrigger' +import { isLocalTriggerNotification } from 'src/notification-service/triggers/createExtensionLocalTriggerDataSource' +import { ModalNotification } from 'uniswap/src/components/notifications/ModalNotification' +import { getLogger } from 'utilities/src/logger/logger' +import { useEvent } from 'utilities/src/react/hooks' +import { type IntroCardProps } from 'wallet/src/components/introCards/IntroCard' +import { IntroCardStack } from 'wallet/src/components/introCards/IntroCardStack' +import { + convertNotificationToIntroCard, + shouldRenderAsIntroCard, +} from 'wallet/src/features/notifications/convertNotificationToIntroCard' +import { type StoreApi, type UseBoundStore } from 'zustand' + +/** + * Routes a notification to the appropriate renderer based on its style + */ +function Notification({ + notification, + onRenderFailed, + onNotificationClick, + onNotificationShown, +}: { + notification: InAppNotification + onRenderFailed?: (id: string) => void + onNotificationClick?: (notificationId: string, target: NotificationClickTarget) => void + onNotificationShown?: (notificationId: string) => void +}) { + const style = notification.content?.style + const isUnknownStyle = style !== ContentStyle.MODAL && style !== ContentStyle.LOWER_LEFT_BANNER + + // Handle unknown/invalid notification styles as a side effect + // This handles cases where the server sends string enums on first request and numeric + // enums on subsequent requests. Clean up without marking as processed to allow retry. + useEffect(() => { + if (!isUnknownStyle) { + return () => null + } + + getLogger().warn( + 'NotificationRenderer', + 'renderNotification', + `Unknown notification style: ${style}, cleaning up failed render to allow retry with correct data`, + { + notification, + }, + ) + + const timeoutId = setTimeout(() => { + onRenderFailed?.(notification.id) + }, 1) + + return () => clearTimeout(timeoutId) + }, [isUnknownStyle, style, notification, onRenderFailed]) + + if (isUnknownStyle) { + return null + } + + if (style === ContentStyle.MODAL) { + return ( + + ) + } + + // ContentStyle.LOWER_LEFT_BANNER + // Intro cards are handled by IntroCardStack in NotificationContainer + if (shouldRenderAsIntroCard(notification)) { + // Intro cards shouldn't reach here since they're filtered in NotificationContainer + getLogger().warn( + 'NotificationRenderer', + 'renderNotification', + 'IntroCard notification reached NotificationRenderer - should be handled by IntroCardStack', + { notification }, + ) + return null + } + + // Standard banner notification + return +} + +/** + * Subscribes to the notification store and renders active notifications depending on their style. + */ +export const NotificationContainer = memo(function NotificationContainer({ + onRenderFailed, + onNotificationClick, + onNotificationShown, + store = extensionNotificationStore, +}: { + onRenderFailed?: (notificationId: string) => void + onNotificationClick?: (notificationId: string, target: NotificationClickTarget) => void + onNotificationShown?: (notificationId: string) => void + store?: UseBoundStore> +}) { + const activeNotifications = store((state) => state.activeNotifications) + const removeNotification = store((state) => state.removeNotification) + + const handleRenderFailed = useEvent((notificationId: string) => { + removeNotification(notificationId) + onRenderFailed?.(notificationId) + }) + + const handleIntroCardPress = useEvent((notificationId: string) => { + onNotificationClick?.(notificationId, { type: 'background' }) + }) + + const handleIntroCardClose = useEvent((notificationId: string) => { + onNotificationClick?.(notificationId, { type: 'dismiss' }) + }) + + // Separate notifications by type: intro cards, local triggers, system banners, and standard notifications + const { introCardNotifications, localTriggerNotifications, systemBannerNotifications, standardNotifications } = + useMemo(() => { + const introCards: InAppNotification[] = [] + const localTriggers: InAppNotification[] = [] + const systemBanners: InAppNotification[] = [] + const standard: InAppNotification[] = [] + + activeNotifications.forEach((notification) => { + if (notification.content?.style === ContentStyle.SYSTEM_BANNER) { + // System banners (storage warning, etc.) use SYSTEM_BANNER style + systemBanners.push(notification) + } else if (shouldRenderAsIntroCard(notification)) { + introCards.push(notification) + } else if (isLocalTriggerNotification(notification.id)) { + localTriggers.push(notification) + } else { + standard.push(notification) + } + }) + + return { + introCardNotifications: introCards, + localTriggerNotifications: localTriggers, + systemBannerNotifications: systemBanners, + standardNotifications: standard, + } + }, [activeNotifications]) + + // Convert intro card notifications to IntroCardProps + const introCards: IntroCardProps[] = useMemo(() => { + return introCardNotifications + .map((notification) => { + return convertNotificationToIntroCard(notification, { + onPress: () => handleIntroCardPress(notification.id), + onClose: () => handleIntroCardClose(notification.id), + }) + }) + .filter((card): card is IntroCardProps => card !== null) + }, [introCardNotifications, handleIntroCardPress, handleIntroCardClose]) + + return ( + <> + {/* Render intro cards in a stack */} + {introCards.length > 0 && } + + {/* Render local trigger notifications with custom renderers */} + {localTriggerNotifications.map((notification) => { + if (isAppRatingNotification(notification)) { + return ( + + ) + } + // Add more local trigger renderers here as they are migrated + // e.g., if (isSmartWalletCreatedNotification(notification)) { ... } + getLogger().warn( + 'NotificationContainer', + 'localTriggerNotifications', + `Unknown local trigger notification: ${notification.id}`, + { notification }, + ) + return null + })} + + {/* Render system banner notifications (storage warning, etc.) */} + {systemBannerNotifications.map((notification) => { + if (isStorageWarningNotification(notification)) { + return ( + + ) + } + getLogger().warn( + 'NotificationContainer', + 'systemBannerNotifications', + `Unknown system banner notification: ${notification.id}`, + { notification }, + ) + return null + })} + + {/* Render standard notification types (modals, banners, etc) */} + {standardNotifications.map((notification) => ( + + ))} + + ) +}) diff --git a/apps/extension/src/notification-service/notification-renderer/createExtensionNotificationRenderer.ts b/apps/extension/src/notification-service/notification-renderer/createExtensionNotificationRenderer.ts new file mode 100644 index 00000000..89977ffd --- /dev/null +++ b/apps/extension/src/notification-service/notification-renderer/createExtensionNotificationRenderer.ts @@ -0,0 +1,44 @@ +import { ContentStyle, type InAppNotification } from '@universe/api' +import { createNotificationRenderer, type NotificationRenderer } from '@universe/notifications' +import { type NotificationState } from 'src/notification-service/notification-renderer/notificationStore' +import { type StoreApi, type UseBoundStore } from 'zustand' + +interface CreateExtensionNotificationRendererContext { + store: UseBoundStore> +} + +/** + * Creates an extension-specific NotificationRenderer that uses Zustand store. + * This renderer coordinates rendering for all notification types in the extension. + */ +export function createExtensionNotificationRenderer( + ctx: CreateExtensionNotificationRendererContext, +): NotificationRenderer { + const store = ctx.store + + return createNotificationRenderer({ + render: (notification: InAppNotification): (() => void) => { + // Add notification to the store, which will trigger React to render it + store.getState().addNotification(notification) + + // Return cleanup function that removes the notification from the store + return (): void => { + store.getState().removeNotification(notification.id) + } + }, + + canRender: (notification: InAppNotification): boolean => { + const { activeNotifications } = store.getState() + const style = notification.content?.style + + // Only one modal at a time + if (style === ContentStyle.MODAL) { + const hasActiveModal = activeNotifications.some((n) => n.content?.style === ContentStyle.MODAL) + return !hasActiveModal + } + + // Other notification types can be rendered concurrently (up to limits defined in the processor) + return true + }, + }) +} diff --git a/apps/extension/src/notification-service/notification-renderer/notificationStore.ts b/apps/extension/src/notification-service/notification-renderer/notificationStore.ts new file mode 100644 index 00000000..b2f9c53e --- /dev/null +++ b/apps/extension/src/notification-service/notification-renderer/notificationStore.ts @@ -0,0 +1,37 @@ +import { type InAppNotification } from '@universe/api' +import { create, type StoreApi, type UseBoundStore } from 'zustand' + +export interface NotificationState { + // Currently active notifications + activeNotifications: InAppNotification[] + // Add a notification to be rendered + addNotification: (notification: InAppNotification) => void + // Remove a notification from the active list + removeNotification: (notificationId: string) => void +} + +export const extensionNotificationStore: UseBoundStore> = create( + (set) => ({ + activeNotifications: [], + + addNotification: (notification: InAppNotification): void => { + set((state) => { + // The NotificationService should prevent duplicates, but we check here defensively just in case. + const exists = state.activeNotifications.some((n) => n.id === notification.id) + if (exists) { + return state + } + + return { + activeNotifications: [...state.activeNotifications, notification], + } + }) + }, + + removeNotification: (notificationId: string): void => { + set((state) => ({ + activeNotifications: state.activeNotifications.filter((n) => n.id !== notificationId), + })) + }, + }), +) diff --git a/apps/extension/src/notification-service/notification-telemetry/getNotificationTelemetry.ts b/apps/extension/src/notification-service/notification-telemetry/getNotificationTelemetry.ts new file mode 100644 index 00000000..ed34b177 --- /dev/null +++ b/apps/extension/src/notification-service/notification-telemetry/getNotificationTelemetry.ts @@ -0,0 +1,36 @@ +import { createNotificationTelemetry, type NotificationTelemetry } from '@universe/notifications' +import { InterfaceEventName } from 'uniswap/src/features/telemetry/constants' +import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' + +/** + * Creates a NotificationTelemetry implementation that sends events to Amplitude + * via the existing extension analytics infrastructure. + */ +export function getNotificationTelemetry(): NotificationTelemetry { + return createNotificationTelemetry({ + onNotificationReceived(params) { + sendAnalyticsEvent(InterfaceEventName.NotificationReceived, { + notification_id: params.notificationId, + notification_type: params.type, + source: params.source, + timestamp: params.timestamp, + }) + }, + + onNotificationShown(params) { + sendAnalyticsEvent(InterfaceEventName.NotificationShown, { + notification_id: params.notificationId, + notification_type: params.type, + timestamp: params.timestamp, + }) + }, + + onNotificationInteracted(params) { + sendAnalyticsEvent(InterfaceEventName.NotificationInteracted, { + notification_id: params.notificationId, + notification_type: params.type, + action: params.action, + }) + }, + }) +} diff --git a/apps/extension/src/notification-service/renderers/AppRatingModalRenderer.tsx b/apps/extension/src/notification-service/renderers/AppRatingModalRenderer.tsx new file mode 100644 index 00000000..89fcc676 --- /dev/null +++ b/apps/extension/src/notification-service/renderers/AppRatingModalRenderer.tsx @@ -0,0 +1,40 @@ +import { type InAppNotification } from '@universe/api' +import { type NotificationClickTarget } from '@universe/notifications' +import { useEffect } from 'react' +import AppRatingModal from 'src/app/features/appRating/AppRatingModal' + +interface AppRatingModalRendererProps { + notification: InAppNotification + onNotificationClick?: (notificationId: string, target: NotificationClickTarget) => void + onNotificationShown?: (notificationId: string) => void +} + +/** + * Wrapper component that renders the AppRatingModal within the notification service. + * + * This component: + * 1. Reports when the modal is shown (for telemetry) + * 2. Reports when the modal is dismissed (for tracking) + * 3. Preserves the existing modal UI exactly (no visual changes) + * + * The AppRatingModal handles its own internal state (Initial, NotReally, Yes states) + * and Redux updates. This wrapper just handles the notification service integration. + */ +export function AppRatingModalRenderer({ + notification, + onNotificationClick, + onNotificationShown, +}: AppRatingModalRendererProps): JSX.Element { + // Report when the modal is shown + useEffect(() => { + onNotificationShown?.(notification.id) + }, [notification.id, onNotificationShown]) + + const handleClose = (): void => { + // Report to notification service that user dismissed the modal + // The AppRatingModal internally handles analytics and Redux updates + onNotificationClick?.(notification.id, { type: 'dismiss' }) + } + + return +} diff --git a/apps/extension/src/notification-service/renderers/StorageWarningModalRenderer.tsx b/apps/extension/src/notification-service/renderers/StorageWarningModalRenderer.tsx new file mode 100644 index 00000000..6ea9244b --- /dev/null +++ b/apps/extension/src/notification-service/renderers/StorageWarningModalRenderer.tsx @@ -0,0 +1,70 @@ +import { type InAppNotification } from '@universe/api' +import { type NotificationClickTarget } from '@universe/notifications' +import { useCallback, useEffect } from 'react' +import { useTranslation } from 'react-i18next' +import { ONBOARDING_CONTENT_WIDTH } from 'src/app/features/onboarding/utils' +import { AppRoutes, SettingsRoutes } from 'src/app/navigation/constants' +import { useExtensionNavigation } from 'src/app/navigation/utils' +import { getIsOnboardingFromNotification } from 'src/notification-service/data-sources/reactive/storageWarningCondition' +import { spacing } from 'ui/src/theme' +import { WarningSeverity } from 'uniswap/src/components/modals/WarningModal/types' +import { WarningModal } from 'uniswap/src/components/modals/WarningModal/WarningModal' +import { ModalName } from 'uniswap/src/features/telemetry/constants' + +interface StorageWarningModalRendererProps { + notification: InAppNotification + onNotificationClick?: (notificationId: string, target: NotificationClickTarget) => void + onNotificationShown?: (notificationId: string) => void +} + +/** + * Renderer for the storage warning modal notification. + * + * This component preserves the exact UI of the original StorageWarningModal: + * - High severity warning modal + * - "Close" button always shown + * - "View Recovery Phrase" button shown only when NOT in onboarding + * + * @see StorageWarningModal for the original implementation + */ +export function StorageWarningModalRenderer({ + notification, + onNotificationClick, + onNotificationShown, +}: StorageWarningModalRendererProps): JSX.Element { + const { t } = useTranslation() + const { navigateTo } = useExtensionNavigation() + const isOnboarding = getIsOnboardingFromNotification(notification) + + // Report when the modal is shown + useEffect(() => { + onNotificationShown?.(notification.id) + }, [notification.id, onNotificationShown]) + + const handleClose = useCallback((): void => { + // Report to notification service that user dismissed the modal + onNotificationClick?.(notification.id, { type: 'dismiss' }) + }, [notification.id, onNotificationClick]) + + const handleAcknowledge = useCallback((): void => { + // Close the modal first + onNotificationClick?.(notification.id, { type: 'dismiss' }) + // Navigate to recovery phrase settings + navigateTo(`/${AppRoutes.Settings}/${SettingsRoutes.ViewRecoveryPhrase}`) + }, [notification.id, onNotificationClick, navigateTo]) + + return ( + + ) +} diff --git a/apps/extension/src/notification-service/triggers/appRatingTrigger.test.ts b/apps/extension/src/notification-service/triggers/appRatingTrigger.test.ts new file mode 100644 index 00000000..8ca88155 --- /dev/null +++ b/apps/extension/src/notification-service/triggers/appRatingTrigger.test.ts @@ -0,0 +1,144 @@ +import { + Content, + Metadata, + Notification, +} from '@uniswap/client-notification-service/dist/uniswap/notificationservice/v1/api_pb' +import { ContentStyle } from '@universe/api' +import { + APP_RATING_NOTIFICATION_ID, + createAppRatingTrigger, + isAppRatingNotification, +} from 'src/notification-service/triggers/appRatingTrigger' +import { type ExtensionState } from 'src/store/extensionReducer' +import { appRatingStateSelector } from 'wallet/src/features/appRating/selectors' +import { setAppRating } from 'wallet/src/features/wallet/slice' + +jest.mock('wallet/src/features/appRating/selectors') +const mockAppRatingStateSelector = appRatingStateSelector as jest.MockedFunction + +describe('appRatingTrigger', () => { + const mockDispatch = jest.fn() + const mockGetState = jest.fn() + + beforeEach(() => { + jest.clearAllMocks() + }) + + describe('createAppRatingTrigger', () => { + it('returns a trigger with the correct ID', () => { + const trigger = createAppRatingTrigger({ + getState: mockGetState, + dispatch: mockDispatch, + }) + + expect(trigger.id).toBe(APP_RATING_NOTIFICATION_ID) + expect(trigger.id.startsWith('local:')).toBe(true) + }) + + describe('shouldShow', () => { + it('returns true when appRatingStateSelector.shouldPrompt is true', () => { + mockAppRatingStateSelector.mockReturnValue({ + shouldPrompt: true, + consecutiveSwapsCondition: true, + appRatingPromptedMs: undefined, + appRatingProvidedMs: undefined, + }) + + const trigger = createAppRatingTrigger({ + getState: mockGetState, + dispatch: mockDispatch, + }) + + expect(trigger.shouldShow()).toBe(true) + expect(mockAppRatingStateSelector).toHaveBeenCalledWith(mockGetState()) + }) + + it('returns false when appRatingStateSelector.shouldPrompt is false', () => { + mockAppRatingStateSelector.mockReturnValue({ + shouldPrompt: false, + consecutiveSwapsCondition: false, + appRatingPromptedMs: Date.now(), + appRatingProvidedMs: undefined, + }) + + const trigger = createAppRatingTrigger({ + getState: mockGetState, + dispatch: mockDispatch, + }) + + expect(trigger.shouldShow()).toBe(false) + }) + }) + + describe('createNotification', () => { + it('returns a notification with the correct ID and style', () => { + const trigger = createAppRatingTrigger({ + getState: mockGetState, + dispatch: mockDispatch, + }) + + const notification = trigger.createNotification() + + expect(notification.id).toBe(APP_RATING_NOTIFICATION_ID) + expect(notification.content?.style).toBe(ContentStyle.MODAL) + }) + + it('returns a notification with local metadata', () => { + const trigger = createAppRatingTrigger({ + getState: mockGetState, + dispatch: mockDispatch, + }) + + const notification = trigger.createNotification() + + expect(notification.metadata?.owner).toBe('local') + expect(notification.metadata?.business).toBe('app_rating') + }) + }) + + describe('onAcknowledge', () => { + it('dispatches setAppRating when called', () => { + const trigger = createAppRatingTrigger({ + getState: mockGetState, + dispatch: mockDispatch, + }) + + trigger.onAcknowledge?.() + + expect(mockDispatch).toHaveBeenCalledWith(setAppRating({})) + }) + }) + }) + + describe('isAppRatingNotification', () => { + it('returns true for app rating notification', () => { + const notification = new Notification({ + id: APP_RATING_NOTIFICATION_ID, + content: new Content({ style: ContentStyle.MODAL, title: '' }), + metadata: new Metadata({ owner: 'local', business: 'app_rating' }), + }) + + expect(isAppRatingNotification(notification)).toBe(true) + }) + + it('returns false for other notifications', () => { + const notification = new Notification({ + id: 'some-other-notification', + content: new Content({ style: ContentStyle.MODAL, title: '' }), + metadata: new Metadata({ owner: 'test', business: 'test' }), + }) + + expect(isAppRatingNotification(notification)).toBe(false) + }) + + it('returns false for other local notifications', () => { + const notification = new Notification({ + id: 'local:other_trigger', + content: new Content({ style: ContentStyle.MODAL, title: '' }), + metadata: new Metadata({ owner: 'local', business: 'other' }), + }) + + expect(isAppRatingNotification(notification)).toBe(false) + }) + }) +}) diff --git a/apps/extension/src/notification-service/triggers/appRatingTrigger.ts b/apps/extension/src/notification-service/triggers/appRatingTrigger.ts new file mode 100644 index 00000000..5a361b17 --- /dev/null +++ b/apps/extension/src/notification-service/triggers/appRatingTrigger.ts @@ -0,0 +1,87 @@ +import { + Content, + Metadata, + Notification, + OnClick, +} from '@uniswap/client-notification-service/dist/uniswap/notificationservice/v1/api_pb' +import { ContentStyle, type InAppNotification, OnClickAction } from '@universe/api' +import { type TriggerCondition } from '@universe/notifications/src/notification-data-source/implementations/createLocalTriggerDataSource' +import { type ExtensionState } from 'src/store/extensionReducer' +import { appRatingStateSelector } from 'wallet/src/features/appRating/selectors' +import { setAppRating } from 'wallet/src/features/wallet/slice' + +/** + * Unique ID for the app rating notification. + * Uses 'local:' prefix to distinguish from backend-generated notifications. + */ +export const APP_RATING_NOTIFICATION_ID = 'local:app_rating_modal' + +/** + * Context required to create the app rating trigger. + */ +interface CreateAppRatingTriggerContext { + /** Function to get the current Redux state */ + getState: () => ExtensionState + /** Redux dispatch function */ + dispatch: (action: ReturnType) => void +} + +/** + * Creates a trigger condition for the app rating modal. + * + * The trigger will show the modal when: + * - User has completed 2+ consecutive successful swaps within 1 minute + * - Either user has never been prompted, or enough time has passed since last prompt + * (120 days without feedback, 180 days with feedback) + * + * @see appRatingStateSelector for the full logic + */ +export function createAppRatingTrigger(ctx: CreateAppRatingTriggerContext): TriggerCondition { + const { getState, dispatch } = ctx + + return { + id: APP_RATING_NOTIFICATION_ID, + + shouldShow: () => { + const state = getState() + const { shouldPrompt } = appRatingStateSelector(state) + return shouldPrompt + }, + + createNotification: (): InAppNotification => { + // Create a minimal notification - the actual UI is rendered by AppRatingModalRenderer + return new Notification({ + id: APP_RATING_NOTIFICATION_ID, + metadata: new Metadata({ + owner: 'local', + business: 'app_rating', + }), + content: new Content({ + style: ContentStyle.MODAL, + title: '', // Title handled by AppRatingModal component + version: 0, + buttons: [], // Buttons handled by AppRatingModal component + // Required: notifications must have a DISMISS action to be valid + onDismissClick: new OnClick({ + onClick: [OnClickAction.DISMISS], + }), + }), + }) + }, + + onAcknowledge: () => { + // Update Redux to mark that the user has been prompted + // This is also done in AppRatingModal's useEffect, but we include it here + // for consistency with the trigger pattern + dispatch(setAppRating({})) + }, + } +} + +/** + * Type guard to check if a notification is the app rating notification. + * Used by NotificationContainer to route to the correct renderer. + */ +export function isAppRatingNotification(notification: InAppNotification): boolean { + return notification.id === APP_RATING_NOTIFICATION_ID +} diff --git a/apps/extension/src/notification-service/triggers/createExtensionLocalTriggerDataSource.ts b/apps/extension/src/notification-service/triggers/createExtensionLocalTriggerDataSource.ts new file mode 100644 index 00000000..3c566692 --- /dev/null +++ b/apps/extension/src/notification-service/triggers/createExtensionLocalTriggerDataSource.ts @@ -0,0 +1,70 @@ +import { + createLocalTriggerDataSource, + type TriggerCondition, +} from '@universe/notifications/src/notification-data-source/implementations/createLocalTriggerDataSource' +import { type NotificationDataSource } from '@universe/notifications/src/notification-data-source/NotificationDataSource' +import { type NotificationTracker } from '@universe/notifications/src/notification-tracker/NotificationTracker' +import { createAppRatingTrigger } from 'src/notification-service/triggers/appRatingTrigger' +import { type ExtensionState } from 'src/store/extensionReducer' +import { setAppRating } from 'wallet/src/features/wallet/slice' + +/** + * Context required to create the extension local trigger data source. + */ +interface CreateExtensionLocalTriggerDataSourceContext { + /** Function to get the current Redux state */ + getState: () => ExtensionState + /** Redux dispatch function */ + dispatch: (action: ReturnType) => void + /** Notification tracker for checking processed state */ + tracker: NotificationTracker + /** How often to check triggers in milliseconds (default: 5000ms) */ + pollIntervalMs?: number +} + +/** + * All trigger conditions for the extension. + * Add new triggers here as they are migrated. + */ +function getExtensionTriggers(ctx: { + getState: () => ExtensionState + dispatch: (action: ReturnType) => void +}): TriggerCondition[] { + return [ + createAppRatingTrigger(ctx), + // Future triggers can be added here: + // createSmartWalletCreatedTrigger(ctx), + // createSmartWalletNudgeTrigger(ctx), + // createSmartWalletEnabledTrigger(ctx), + ] +} + +/** + * Creates a data source for all extension local trigger notifications. + * + * This combines all extension-specific triggers (app rating, smart wallet nudges, etc.) + * into a single data source that can be added to the notification service. + */ +export function createExtensionLocalTriggerDataSource( + ctx: CreateExtensionLocalTriggerDataSourceContext, +): NotificationDataSource { + const { getState, dispatch, tracker, pollIntervalMs = 5000 } = ctx + + const triggers = getExtensionTriggers({ getState, dispatch }) + + return createLocalTriggerDataSource({ + triggers, + tracker, + pollIntervalMs, + source: 'extension_local_triggers', + logFileTag: 'createExtensionLocalTriggerDataSource', + }) +} + +/** + * Check if a notification ID is a local trigger notification. + * Local trigger notifications use the 'local:' prefix. + */ +export function isLocalTriggerNotification(notificationId: string): boolean { + return notificationId.startsWith('local:') +} diff --git a/apps/extension/src/public/assets/fonts/Basel-Book.woff b/apps/extension/src/public/assets/fonts/Basel-Book.woff new file mode 100644 index 00000000..7cfd4abb Binary files /dev/null and b/apps/extension/src/public/assets/fonts/Basel-Book.woff differ diff --git a/apps/extension/src/public/assets/fonts/Basel-Medium.woff b/apps/extension/src/public/assets/fonts/Basel-Medium.woff new file mode 100644 index 00000000..004a41fc Binary files /dev/null and b/apps/extension/src/public/assets/fonts/Basel-Medium.woff differ diff --git a/apps/extension/src/public/assets/fonts/Inter-normal.var.ttf b/apps/extension/src/public/assets/fonts/Inter-normal.var.ttf new file mode 100644 index 00000000..600b384a Binary files /dev/null and b/apps/extension/src/public/assets/fonts/Inter-normal.var.ttf differ diff --git a/apps/extension/src/public/assets/icons/icon128.png b/apps/extension/src/public/assets/icons/icon128.png new file mode 100644 index 00000000..e6562013 Binary files /dev/null and b/apps/extension/src/public/assets/icons/icon128.png differ diff --git a/apps/extension/src/public/assets/icons/icon16.png b/apps/extension/src/public/assets/icons/icon16.png new file mode 100644 index 00000000..281c3aa9 Binary files /dev/null and b/apps/extension/src/public/assets/icons/icon16.png differ diff --git a/apps/extension/src/public/assets/icons/icon32.png b/apps/extension/src/public/assets/icons/icon32.png new file mode 100644 index 00000000..1133b87b Binary files /dev/null and b/apps/extension/src/public/assets/icons/icon32.png differ diff --git a/apps/extension/src/public/assets/icons/icon48.png b/apps/extension/src/public/assets/icons/icon48.png new file mode 100644 index 00000000..c9526445 Binary files /dev/null and b/apps/extension/src/public/assets/icons/icon48.png differ diff --git a/apps/extension/src/public/assets/icons/lux-logo.svg b/apps/extension/src/public/assets/icons/lux-logo.svg new file mode 100644 index 00000000..49cf6030 --- /dev/null +++ b/apps/extension/src/public/assets/icons/lux-logo.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/extension/src/public/assets/index.ts b/apps/extension/src/public/assets/index.ts new file mode 100644 index 00000000..3f74421e --- /dev/null +++ b/apps/extension/src/public/assets/index.ts @@ -0,0 +1,3 @@ +/* oxlint-disable typescript/no-var-requires */ +export const ONBOARDING_BACKGROUND_LIGHT = require('./onboarding-background-light.png') +export const ONBOARDING_BACKGROUND_DARK = require('./onboarding-background-dark.png') diff --git a/apps/extension/src/public/assets/onboarding-background-dark.png b/apps/extension/src/public/assets/onboarding-background-dark.png new file mode 100644 index 00000000..d171047c Binary files /dev/null and b/apps/extension/src/public/assets/onboarding-background-dark.png differ diff --git a/apps/extension/src/public/assets/onboarding-background-light.png b/apps/extension/src/public/assets/onboarding-background-light.png new file mode 100644 index 00000000..9c1296b3 Binary files /dev/null and b/apps/extension/src/public/assets/onboarding-background-light.png differ diff --git a/apps/extension/src/publicAssetsByEnv/beta/icon128.png b/apps/extension/src/publicAssetsByEnv/beta/icon128.png new file mode 100644 index 00000000..3730b112 Binary files /dev/null and b/apps/extension/src/publicAssetsByEnv/beta/icon128.png differ diff --git a/apps/extension/src/publicAssetsByEnv/beta/icon16.png b/apps/extension/src/publicAssetsByEnv/beta/icon16.png new file mode 100644 index 00000000..acbe9bc4 Binary files /dev/null and b/apps/extension/src/publicAssetsByEnv/beta/icon16.png differ diff --git a/apps/extension/src/publicAssetsByEnv/beta/icon32.png b/apps/extension/src/publicAssetsByEnv/beta/icon32.png new file mode 100644 index 00000000..c5856450 Binary files /dev/null and b/apps/extension/src/publicAssetsByEnv/beta/icon32.png differ diff --git a/apps/extension/src/publicAssetsByEnv/beta/icon48.png b/apps/extension/src/publicAssetsByEnv/beta/icon48.png new file mode 100644 index 00000000..ef5c09ad Binary files /dev/null and b/apps/extension/src/publicAssetsByEnv/beta/icon48.png differ diff --git a/apps/extension/src/publicAssetsByEnv/beta/icon64.png b/apps/extension/src/publicAssetsByEnv/beta/icon64.png new file mode 100644 index 00000000..9e48b83a Binary files /dev/null and b/apps/extension/src/publicAssetsByEnv/beta/icon64.png differ diff --git a/apps/extension/src/publicAssetsByEnv/local/icon128.png b/apps/extension/src/publicAssetsByEnv/local/icon128.png new file mode 100644 index 00000000..5c0321dc Binary files /dev/null and b/apps/extension/src/publicAssetsByEnv/local/icon128.png differ diff --git a/apps/extension/src/publicAssetsByEnv/local/icon16.png b/apps/extension/src/publicAssetsByEnv/local/icon16.png new file mode 100644 index 00000000..97a3eaf2 Binary files /dev/null and b/apps/extension/src/publicAssetsByEnv/local/icon16.png differ diff --git a/apps/extension/src/publicAssetsByEnv/local/icon32.png b/apps/extension/src/publicAssetsByEnv/local/icon32.png new file mode 100644 index 00000000..b318ae9d Binary files /dev/null and b/apps/extension/src/publicAssetsByEnv/local/icon32.png differ diff --git a/apps/extension/src/publicAssetsByEnv/local/icon48.png b/apps/extension/src/publicAssetsByEnv/local/icon48.png new file mode 100644 index 00000000..d42343a3 Binary files /dev/null and b/apps/extension/src/publicAssetsByEnv/local/icon48.png differ diff --git a/apps/extension/src/publicAssetsByEnv/prod/icon128.png b/apps/extension/src/publicAssetsByEnv/prod/icon128.png new file mode 100644 index 00000000..e6562013 Binary files /dev/null and b/apps/extension/src/publicAssetsByEnv/prod/icon128.png differ diff --git a/apps/extension/src/publicAssetsByEnv/prod/icon16.png b/apps/extension/src/publicAssetsByEnv/prod/icon16.png new file mode 100644 index 00000000..281c3aa9 Binary files /dev/null and b/apps/extension/src/publicAssetsByEnv/prod/icon16.png differ diff --git a/apps/extension/src/publicAssetsByEnv/prod/icon32.png b/apps/extension/src/publicAssetsByEnv/prod/icon32.png new file mode 100644 index 00000000..1133b87b Binary files /dev/null and b/apps/extension/src/publicAssetsByEnv/prod/icon32.png differ diff --git a/apps/extension/src/publicAssetsByEnv/prod/icon48.png b/apps/extension/src/publicAssetsByEnv/prod/icon48.png new file mode 100644 index 00000000..c9526445 Binary files /dev/null and b/apps/extension/src/publicAssetsByEnv/prod/icon48.png differ diff --git a/apps/extension/src/publicAssetsByEnv/prod/icon64.png b/apps/extension/src/publicAssetsByEnv/prod/icon64.png new file mode 100644 index 00000000..800dd009 Binary files /dev/null and b/apps/extension/src/publicAssetsByEnv/prod/icon64.png differ diff --git a/apps/extension/src/store/PrimaryAppInstanceDebugger.tsx b/apps/extension/src/store/PrimaryAppInstanceDebugger.tsx new file mode 100644 index 00000000..f1984abc --- /dev/null +++ b/apps/extension/src/store/PrimaryAppInstanceDebugger.tsx @@ -0,0 +1,26 @@ +/* oxlint-disable react/forbid-elements */ +import { useIsPrimaryAppInstance } from 'src/store/storeSynchronization' + +// This is a dev-only component that renders a small green/red dot in the bottom right corner of the screen +// to indicate whether the current app instance is the primary one. +export default function PrimaryAppInstanceDebugger(): JSX.Element | null { + const isPrimaryAppInstance = useIsPrimaryAppInstance() + + return ( + // oxlint-disable-next-line react/forbid-elements -- needed here +
+ ) +} diff --git a/apps/extension/src/store/PrimaryAppInstanceDebuggerLazy.tsx b/apps/extension/src/store/PrimaryAppInstanceDebuggerLazy.tsx new file mode 100644 index 00000000..42ff1f81 --- /dev/null +++ b/apps/extension/src/store/PrimaryAppInstanceDebuggerLazy.tsx @@ -0,0 +1,7 @@ +import { lazy } from 'react' + +const PrimaryAppInstanceDebugger = lazy(() => import('src/store/PrimaryAppInstanceDebugger')) + +export function PrimaryAppInstanceDebuggerLazy(): JSX.Element | null { + return __DEV__ ? : null +} diff --git a/apps/extension/src/store/appStateResetter.test.tsx b/apps/extension/src/store/appStateResetter.test.tsx new file mode 100644 index 00000000..04085b6a --- /dev/null +++ b/apps/extension/src/store/appStateResetter.test.tsx @@ -0,0 +1,111 @@ +import { ApolloClient, InMemoryCache } from '@apollo/client' +import { configureStore } from '@reduxjs/toolkit' +import { QueryClient } from '@tanstack/react-query' +import { dappRequestActions } from 'src/app/features/dappRequests/slice' +import { createExtensionAppStateResetter } from 'src/store/appStateResetter' +import { type ExtensionState, extensionReducer } from 'src/store/extensionReducer' +import { DappRequestType } from 'uniswap/src/features/dappRequests/types' +import { pushNotification } from 'uniswap/src/features/notifications/slice/slice' +import { AppNotificationType } from 'uniswap/src/features/notifications/slice/types' + +const createMockApolloClient = (): ApolloClient => { + const client = new ApolloClient({ + cache: new InMemoryCache(), + }) + jest.spyOn(client, 'resetStore').mockResolvedValue([]) + return client +} + +const createMockQueryClient = (): QueryClient => { + const client = new QueryClient() + jest.spyOn(client, 'resetQueries').mockResolvedValue() + return client +} + +describe('createExtensionAppStateResetter', () => { + let store: ReturnType> + let apolloClient: ApolloClient + let queryClient: QueryClient + let resetter: ReturnType + + beforeEach(() => { + store = configureStore({ + reducer: extensionReducer, + }) + apolloClient = createMockApolloClient() + queryClient = createMockQueryClient() + resetter = createExtensionAppStateResetter({ + dispatch: store.dispatch, + apolloClient, + queryClient, + }) + jest.clearAllMocks() + }) + + describe('resetAccountHistory', () => { + it('dispatches extension-specific account history reset actions', async () => { + // Modify extension-specific state - add a dapp request + store.dispatch( + dappRequestActions.add({ + dappRequest: { + type: DappRequestType.SignMessage, + requestId: 'test-request-1', + messageHex: '0x123', + address: '0x123', + }, + senderTabInfo: { id: 1, url: 'https://test.com' }, + isSidebarClosed: false, + }), + ) + + // Verify state was modified + expect(Object.keys(store.getState().dappRequests.requests).length).toBe(1) + + await resetter.resetAccountHistory() + + // Verify extension-specific state was reset + const state = store.getState() + expect(Object.keys(state.dappRequests.requests).length).toBe(0) + }) + }) + + describe('resetUserSettings', () => { + it('completes without errors', async () => { + // Extension has no additional user settings to reset beyond base + await expect(resetter.resetUserSettings()).resolves.not.toThrow() + }) + }) + + describe('resetQueryCaches', () => { + it('clears Apollo and React Query caches', async () => { + await resetter.resetQueryCaches() + + // Verify cache clearing methods were called + expect(apolloClient.resetStore).toHaveBeenCalledTimes(1) + expect(queryClient.resetQueries).toHaveBeenCalledTimes(1) + }) + }) + + describe('resetAll', () => { + it('resets all state and clears all caches', async () => { + // Modify state first + store.dispatch( + pushNotification({ + type: AppNotificationType.Success, + title: 'Test notification', + }), + ) + + // Verify state was modified + expect(store.getState().notifications.notificationQueue.length).toBe(1) + + await resetter.resetAll() + + // Verify all resets worked + const state = store.getState() + expect(state.notifications.notificationQueue).toEqual([]) + expect(apolloClient.resetStore).toHaveBeenCalledTimes(1) + expect(queryClient.resetQueries).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/apps/extension/src/store/appStateResetter.tsx b/apps/extension/src/store/appStateResetter.tsx new file mode 100644 index 00000000..f23f017a --- /dev/null +++ b/apps/extension/src/store/appStateResetter.tsx @@ -0,0 +1,78 @@ +import { type ApolloClient, useApolloClient } from '@apollo/client' +import { type Dispatch } from '@reduxjs/toolkit' +import { type QueryClient, useQueryClient } from '@tanstack/react-query' +import { useMemo } from 'react' +import { useDispatch } from 'react-redux' +import { dappRequestActions } from 'src/app/features/dappRequests/slice' +import { resetAlerts } from 'src/app/features/onboarding/alerts/slice' +import { resetPopups } from 'src/app/features/popups/slice' +import { type AppStateResetter } from 'uniswap/src/state/createAppStateResetter' +import { createLogger } from 'utilities/src/logger/logger' +import { createWalletStateResetter } from 'wallet/src/state/createWalletStateResetter' + +/** + * Creates the extension app's state resetter instance. + * This wraps the base createAppStateResetter and adds extension-specific reset actions. + * + * @param apolloClient - Optional Apollo client for cache clearing. If not provided, cache clearing is skipped. + * @param queryClient - Optional React Query client for cache clearing. If not provided, cache clearing is skipped. + */ +export function createExtensionAppStateResetter({ + dispatch, + apolloClient, + queryClient, +}: { + dispatch: Dispatch + apolloClient?: ApolloClient + queryClient?: QueryClient +}): AppStateResetter { + const logger = createLogger('appResetter.tsx', 'createExtensionAppStateResetter') + + return createWalletStateResetter({ + dispatch, + + onResetAccountHistory: () => { + dispatch(dappRequestActions.reset()) + dispatch(resetPopups()) + dispatch(resetAlerts()) + }, + + onResetUserSettings: () => { + // No extension-specific settings resets are currently required + }, + + onResetQueryCaches: async () => { + const cachePromises: Promise[] = [] + if (apolloClient) { + cachePromises.push(apolloClient.resetStore().then(() => logger.info('Apollo cache cleared successfully'))) + } + if (queryClient) { + cachePromises.push(queryClient.resetQueries().then(() => logger.info('React Query cache cleared successfully'))) + } + if (cachePromises.length > 0) { + await Promise.all(cachePromises) + } + }, + }) +} + +export function useAppStateResetter(): AppStateResetter { + const dispatch = useDispatch() + const apolloClient = useApolloClient() + const queryClient = useQueryClient() + return useMemo( + () => createExtensionAppStateResetter({ dispatch, apolloClient, queryClient }), + [dispatch, apolloClient, queryClient], + ) +} + +/** + * Creates a resetter for use in crash recovery scenarios (e.g., error boundaries). + * This version does not require the Apollo context, which is not + * available when rendering the error fallback UI. + */ +export function useOnCrashAppStateResetter(): AppStateResetter { + const dispatch = useDispatch() + const queryClient = useQueryClient() + return useMemo(() => createExtensionAppStateResetter({ dispatch, queryClient }), [dispatch, queryClient]) +} diff --git a/apps/extension/src/store/constants.ts b/apps/extension/src/store/constants.ts new file mode 100644 index 00000000..68d633df --- /dev/null +++ b/apps/extension/src/store/constants.ts @@ -0,0 +1,2 @@ +export const PERSIST_KEY = 'root' +export const STATE_STORAGE_KEY = `persist:${PERSIST_KEY}` diff --git a/apps/extension/src/store/enhancePersistReducer.ts b/apps/extension/src/store/enhancePersistReducer.ts new file mode 100644 index 00000000..d745cb40 --- /dev/null +++ b/apps/extension/src/store/enhancePersistReducer.ts @@ -0,0 +1,46 @@ +import { Action, Reducer } from 'redux' +import { logger } from 'utilities/src/logger/logger' + +// We use `any` in a few places in this file because those values truly can be anything, so that's the proper type. + +// oxlint-disable-next-line typescript/no-explicit-any -- PersistPartial type allows any shape for redux-persist compatibility +type PersistPartial = { _persist: undefined } | any + +export function enhancePersistReducer( + reducer: Reducer, +): Reducer { + return forceRehydrationFromDiskWhenResumingPersistence(reducer) +} + +/** + * Whenever the `persist/PERSIST` action is dispatched, we reset the `_persist` state in order to trigger rehydration from disk + * regardless of whether it had already rehydrated during startup. + * + * Whenever another app becomes the primary instance, `storeSynchronization.ts` calls `persistor.pause()`, + * and then when this app becomes primary again we need to not only re-start persistance but also rehydrate from disk. + * We do this by calling `persistor.persist()`, which by default will just continue persisting and skip rehydration. + * This custom enhancer ensures that the `_persist` state is reset whenever the `persist/PERSIST` action is dispatched, + * so that the internal `redux-persist` logic will rehydrate from disk again. + * + * See relevat `redux-persist` code here: https://github.com/rt2zz/redux-persist/blob/9c0baee/src/persistReducer.ts#L110 + */ +function forceRehydrationFromDiskWhenResumingPersistence( + reducer: Reducer, +): Reducer { + return (state, action) => { + if (action.type !== 'persist/PERSIST') { + // oxlint-disable-next-line typescript/no-unsafe-return + return reducer(state, action) + } + + logger.debug('store-synchronization', 'enhancePersistReducer', 'Resetting redux _persist state') + + const newState = { + ...state, + _persist: undefined, + } + + // oxlint-disable-next-line typescript/no-unsafe-return + return reducer(newState, action) + } +} diff --git a/apps/extension/src/store/extensionMigrations.test.ts b/apps/extension/src/store/extensionMigrations.test.ts new file mode 100644 index 00000000..69270292 --- /dev/null +++ b/apps/extension/src/store/extensionMigrations.test.ts @@ -0,0 +1,189 @@ +/** + * Isolated tests for individual migration functions. + * + * Tests each migration independently with various input states, edge cases, + * and error handling, without relying on output from previous migrations. + * + * For tests of the full migration chain, see extensionMigrationsTests.ts. + */ +import { DappRequestStatus } from 'src/app/features/dappRequests/shared' +import { + migratePendingDappRequestsToRecord, + migrateUnknownBackupAccountsToMaybeManualBackup, + removeDappInfoToChromeLocalStorage, + setLanguageToNavigatorLanguage, +} from 'src/store/extensionMigrations' +import { Language } from 'uniswap/src/features/language/constants' +import { createThrowingProxy } from 'utilities/src/test/utils' + +describe('removeDappInfoToChromeLocalStorage', () => { + it('removes dapp from state', () => { + const state = { dapp: { someData: true }, otherData: 'preserved' } + const result = removeDappInfoToChromeLocalStorage(state) + expect(result.dapp).toBeUndefined() + expect(result.otherData).toBe('preserved') + }) + + it('handles state without dapp', () => { + const state = { otherData: 'preserved' } + const result = removeDappInfoToChromeLocalStorage(state) + expect(result.otherData).toBe('preserved') + }) +}) + +describe('migratePendingDappRequestsToRecord', () => { + it('migrates pending array to requests record', () => { + const state = { + dappRequests: { + pending: [ + { dappRequest: { requestId: 'req1', data: 'test1' } }, + { dappRequest: { requestId: 'req2', data: 'test2' } }, + ], + }, + } + const result = migratePendingDappRequestsToRecord(state) + expect(result.dappRequests.requests.req1).toBeDefined() + expect(result.dappRequests.requests.req1.status).toBe(DappRequestStatus.Pending) + expect(result.dappRequests.requests.req1.createdAt).toBeDefined() + expect(result.dappRequests.requests.req2).toBeDefined() + expect(result.dappRequests.pending).toBeUndefined() + }) + + it('returns state unchanged if no dappRequests', () => { + const state = { otherData: 'preserved' } + const result = migratePendingDappRequestsToRecord(state) + expect(result).toEqual(state) + }) + + it('returns state unchanged if already migrated (has requests)', () => { + const state = { + dappRequests: { + requests: { req1: { status: DappRequestStatus.Pending } }, + }, + } + const result = migratePendingDappRequestsToRecord(state) + expect(result).toEqual(state) + }) + + it('returns state unchanged if no pending array', () => { + const state = { dappRequests: {} } + const result = migratePendingDappRequestsToRecord(state) + expect(result).toEqual(state) + }) + + it('clears dappRequests if pending is not an array', () => { + const state = { dappRequests: { pending: 'invalid' } } + const result = migratePendingDappRequestsToRecord(state) + expect(result.dappRequests).toEqual({ requests: {} }) + }) + + it('skips invalid items in pending array', () => { + const state = { + dappRequests: { + pending: [ + null, + 'invalid', + { notDappRequest: true }, + { dappRequest: null }, + { dappRequest: { noRequestId: true } }, + { dappRequest: { requestId: 'validReq', data: 'test' } }, + ], + }, + } + const result = migratePendingDappRequestsToRecord(state) + expect(Object.keys(result.dappRequests.requests)).toEqual(['validReq']) + }) + + it('falls back to empty requests on error', () => { + const state = { + dappRequests: { + pending: createThrowingProxy([], { throwingMethods: ['forEach'] }), + }, + } + const result = migratePendingDappRequestsToRecord(state) + expect(result.dappRequests).toEqual({ requests: {} }) + }) +}) + +describe('migrateUnknownBackupAccountsToMaybeManualBackup', () => { + it('adds maybe-manual backup to accounts without backups', () => { + const state = { + wallet: { + accounts: { + '0x123': { address: '0x123' }, + '0x456': { address: '0x456', backups: [] }, + }, + }, + } + const result = migrateUnknownBackupAccountsToMaybeManualBackup(state) + expect(result.wallet.accounts['0x123'].backups).toEqual(['maybe-manual']) + expect(result.wallet.accounts['0x456'].backups).toEqual(['maybe-manual']) + }) + + it('preserves existing backups', () => { + const state = { + wallet: { + accounts: { + '0x123': { address: '0x123', backups: ['cloud'] }, + }, + }, + } + const result = migrateUnknownBackupAccountsToMaybeManualBackup(state) + expect(result.wallet.accounts['0x123'].backups).toEqual(['cloud']) + }) + + it('returns state unchanged if no wallet', () => { + const state = { otherData: 'preserved' } + const result = migrateUnknownBackupAccountsToMaybeManualBackup(state) + expect(result).toEqual(state) + }) + + it('returns state unchanged if no accounts', () => { + const state = { wallet: { otherData: 'preserved' } } + const result = migrateUnknownBackupAccountsToMaybeManualBackup(state) + expect(result).toEqual(state) + }) + + it('returns state unchanged if accounts is not an object', () => { + const state = { wallet: { accounts: 'invalid' } } + const result = migrateUnknownBackupAccountsToMaybeManualBackup(state) + expect(result).toEqual(state) + }) + + it('skips non-object accounts', () => { + const state = { + wallet: { + accounts: { + '0x123': 'invalid', + '0x456': { address: '0x456' }, + }, + }, + } + const result = migrateUnknownBackupAccountsToMaybeManualBackup(state) + expect(result.wallet.accounts['0x123']).toBeUndefined() + expect(result.wallet.accounts['0x456'].backups).toEqual(['maybe-manual']) + }) +}) + +describe('setLanguageToNavigatorLanguage', () => { + it('sets language from navigator', () => { + const state = { userSettings: { otherSetting: true } } + const result = setLanguageToNavigatorLanguage(state) + expect(result.userSettings.currentLanguage).toBeDefined() + expect(result.userSettings.otherSetting).toBe(true) + }) + + it('returns state unchanged if no userSettings', () => { + const state = { otherData: 'preserved' } + const result = setLanguageToNavigatorLanguage(state) + expect(result).toEqual(state) + }) + + it('falls back to English on error', () => { + const state = { + userSettings: createThrowingProxy({}, { throwingMethods: ['*'] }), + } + const result = setLanguageToNavigatorLanguage(state) + expect(result.userSettings.currentLanguage).toBe(Language.English) + }) +}) diff --git a/apps/extension/src/store/extensionMigrations.ts b/apps/extension/src/store/extensionMigrations.ts new file mode 100644 index 00000000..c3e97cb1 --- /dev/null +++ b/apps/extension/src/store/extensionMigrations.ts @@ -0,0 +1,128 @@ +import { DappRequestStatus } from 'src/app/features/dappRequests/shared' +import type { DappRequestState } from 'src/app/features/dappRequests/slice' +import { Language } from 'uniswap/src/features/language/constants' +import { getCurrentLanguageFromNavigator } from 'uniswap/src/features/language/utils' +import { createSafeMigrationFactory } from 'uniswap/src/state/createSafeMigration' +import { type BackupType } from 'wallet/src/features/wallet/accounts/types' + +const createSafeMigration = createSafeMigrationFactory('extensionMigrations') + +export function removeDappInfoToChromeLocalStorage({ dapp: _dapp, ...state }: any): any { + return state +} + +// migrates pending dapp requests array without status or timestamp to a record with status (pending|confirming) and timestamp +export const migratePendingDappRequestsToRecord = createSafeMigration({ + name: 'migratePendingDappRequestsToRecord', + migrate: (state: any) => { + // If there's no dappRequests state or it's already in the new format, return unchanged + if (!state?.dappRequests || !state.dappRequests.pending || state.dappRequests.requests) { + return state + } + + // Create new record object to hold requests + const requests: DappRequestState['requests'] = {} + + const pending = state.dappRequests.pending + if (!Array.isArray(pending)) { + // If pending is not an array, just clear the dapp requests + return { + ...state, + dappRequests: { requests: {} }, + } + } + + // Convert each pending request to the record format with status + pending.forEach((item: unknown, index: number) => { + if ( + item !== null && + typeof item === 'object' && + 'dappRequest' in item && + typeof item.dappRequest === 'object' && + item.dappRequest !== null && + 'requestId' in item.dappRequest && + typeof item.dappRequest.requestId === 'string' + ) { + const updatedRequest = { + ...item, + // Map to new structure with status and timestamp + status: DappRequestStatus.Pending, + createdAt: Date.now() + index * 1000, // Add timestamp for sorting + } as DappRequestState['requests'][string] + + requests[item.dappRequest.requestId] = updatedRequest + } + }) + + // Return state with updated dappRequests slice + return { + ...state, + dappRequests: { + requests, + }, + } + }, + onError: (state: any) => ({ + ...state, + dappRequests: { requests: {} }, + }), +}) + +// Migrates accounts with no backup method to have `maybe-manual` backup method. +// Before this migration, we were not setting the backup method on accounts created during Extension onboarding, +// so we're unsure if the user completed the backup flow during onboarding or if they hit "Skip". +export function migrateUnknownBackupAccountsToMaybeManualBackup(state: any): any { + if (!state?.wallet?.accounts || typeof state.wallet.accounts !== 'object') { + return state + } + + // Update each account to have manual backup + const updatedAccounts = Object.entries(state.wallet.accounts as Record).reduce( + (acc, [address, account]) => { + // Skip if not an object + if (typeof account !== 'object') { + return acc + } + + acc[address] = { + ...account, + // Add manual backup if backups array doesn't exist or is empty + backups: account.backups?.length ? account.backups : ['maybe-manual'], + } + return acc + }, + {} as Record, + ) + + return { + ...state, + wallet: { + ...state.wallet, + accounts: updatedAccounts, + }, + } +} + +export const setLanguageToNavigatorLanguage = createSafeMigration({ + name: 'setLanguageToNavigatorLanguage', + migrate: (state: any) => { + if (!state?.userSettings) { + return state + } + + return { + ...state, + userSettings: { + ...state.userSettings, + currentLanguage: getCurrentLanguageFromNavigator(), + }, + } + }, + onError: (state: any) => ({ + ...state, + userSettings: { + ...(state?.userSettings ?? {}), + currentLanguage: Language.English, + }, + }), +}) diff --git a/apps/extension/src/store/extensionMigrationsTests.ts b/apps/extension/src/store/extensionMigrationsTests.ts new file mode 100644 index 00000000..e51dcb09 --- /dev/null +++ b/apps/extension/src/store/extensionMigrationsTests.ts @@ -0,0 +1,168 @@ +/** + * Test helpers for testing migrations run in sequence. + * + * Called by migrations.test.ts to verify migrations work correctly with realistic + * data that has passed through all prior migrations in the chain. + * + * For unit tests of individual migrations, see extensionMigrations.test.ts. + */ +import { createThrowingProxy } from 'utilities/src/test/utils' + +export function testRemoveDappInfoToChromeLocalStorage(migration: (state: any) => any, _prevSchema: any): void { + // Test: removes dapp property from state + const result = migration({ + dapp: { someData: 'value' }, + otherData: 'preserved', + }) + + expect(result.dapp).toBeUndefined() + expect(result.otherData).toBe('preserved') + + // Test: handles state without dapp property + const resultWithoutDapp = migration({ + otherData: 'preserved', + }) + + expect(resultWithoutDapp.dapp).toBeUndefined() + expect(resultWithoutDapp.otherData).toBe('preserved') +} + +export function testMigratePendingDappRequestsToRecord(migration: (state: any) => any, _prevSchema: any): void { + // Test: empty pending → empty requests + expect( + migration({ + dappRequests: { pending: [] }, + otherData: 'value', + }), + ).toEqual({ + dappRequests: { requests: {} }, + otherData: 'value', + }) + + // Test: sets sequential timestamps + const mockTime = 1000 + jest.spyOn(Date, 'now').mockReturnValue(mockTime) + + const timestampResult = migration({ + dappRequests: { + pending: [0, 1, 2].map((i) => ({ dappRequest: { requestId: `r${i}` } })), + }, + }) + + expect(timestampResult.dappRequests.requests.r0.createdAt).toBe(mockTime) + expect(timestampResult.dappRequests.requests.r1.createdAt).toBe(mockTime + 1000) + expect(timestampResult.dappRequests.requests.r2.createdAt).toBe(mockTime + 2000) + + jest.restoreAllMocks() + + // Test: preserves data and handles missing IDs + const mockData = { + dappRequests: { + pending: [ + { dappRequest: { type: 'Missing' } }, // Missing ID + { dappRequest: { requestId: 'id', data: { x: 1 } }, meta: 'kept' }, + ], + }, + } + + const dataResult = migration(mockData) + + // Verify only valid request exists and contains original data + expect(Object.keys(dataResult.dappRequests.requests)).toEqual(['id']) + expect(dataResult.dappRequests.requests.id).toMatchObject({ + dappRequest: { requestId: 'id', data: { x: 1 } }, + meta: 'kept', + createdAt: expect.any(Number), + }) + + // Test: fallback on error - uses a throwing proxy to trigger catch block + const errorResult = migration({ + dappRequests: { pending: createThrowingProxy([], { throwingMethods: ['forEach'] }) }, + otherData: 'preserved', + }) + expect(errorResult.dappRequests).toEqual({ requests: {} }) + expect(errorResult.otherData).toBe('preserved') +} + +export function testMigrateUnknownBackupAccountsToMaybeManualBackup( + migration: (state: any) => any, + _prevSchema: any, +): void { + // it should migrate all accounts to manual backup when the account has an empty backups array + const migration1 = migration({ + wallet: { + accounts: { + '0x1': { + address: '0x1', + backups: [], + }, + '0x2': { + address: '0x2', + backups: [], + }, + }, + }, + }) + + expect(migration1.wallet.accounts['0x1'].backups).toEqual(['maybe-manual']) + expect(migration1.wallet.accounts['0x2'].backups).toEqual(['maybe-manual']) + + // it should migrate all accounts to manual backup when the account has a no backups property + const migration2 = migration({ + wallet: { + accounts: { + '0x1': { + address: '0x1', + backups: undefined, + }, + '0x2': { + address: '0x2', + backups: undefined, + }, + }, + }, + }) + + expect(migration2.wallet.accounts['0x1'].backups).toEqual(['maybe-manual']) + expect(migration2.wallet.accounts['0x2'].backups).toEqual(['maybe-manual']) + + // it should not migrate accounts to manual backup when the account has a backups property + const migration3 = migration({ + wallet: { + accounts: { + '0x1': { + address: '0x1', + backups: ['cloud'], + }, + '0x2': { + address: '0x2', + backups: ['cloud'], + }, + }, + }, + }) + + expect(migration3.wallet.accounts['0x1'].backups).toEqual(['cloud']) + expect(migration3.wallet.accounts['0x2'].backups).toEqual(['cloud']) +} + +export function testSetLanguageToNavigatorLanguage(migration: (state: any) => any, _prevSchema: any): void { + // Test: sets language when userSettings exists + const result = migration({ + userSettings: { + currentLanguage: 'es', + otherSetting: 'preserved', + }, + }) + + expect(result.userSettings.currentLanguage).toBeDefined() + expect(result.userSettings.otherSetting).toBe('preserved') + + // Test: returns state unchanged when userSettings doesn't exist + const resultWithoutUserSettings = migration({ + otherData: 'preserved', + }) + + expect(resultWithoutUserSettings.userSettings).toBeUndefined() + expect(resultWithoutUserSettings.otherData).toBe('preserved') +} diff --git a/apps/extension/src/store/extensionReducer.ts b/apps/extension/src/store/extensionReducer.ts new file mode 100644 index 00000000..420e4740 --- /dev/null +++ b/apps/extension/src/store/extensionReducer.ts @@ -0,0 +1,28 @@ +import { combineReducers } from 'redux' +import { PersistState } from 'redux-persist' +import { dappRequestReducer } from 'src/app/features/dappRequests/slice' +import { alertsReducer } from 'src/app/features/onboarding/alerts/slice' +import { popupsReducer } from 'src/app/features/popups/slice' +import { monitoredSagaReducers } from 'src/app/saga' +import { walletPersistedStateList, walletReducers } from 'wallet/src/state/walletReducer' + +const extensionReducers = { + ...walletReducers, + saga: monitoredSagaReducers, + dappRequests: dappRequestReducer, + popups: popupsReducer, + alerts: alertsReducer, +} as const + +export const extensionReducer = combineReducers(extensionReducers) + +// Only include here things that need to be persisted and shared between different instances of the sidebar. +// Only one sidebar can write to the storage at a time, so we need to be careful about what we persist. +// Things that only belong to a single instance of the sidebar (for example, dapp requests) should not be whitelisted. +export const extensionPersistedStateList: Array = [ + ...walletPersistedStateList, + 'dappRequests', + 'alerts', +] + +export type ExtensionState = ReturnType & { _persist?: PersistState } diff --git a/apps/extension/src/store/migrations.test.ts b/apps/extension/src/store/migrations.test.ts new file mode 100644 index 00000000..d90570a9 --- /dev/null +++ b/apps/extension/src/store/migrations.test.ts @@ -0,0 +1,383 @@ +/* oxlint-disable jest/expect-expect */ +import { BigNumber } from '@ethersproject/bignumber' +import { toIncludeSameMembers } from 'jest-extended' +import { + testMigratePendingDappRequestsToRecord, + testMigrateUnknownBackupAccountsToMaybeManualBackup, + testRemoveDappInfoToChromeLocalStorage, + testSetLanguageToNavigatorLanguage, +} from 'src/store/extensionMigrationsTests' +import { EXTENSION_STATE_VERSION, migrations } from 'src/store/migrations' +import { + getSchema, + initialSchema, + v0Schema, + v1Schema, + v2Schema, + v3Schema, + v4Schema, + v5Schema, + v6Schema, + v7Schema, + v8Schema, + v9Schema, + v10Schema, + v11Schema, + v12Schema, + v13Schema, + v14Schema, + v15Schema, + v16Schema, + v17Schema, + v18Schema, + v19Schema, + v20Schema, + v21Schema, + v22Schema, + v23Schema, + v24Schema, + v25Schema, + v26Schema, + v27Schema, + v29Schema, + v30Schema, +} from 'src/store/schema' +import { USDC } from 'uniswap/src/constants/tokens' +import { initialAppearanceSettingsState } from 'uniswap/src/features/appearance/slice' +import { initialUniswapBehaviorHistoryState } from 'uniswap/src/features/behaviorHistory/slice' +import { UniverseChainId } from 'uniswap/src/features/chains/types' +import { initialFavoritesState } from 'uniswap/src/features/favorites/slice' +import { FiatCurrency } from 'uniswap/src/features/fiatCurrency/constants' +import { initialNotificationsState } from 'uniswap/src/features/notifications/slice/slice' +import { initialSearchHistoryState } from 'uniswap/src/features/search/searchHistorySlice' +import { initialUserSettingsState } from 'uniswap/src/features/settings/slice' +import { initialTokensState } from 'uniswap/src/features/tokens/warnings/slice/slice' +import { initialTransactionsState } from 'uniswap/src/features/transactions/slice' +import { TransactionStatus, TransactionType } from 'uniswap/src/features/transactions/types/transactionDetails' +import { initialVisibilityState } from 'uniswap/src/features/visibility/slice' +import { + testAddActivityVisibility, + testMigrateDismissedTokenWarnings, + testMigrateSearchHistory, + testRemoveTHBFromCurrency, +} from 'uniswap/src/state/uniswapMigrationTests' +import { getAllKeysOfNestedObject } from 'utilities/src/primitives/objects' +import { initialBatchedTransactionsState } from 'wallet/src/features/batchedTransactions/slice' +import { initialBehaviorHistoryState } from 'wallet/src/features/behaviorHistory/slice' +import { initialWalletState } from 'wallet/src/features/wallet/slice' +import { createMigrate } from 'wallet/src/state/createMigrate' +import { HAYDEN_ETH_ADDRESS } from 'wallet/src/state/walletMigrations' +import { + testActivatePendingAccounts, + testAddBatchedTransactions, + testAddCreatedOnboardingRedesignAccount, + testAddedHapticSetting, + testDeleteWelcomeWalletCard, + testMigrateLiquidityTransactionInfoRename, + testMovedCurrencySetting, + testMovedLanguageSetting, + testMovedTokenWarnings, + testMovedUserSettings, + testMoveHapticsToUserSettings, + testMoveTokenAndNFTVisibility, + testRemoveCreatedOnboardingRedesignAccount, + testRemoveHoldToSwap, + testUnchecksumDismissedTokenWarningKeys, + testUpdateExploreOrderByType, +} from 'wallet/src/state/walletMigrationsTests' + +expect.extend({ toIncludeSameMembers }) + +describe('Redux state migrations', () => { + it('is able to perform all migrations starting from the initial schema', async () => { + const initialSchemaStub = { + ...initialSchema, + _persist: { version: -1, rehydrated: false }, + } + + const migrate = createMigrate(migrations) + const migratedSchema = await migrate(initialSchemaStub, EXTENSION_STATE_VERSION) + expect(typeof migratedSchema).toBe('object') + }) + + // If this test fails then it's likely a required property was added to the Redux state but a migration was not defined + it('migrates all the properties correctly', async () => { + const initialSchemaStub = { + ...initialSchema, + _persist: { version: -1, rehydrated: false }, + } + + const migrate = createMigrate(migrations) + const migratedSchema = await migrate(initialSchemaStub, EXTENSION_STATE_VERSION) + + // Add new slices here! + const initialState = { + appearanceSettings: initialAppearanceSettingsState, + dappRequests: { + requests: {}, + }, + batchedTransactions: initialBatchedTransactionsState, + blocks: { byChainId: {} }, + chains: { + byChainId: { + '1': { isActive: true }, + '10': { isActive: true }, + '137': { isActive: true }, + '42161': { isActive: true }, + }, + }, + dapp: {}, + ens: { ensForAddress: {} }, + favorites: initialFavoritesState, + fiatCurrencySettings: { currentCurrency: FiatCurrency.UnitedStatesDollar }, + notifications: initialNotificationsState, + behaviorHistory: initialBehaviorHistoryState, + providers: { isInitialized: false }, + saga: {}, + searchHistory: initialSearchHistoryState, + tokenLists: {}, + tokens: initialTokensState, + transactions: initialTransactionsState, + uniswapBehaviorHistory: initialUniswapBehaviorHistoryState, + userSettings: initialUserSettingsState, + visibility: initialVisibilityState, + wallet: initialWalletState, + _persist: { + version: EXTENSION_STATE_VERSION, + rehydrated: true, + }, + } + + const migratedSchemaKeys = new Set(getAllKeysOfNestedObject(migratedSchema as Record)) + const latestSchemaKeys = new Set(getAllKeysOfNestedObject(getSchema())) + const initialStateKeys = new Set(getAllKeysOfNestedObject(initialState)) + + for (const key of initialStateKeys) { + if (latestSchemaKeys.has(key)) { + latestSchemaKeys.delete(key) + } + if (migratedSchemaKeys.has(key)) { + migratedSchemaKeys.delete(key) + } + initialStateKeys.delete(key) + } + + expect(migratedSchemaKeys.size).toBe(0) + expect(latestSchemaKeys.size).toBe(0) + expect(initialStateKeys.size).toBe(0) + }) + + // This is a precaution to ensure we do not attempt to access undefined properties during migrations + // If this test fails, make sure all property references to state are using optional chaining + it('uses optional chaining when accessing old state variables', async () => { + const emptyStub = { _persist: { version: -1, rehydrated: false } } + + const migrate = createMigrate(migrations) + const migratedSchema = await migrate(emptyStub, EXTENSION_STATE_VERSION) + expect(typeof migratedSchema).toBe('object') + }) + + it('migrates from initial schema to v0', () => { + const stub = { ...initialSchema } + const v0 = migrations[0](stub) + + expect(v0.wallet.isUnlocked).toBe(undefined) + }) + + it('migrates from v0 to v1', () => { + const v0Stub = { ...v0Schema } + const v1 = migrations[1](v0Stub) + + expect(v1.behaviorHistory.hasViewedUniconV2IntroModal).toBe(undefined) + }) + + it('migrates from v1 to v2', () => { + const TEST_ADDRESS = '0xTestAddress' + const txDetails0 = { + chainId: UniverseChainId.Mainnet, + id: '0', + from: '0xTestAddress', + options: { + request: { + from: '0x123', + to: '0x456', + value: '0x0', + data: '0x789', + nonce: 10, + gasPrice: BigNumber.from('10000'), + }, + }, + typeInfo: { + type: TransactionType.Approve, + tokenAddress: '0xtokenAddress', + spender: '0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45', + }, + status: TransactionStatus.Pending, + addedTime: 1487076708000, + hash: '0x123', + } + + const txDetails1 = { + ...txDetails0, + chainId: UniverseChainId.Optimism, + id: '1', + } + + const transactions = { + [TEST_ADDRESS]: { + [UniverseChainId.Mainnet]: { + '0': txDetails0, + }, + [UniverseChainId.Optimism]: { + '1': txDetails1, + }, + }, + } + + const v0stub = { ...v1Schema, transactions } + + const v64 = migrations[2](v0stub) + + expect(v64.transactions[TEST_ADDRESS][UniverseChainId.Mainnet]['0'].routing).toBe('CLASSIC') + expect(v64.transactions[TEST_ADDRESS][UniverseChainId.Optimism]['1'].routing).toBe('CLASSIC') + }) + + it('migrates from v2 to v3', () => { + testActivatePendingAccounts(migrations[3], v2Schema) + }) + + it('migrates from v3 to v4', async () => { + testRemoveDappInfoToChromeLocalStorage(migrations[4], v3Schema) + }) + + it('migrates from v4 to v5', async () => { + const v4Stub = { ...v4Schema } + const v5 = await migrations[5](v4Stub) + expect(v5.behaviorHistory.extensionBetaFeedbackState).toBe(undefined) + }) + + it('migrates from v5 to v6', async () => { + const v5Stub = { ...v5Schema } + const v6 = await migrations[6](v5Stub) + expect(v6.behaviorHistory.extensionOnboardingState).toBe(undefined) + }) + + it('migrates from v6 to v7', async () => { + const v6Stub = { ...v6Schema } + v6Stub.favorites.watchedAddresses = [HAYDEN_ETH_ADDRESS] as never + const v7 = await migrations[7](v6Stub) + expect(v7.favorites.watchedAddresses).toEqual([]) + }) + + it('migrates from v7 to v8', async () => { + testAddedHapticSetting(migrations[8], v7Schema) + }) + + it('migrates from v8 to v9', async () => { + const v8Stub = { ...v8Schema } + const v9 = await migrations[9](v8Stub) + + expect(v9.behaviorHistory.hasUsedExplore).toBe(false) + expect(v9.behaviorHistory.hasViewedWelcomeWalletCard).toBe(false) + }) + + it('migrates from v9 to v10', async () => { + testMovedUserSettings(migrations[10], v9Schema) + }) + + it('migrates from v10 to v11', async () => { + testRemoveHoldToSwap(migrations[11], v10Schema) + }) + + it('migrates from v11 to v12', async () => { + testAddCreatedOnboardingRedesignAccount(migrations[12], v11Schema) + }) + + it('migrates from v12 to v13', async () => { + testMovedTokenWarnings(migrations[13], v12Schema) + }) + + it('migrates from v13 to v14', async () => { + testMovedLanguageSetting(migrations[14], v13Schema) + }) + + it('migrates from v14 to v15', async () => { + testMovedCurrencySetting(migrations[15], v14Schema) + }) + + it('migrates from v15 to v16', async () => { + testUpdateExploreOrderByType(migrations[16], v15Schema) + }) + + it('migrates from v16 to v17', async () => { + testRemoveCreatedOnboardingRedesignAccount(migrations[17], v16Schema) + }) + + it('migrates from v17 to v18', () => { + testUnchecksumDismissedTokenWarningKeys(migrations[18], v17Schema) + }) + + it('migrates from v18 to v19', () => { + testDeleteWelcomeWalletCard(migrations[19], v18Schema) + }) + + it('migrates from v19 to v20', () => { + testMoveTokenAndNFTVisibility(migrations[20], v19Schema) + }) + + it('migrates from v20 to v21', () => { + testMigratePendingDappRequestsToRecord(migrations[21], v20Schema) + }) + + it('migrates from v21 to v22', () => { + testAddBatchedTransactions(migrations[22], v21Schema) + }) + + it('migrates from v22 to v23', () => { + testMigrateUnknownBackupAccountsToMaybeManualBackup(migrations[23], v22Schema) + }) + + it('migrates from v23 to v24', () => { + testMoveHapticsToUserSettings(migrations[24], v23Schema) + }) + + it('migrates from v24 to v25', () => { + const v24Stub = { ...v24Schema, userSettings: { ...v24Schema.userSettings, currentCurrency: 'THB' } } + testRemoveTHBFromCurrency(migrations[25], v24Stub) + + const v24Stub2 = { ...v24Schema, userSettings: { ...v24Schema.userSettings, currentCurrency: 'JPY' } } + testRemoveTHBFromCurrency(migrations[25], v24Stub2) + }) + + it('migrates from v25 to v26', () => { + testMigrateLiquidityTransactionInfoRename(migrations[26], v25Schema) + }) + + it('migrates from v26 to v27', () => { + testMigrateSearchHistory(migrations[27], v26Schema) + }) + + it('migrates from v27 to v29', () => { + testAddActivityVisibility(migrations[29], v27Schema) + }) + + it('migrates from v29 to v30', () => { + testMigrateDismissedTokenWarnings(migrations[30], { + ...v29Schema, + tokens: { + dismissedTokenWarnings: { + [UniverseChainId.Mainnet]: { + [USDC.address]: { + chainId: UniverseChainId.Mainnet, + address: USDC.address, + }, + }, + }, + }, + }) + }) + + it('migrates from v30 to v31', () => { + testSetLanguageToNavigatorLanguage(migrations[31], v30Schema) + }) +}) diff --git a/apps/extension/src/store/migrations.ts b/apps/extension/src/store/migrations.ts new file mode 100644 index 00000000..1eb98c36 --- /dev/null +++ b/apps/extension/src/store/migrations.ts @@ -0,0 +1,78 @@ +/* oxlint-disable typescript/no-explicit-any -- Migration functions handle arbitrary state shapes from different versions */ +/* oxlint-disable typescript/explicit-function-return-type */ + +import { + migratePendingDappRequestsToRecord, + migrateUnknownBackupAccountsToMaybeManualBackup, + removeDappInfoToChromeLocalStorage, + setLanguageToNavigatorLanguage, +} from 'src/store/extensionMigrations' +import { + addActivityVisibility, + addDismissedBridgedAndCompatibleWarnings, + migrateDismissedTokenWarnings, + migrateSearchHistory, + removeThaiBahtFromFiatCurrency, + unchecksumDismissedTokenWarningKeys, +} from 'uniswap/src/state/uniswapMigrations' +import { + activatePendingAccounts, + addBatchedTransactions, + addCreatedOnboardingRedesignAccountBehaviorHistory, + addExploreAndWelcomeBehaviorHistory, + addHapticSetting, + addRoutingFieldToTransactions, + deleteBetaOnboardingState, + deleteDefaultFavoritesFromFavoritesState, + deleteExtensionOnboardingState, + deleteHoldToSwapBehaviorHistory, + deleteWelcomeWalletCardBehaviorHistory, + migrateLiquidityTransactionInfo, + moveCurrencySetting, + moveDismissedTokenWarnings, + moveHapticsToUserSettings, + moveLanguageSetting, + moveTokenAndNFTVisibility, + moveUserSettings, + removeCreatedOnboardingRedesignAccountBehaviorHistory, + removeUniconV2BehaviorState, + removeWalletIsUnlockedState, + updateExploreOrderByType, +} from 'wallet/src/state/walletMigrations' + +export const migrations = { + 0: removeWalletIsUnlockedState, + 1: removeUniconV2BehaviorState, + 2: addRoutingFieldToTransactions, + 3: activatePendingAccounts, + 4: removeDappInfoToChromeLocalStorage, + 5: deleteBetaOnboardingState, + 6: deleteExtensionOnboardingState, + 7: deleteDefaultFavoritesFromFavoritesState, + 8: addHapticSetting, + 9: addExploreAndWelcomeBehaviorHistory, + 10: moveUserSettings, + 11: deleteHoldToSwapBehaviorHistory, + 12: addCreatedOnboardingRedesignAccountBehaviorHistory, + 13: moveDismissedTokenWarnings, + 14: moveLanguageSetting, + 15: moveCurrencySetting, + 16: updateExploreOrderByType, + 17: removeCreatedOnboardingRedesignAccountBehaviorHistory, + 18: unchecksumDismissedTokenWarningKeys, + 19: deleteWelcomeWalletCardBehaviorHistory, + 20: moveTokenAndNFTVisibility, + 21: migratePendingDappRequestsToRecord, + 22: addBatchedTransactions, + 23: migrateUnknownBackupAccountsToMaybeManualBackup, + 24: moveHapticsToUserSettings, + 25: removeThaiBahtFromFiatCurrency, + 26: migrateLiquidityTransactionInfo, + 27: migrateSearchHistory, + 28: addDismissedBridgedAndCompatibleWarnings, + 29: addActivityVisibility, + 30: migrateDismissedTokenWarnings, + 31: setLanguageToNavigatorLanguage, +} + +export const EXTENSION_STATE_VERSION = 31 diff --git a/apps/extension/src/store/schema.ts b/apps/extension/src/store/schema.ts new file mode 100644 index 00000000..0e14d98e --- /dev/null +++ b/apps/extension/src/store/schema.ts @@ -0,0 +1,287 @@ +import { RankingType } from '@universe/api' + +// only add fields that are persisted +export const initialSchema = { + dapp: {}, + dappRequests: { + pending: [], + }, + favorites: { + tokens: [], + watchedAddresses: [], + tokensVisibility: {}, + nftsVisibility: {}, + }, + notifications: { + notificationQueue: [], + notificationStatus: {}, + lastTxNotificationUpdate: {}, + }, + saga: {}, + tokens: { + dismissedWarningTokens: {}, + }, + transactions: {}, + wallet: { + accounts: {}, + activeAccountAddress: null, + hardwareDevices: [], + isUnlocked: false, + settings: { + swapProtection: 'on', + hideSmallBalances: true, + hideSpamTokens: true, + }, + }, + searchHistory: { + results: [], + }, + appearanceSettings: { + selectedAppearanceSettings: 'system', + }, + languageSettings: { + currentLanguage: 'en', + }, + fiatCurrencySettings: { + currentCurrency: 'USD', + }, + behaviorHistory: { + hasViewedConnectionMigration: false, + hasViewedReviewScreen: false, + hasSubmittedHoldToSwap: false, + hasSkippedUnitagPrompt: false, + hasCompletedUnitagsIntroModal: false, + extensionOnboardingState: 0, + }, + batchedTransactions: {}, +} + +const v0SchemaIntermediate = { + ...initialSchema, + wallet: { + ...initialSchema.wallet, + isUnlocked: undefined, + }, +} + +// We will no longer keep track of this in the redux state. +delete v0SchemaIntermediate.wallet.isUnlocked + +export const v0Schema = v0SchemaIntermediate + +const v1SchemaIntermediate = { + ...v0Schema, + behaviorHistory: { + ...v0Schema.behaviorHistory, + hasViewedUniconV2IntroModal: undefined, + }, +} + +delete v1SchemaIntermediate.behaviorHistory.hasViewedUniconV2IntroModal + +export const v1Schema = v1SchemaIntermediate +export const v2Schema = { ...v1Schema } +export const v3Schema = { ...v2Schema } + +const v4SchemaIntermediate = { + ...v3Schema, + dapp: undefined, +} + +delete v4SchemaIntermediate.dapp + +export const v4Schema = v4SchemaIntermediate + +const v5SchemaIntermediate = { + ...v4Schema, + behaviorHistory: { + ...v4Schema.behaviorHistory, + extensionBetaFeedbackState: undefined, + }, +} + +delete v5SchemaIntermediate.behaviorHistory.extensionBetaFeedbackState + +export const v5Schema = v5SchemaIntermediate + +const v6SchemaIntermediate = { + ...v5Schema, + behaviorHistory: { + ...v5Schema.behaviorHistory, + extensionOnboardingState: undefined, + }, +} +delete v6SchemaIntermediate.behaviorHistory.extensionOnboardingState +export const v6Schema = v6SchemaIntermediate + +export const v7Schema = { ...v6Schema } + +export const v8Schema = { + ...v7Schema, + appearanceSettings: { + ...v7Schema.appearanceSettings, + hapticsEnabled: true, + }, +} + +export const v9Schema = { + ...v8Schema, + behaviorHistory: { ...v8Schema.behaviorHistory, hasViewedWelcomeWalletCard: false, hasUsedExplore: false }, +} + +export const v10Schema = { + ...v9Schema, + wallet: { + ...v9Schema.wallet, + settings: { + swapProtection: v9Schema.wallet.settings.swapProtection, + }, + }, + userSettings: { + hideSmallBalances: v9Schema.wallet.settings.hideSmallBalances, + hideSpamTokens: v9Schema.wallet.settings.hideSpamTokens, + }, +} + +const v11SchemaIntermediate = { + ...v10Schema, + behaviorHistory: { + ...v10Schema.behaviorHistory, + hasViewedReviewScreen: undefined, + hasSubmittedHoldToSwap: undefined, + }, +} + +delete v11SchemaIntermediate.behaviorHistory.hasViewedReviewScreen +delete v11SchemaIntermediate.behaviorHistory.hasSubmittedHoldToSwap + +export const v11Schema = v11SchemaIntermediate + +export const v12Schema = { + ...v11Schema, + behaviorHistory: { + ...v11Schema.behaviorHistory, + createdOnboardingRedesignAccount: false, + }, +} + +export const v13Schema = { + ...v12Schema, + tokens: { + dismissedTokenWarnings: {}, + }, +} + +const v14SchemaIntermediate = { + ...v13Schema, + languageSettings: undefined, + userSettings: { + ...v13Schema.userSettings, + currentLanguage: v13Schema.languageSettings.currentLanguage, + }, +} +delete v14SchemaIntermediate.languageSettings +export const v14Schema = v14SchemaIntermediate + +const v15SchemaIntermediate = { + ...v14Schema, + fiatCurrencySettings: undefined, + userSettings: { + ...v14Schema.userSettings, + currentLanguage: v14Schema.fiatCurrencySettings.currentCurrency, + }, +} +delete v15SchemaIntermediate.fiatCurrencySettings +export const v15Schema = v15SchemaIntermediate + +export const v16Schema = { + ...v15Schema, + wallet: { ...v15Schema.wallet, settings: { ...v15Schema.wallet.settings, tokensOrderBy: RankingType.Volume } }, +} + +const v17SchemaIntermediate = { + ...v16Schema, + behaviorHistory: { + ...v16Schema.behaviorHistory, + createdOnboardingRedesignAccount: undefined, + }, +} +delete v17SchemaIntermediate.behaviorHistory.createdOnboardingRedesignAccount +export const v17Schema = v17SchemaIntermediate + +export const v18Schema = v17Schema + +const v19SchemaIntermediate = { + ...v17Schema, + behaviorHistory: { + ...v17Schema.behaviorHistory, + hasViewedWelcomeWalletCard: undefined, + }, +} +delete v19SchemaIntermediate.behaviorHistory.hasViewedWelcomeWalletCard +export const v19Schema = v19SchemaIntermediate + +const v20SchemaIntermediate = { + ...v19Schema, + visibility: { + positions: {}, + tokens: v19Schema.favorites.tokensVisibility, + nfts: v19Schema.favorites.nftsVisibility, + }, + favorites: { + ...v19Schema.favorites, + tokensVisibility: undefined, + nftsVisibility: undefined, + }, +} +delete v20SchemaIntermediate.favorites.tokensVisibility +delete v20SchemaIntermediate.favorites.nftsVisibility +export const v20Schema = v20SchemaIntermediate + +const v21SchemaIntermediate = { + ...v20Schema, + dappRequests: { + ...v20Schema.dappRequests, + pending: undefined, + requests: {}, + }, +} +delete v21SchemaIntermediate.dappRequests.pending +export const v21Schema = v21SchemaIntermediate + +export const v22Schema = { + ...v21Schema, + batchedTransactions: {}, +} + +export const v23Schema = v22Schema + +const v24SchemaIntermediate = { + ...v23Schema, + appearanceSettings: { + ...v23Schema.appearanceSettings, + hapticsEnabled: undefined, + }, + userSettings: { + ...v23Schema.userSettings, + // oxlint-disable-next-line typescript/no-unnecessary-condition + hapticsEnabled: v23Schema.appearanceSettings.hapticsEnabled ?? false, + }, +} +delete v24SchemaIntermediate.appearanceSettings.hapticsEnabled + +export const v24Schema = v24SchemaIntermediate + +export const v25Schema = { ...v24Schema } + +export const v26Schema = { ...v25Schema } + +export const v27Schema = { ...v26Schema } + +export const v29Schema = { ...v27Schema, visibility: { ...v27Schema.visibility, activity: {} } } + +export const v30Schema = { ...v29Schema } + +const v31Schema = { ...v30Schema } + +export const getSchema = (): typeof v31Schema => v31Schema diff --git a/apps/extension/src/store/store.ts b/apps/extension/src/store/store.ts new file mode 100644 index 00000000..4745745b --- /dev/null +++ b/apps/extension/src/store/store.ts @@ -0,0 +1,76 @@ +import { persistReducer, persistStore } from 'redux-persist' +import { localStorage } from 'redux-persist-webextension-storage' +import { rootExtensionSaga } from 'src/app/saga' +import { loggerMiddleware } from 'src/background/utils/loggerMiddleware' +import { PERSIST_KEY } from 'src/store/constants' +import { enhancePersistReducer } from 'src/store/enhancePersistReducer' +import { ExtensionState, extensionPersistedStateList, extensionReducer } from 'src/store/extensionReducer' +import { EXTENSION_STATE_VERSION, migrations } from 'src/store/migrations' +import { delegationListenerMiddleware } from 'uniswap/src/features/smartWallet/delegation/slice' +import { createDatadogReduxEnhancer } from 'utilities/src/logger/datadog/Datadog' +import { logger } from 'utilities/src/logger/logger' +import { createStore } from 'wallet/src/state' +import { createMigrate } from 'wallet/src/state/createMigrate' +import { setReduxPersistor } from 'wallet/src/state/persistor' + +const persistConfig = { + key: PERSIST_KEY, + storage: localStorage, + whitelist: extensionPersistedStateList, + version: EXTENSION_STATE_VERSION, + migrate: createMigrate(migrations), +} + +const persistedReducer = enhancePersistReducer(persistReducer(persistConfig, extensionReducer)) + +const dataDogReduxEnhancer = createDatadogReduxEnhancer({ + shouldLogReduxState: (state: ExtensionState): boolean => { + // Do not log the state if a user has opted out of analytics. + return !!state.telemetry.allowAnalytics + }, +}) + +const setupStore = (): ReturnType => { + return createStore({ + reducer: persistedReducer, + additionalSagas: [rootExtensionSaga], + middlewareBefore: __DEV__ ? [loggerMiddleware] : [], + middlewareAfter: [delegationListenerMiddleware.middleware], + enhancers: [dataDogReduxEnhancer], + }) +} + +let store: ReturnType | undefined + +export function initializeReduxStore(args?: { readOnly?: boolean }): void { + if (store) { + // This should never happen. It's only here to alert us if a bug is introduced in the future. + logger.error(new Error('`initializeReduxStore` called when already initialized'), { + tags: { + file: 'store.ts', + function: 'initializeReduxStore', + }, + }) + + return + } + + store = setupStore() + const persistor = persistStore(store) + setReduxPersistor(persistor) + + if (args?.readOnly) { + // This means the store will be initialized with the persisted state from disk, but it won't persist any changes. + // Only useful for use cases where we don't want to modify the state (for example, a popup window instead of the sidebar). + persistor.pause() + } +} + +export function getReduxStore(): ReturnType { + if (!store) { + throw new Error('Invalid call to `getReduxStore` before store has been initialized') + } + return store +} + +export type AppStore = ReturnType diff --git a/apps/extension/src/store/storeSynchronization.ts b/apps/extension/src/store/storeSynchronization.ts new file mode 100644 index 00000000..fb7345b0 --- /dev/null +++ b/apps/extension/src/store/storeSynchronization.ts @@ -0,0 +1,149 @@ +import { useEffect, useState } from 'react' +import { initializeReduxStore } from 'src/store/store' +import { logger } from 'utilities/src/logger/logger' +import { v4 as uuid } from 'uuid' +import { getReduxPersistor } from 'wallet/src/state/persistor' +import { PersistedStorage } from 'wallet/src/utils/persistedStorage' + +/** + * We want only one instance of the app to be persisting the redux store to disk at a time. + * To accomplish this, we use the concept of "primary instance", which is the instance of the app that is currently being used. + * + * An instance of the app is the primary instance when: + * - It is the only instance of the app running. + * - There are multiple instances of the app running, and this is the instance of the sidebar that lives in the window that is currently (or was last) focused. + * - When there is a sidebar and an onboarding instance running on the same window, whichever is currently focused will be the primary. + */ + +const PRIMARY_APP_INSTANCE_ID_KEY = 'primaryAppInstanceId' + +let isPrimaryAppInstance = false +const terminate: (() => Promise) | null = null + +const STORAGE_NAMESPACE = 'session' +const sessionStorage = new PersistedStorage(STORAGE_NAMESPACE) +const currentAppInstanceId = uuid() + +// These listeners are meant for `useIsPrimaryAppInstance()` to listen for changes. +const primaryAppInstanceListeners = new Set<(isPrimary: boolean) => void>() + +export enum ExtensionAppLocation { + SidePanel = 0, + Tab = 1, +} + +function initPrimaryInstanceHandler(appLocation: ExtensionAppLocation): void { + initializeReduxStore() + + const onStorageChangedListener: Parameters[0] = async ( + changes, + namespace, + ) => { + if (namespace === STORAGE_NAMESPACE && changes[PRIMARY_APP_INSTANCE_ID_KEY]) { + const wasPrimaryAppInstance = isPrimaryAppInstance + isPrimaryAppInstance = currentAppInstanceId === changes[PRIMARY_APP_INSTANCE_ID_KEY].newValue + + if (wasPrimaryAppInstance === isPrimaryAppInstance) { + return + } + + const persistor = getReduxPersistor() + + if (isPrimaryAppInstance) { + logger.debug('store-synchronization', 'chrome.storage.onChanged', 'Resuming redux persistor') + + persistor.persist() + } else { + logger.debug('store-synchronization', 'chrome.storage.onChanged', 'Pausing redux persistor') + await persistor.flush() + persistor.pause() + } + + primaryAppInstanceListeners.forEach((listener) => listener(isPrimaryAppInstance)) + } + } + + const onFocusChangedListener: Parameters[0] = async ( + focusedWindowId, + ) => { + const { id: currentWindowId } = await chrome.windows.getCurrent() + + if (focusedWindowId === currentWindowId) { + logger.debug('store-synchronization', 'chrome.windows.onFocusChanged', 'Window focused') + await sessionStorage.setItem(PRIMARY_APP_INSTANCE_ID_KEY, currentAppInstanceId) + } + } + + const onWindowFocusListener: Parameters[1] = async () => { + // We set a slight delay to ensure that the `chrome.windows.onFocusChanged` listener runs first. + // This is to handle the case where we have a sidebar and an onboarding instance running on the same window. + setTimeout(async () => { + logger.debug('store-synchronization', 'window.onFocus', 'Window focused') + await sessionStorage.setItem(PRIMARY_APP_INSTANCE_ID_KEY, currentAppInstanceId) + }, 25) + } + + chrome.storage.onChanged.addListener(onStorageChangedListener) + + if (appLocation === ExtensionAppLocation.SidePanel) { + chrome.windows.onFocusChanged.addListener(onFocusChangedListener) + } + + window.addEventListener('focus', onWindowFocusListener) + + // We always set the current app instance as the primary when it first launches. + sessionStorage.setItem(PRIMARY_APP_INSTANCE_ID_KEY, currentAppInstanceId).catch((error) => { + logger.error(error, { + tags: { file: 'storeSynchronization.ts', function: 'sessionStorage.setItem' }, + }) + }) + + // This will be used in the onboarding flow when the user completes onboarding but the tab remains open. + // We don't want this tab to become the primary ever again when it's focused. + StoreSynchronization.terminate = async (): Promise => { + chrome.storage.onChanged.removeListener(onStorageChangedListener) + chrome.windows.onFocusChanged.removeListener(onFocusChangedListener) + window.removeEventListener('focus', onWindowFocusListener) + + const persistor = getReduxPersistor() + await persistor.flush() + persistor.pause() + + isPrimaryAppInstance = false + primaryAppInstanceListeners.forEach((listener) => listener(isPrimaryAppInstance)) + } +} + +export function useIsPrimaryAppInstance(): boolean { + const [isPrimary, setIsPrimary] = useState(isPrimaryAppInstance) + + useEffect(() => { + const listener = (_isPrimary: boolean): void => { + setIsPrimary(_isPrimary) + } + + primaryAppInstanceListeners.add(listener) + + return () => { + primaryAppInstanceListeners.delete(listener) + } + }, []) + + return isPrimary +} + +export function terminateStoreSynchronization(): void { + StoreSynchronization.terminate?.().catch((error) => { + logger.error(error, { + tags: { file: 'storeSynchronization.ts', function: 'useTerminateStoreSynchronization' }, + }) + }) +} + +export const StoreSynchronization: { + init: typeof initPrimaryInstanceHandler + terminate: (() => Promise) | null +} = { + init: initPrimaryInstanceHandler, + terminate, +} diff --git a/apps/extension/src/test/__mocks__/@react-native-masked-view/masked-view.ts b/apps/extension/src/test/__mocks__/@react-native-masked-view/masked-view.ts new file mode 100644 index 00000000..66e67ac3 --- /dev/null +++ b/apps/extension/src/test/__mocks__/@react-native-masked-view/masked-view.ts @@ -0,0 +1,13 @@ +import React, { PropsWithChildren, ReactNode } from 'react' +import { View, ViewProps } from 'react-native' + +// react-native-masked-view for Storybook web +// https://github.com/react-native-masked-view/masked-view/issues/70#issuecomment-1171801526 +function MaskedViewWeb({ + maskElement, + ...props +}: PropsWithChildren<{ maskElement: ReactNode }>): React.CElement { + return React.createElement(View, props, maskElement) +} + +export default MaskedViewWeb diff --git a/apps/extension/src/test/__mocks__/@shopify/react-native-skia.ts b/apps/extension/src/test/__mocks__/@shopify/react-native-skia.ts new file mode 100644 index 00000000..766d3d19 --- /dev/null +++ b/apps/extension/src/test/__mocks__/@shopify/react-native-skia.ts @@ -0,0 +1,19 @@ +import React, { PropsWithChildren } from 'react' +import { View, ViewProps } from 'react-native' + +// Source: https://github.com/Shopify/react-native-skia/issues/548#issuecomment-1157609472 + +const PlainView = ({ children, ...props }: PropsWithChildren): React.CElement => { + return React.createElement(View, props, children) +} +const noop = (): null => null + +export const BlurMask = PlainView +export const Canvas = PlainView +export const Circle = PlainView +export const Group = PlainView +export const LinearGradient = PlainView +export const Mask = PlainView +export const Path = PlainView +export const Rect = PlainView +export const vec = noop diff --git a/apps/extension/src/test/babel.config.js b/apps/extension/src/test/babel.config.js new file mode 100644 index 00000000..c6075122 --- /dev/null +++ b/apps/extension/src/test/babel.config.js @@ -0,0 +1,41 @@ +// This file is used only by jest in the test environment. To check the extension +// build set up, see the webpack.config.js file. + +// Inline Babel plugin to transform import.meta.url for Jest compatibility. +// Jest runs in CommonJS mode where import.meta is not available. +function importMetaTransformPlugin() { + return { + visitor: { + MetaProperty(path) { + path.replaceWithSourceString('({url: "file:///test.js"})') + }, + }, + } +} + +module.exports = function (api) { + api.cache.using(() => process.env.NODE_ENV) + // oxlint-disable-next-line no-var -- biome-parity: oxlint is stricter here + var plugins = [ + 'react-native-web', + [ + 'module:react-native-dotenv', + { + moduleName: 'react-native-dotenv', + path: '../../.env.defaults', + safe: true, + allowUndefined: false, + }, + ], + // https://github.com/software-mansion/react-native-reanimated/issues/3364#issuecomment-1268591867 + '@babel/plugin-proposal-export-namespace-from', + '@babel/plugin-transform-new-target', + importMetaTransformPlugin, + 'react-native-reanimated/plugin', + ].filter(Boolean) + + return { + presets: ['babel-preset-expo'], + plugins, + } +} diff --git a/apps/extension/src/test/fixtures/redux.ts b/apps/extension/src/test/fixtures/redux.ts new file mode 100644 index 00000000..773a0716 --- /dev/null +++ b/apps/extension/src/test/fixtures/redux.ts @@ -0,0 +1,17 @@ +import { PreloadedState } from 'redux' +import { ExtensionState } from 'src/store/extensionReducer' +import { createFixture } from 'uniswap/src/test/utils' +import { preloadedWalletPackageState } from 'wallet/src/test/fixtures' + +type PreloadedExtensionStateOptions = Record + +type PreloadedExtensionStateFactory = ( + overrides?: Partial & PreloadedExtensionStateOptions>, +) => PreloadedState + +export const preloadedExtensionState: PreloadedExtensionStateFactory = createFixture< + PreloadedState, + PreloadedExtensionStateOptions +>({})(() => ({ + ...preloadedWalletPackageState(), +})) diff --git a/apps/extension/src/test/jest-resolver.js b/apps/extension/src/test/jest-resolver.js new file mode 100644 index 00000000..c7a1c690 --- /dev/null +++ b/apps/extension/src/test/jest-resolver.js @@ -0,0 +1,33 @@ +const fs = require('fs') +const path = require('path') + +const platformExtensions = ['native', 'ios', 'android'] +const targetExtensions = ['web', ''] + +module.exports = (request, options) => { + const { defaultResolver } = options + const resolvedPath = defaultResolver(request, options) + + const parsedPath = path.parse(resolvedPath) + const isPlatformSpecific = platformExtensions.some((ext) => parsedPath.name.endsWith(`.${ext}`)) + + if (isPlatformSpecific) { + const index = parsedPath.name.lastIndexOf('.') + const strippedName = parsedPath.name.slice(0, index) + + for (const targetExt of targetExtensions) { + const candidatePath = path.format({ + dir: parsedPath.dir, + name: targetExt ? `${strippedName}.${targetExt}` : strippedName, + ext: parsedPath.ext, + }) + + if (fs.existsSync(candidatePath)) { + return candidatePath + } + } + } + + // Return default resolved path if no replacement is found + return resolvedPath +} diff --git a/apps/extension/src/test/render.tsx b/apps/extension/src/test/render.tsx new file mode 100644 index 00000000..f9fd663f --- /dev/null +++ b/apps/extension/src/test/render.tsx @@ -0,0 +1,135 @@ +import type { EnhancedStore, PreloadedState } from '@reduxjs/toolkit' +import { configureStore } from '@reduxjs/toolkit' +import { + render as ReactRender, + renderHook as ReactRenderHook, + RenderHookOptions, + RenderHookResult, + RenderOptions, + RenderResult, +} from '@testing-library/react' +import { GraphQLApi } from '@universe/api' +import React, { PropsWithChildren } from 'react' +import { ExtensionState, extensionReducer } from 'src/store/extensionReducer' +import { AppStore } from 'src/store/store' +import { UniswapProvider } from 'uniswap/src/contexts/UniswapContext' +import { AutoMockedApolloProvider } from 'uniswap/src/test/mocks' +import { mockUniswapContext } from 'uniswap/src/test/render' +import { SharedWalletProvider } from 'wallet/src/providers/SharedWalletProvider' + +// This type extends the default options for render from RTL, as well +// as allows the user to specify other things such as initialState, store. +type ExtendedRenderOptions = RenderOptions & { + resolvers?: GraphQLApi.Resolvers + preloadedState?: PreloadedState + store?: AppStore +} + +/** + * + * @param ui Component to render + * @param resolvers Custom resolvers that override the default ones + * @param preloadedState and store + * @returns `ui` wrapped with providers + */ +export function renderWithProviders( + ui: React.ReactElement, + { + resolvers, + preloadedState = {}, + // Automatically create a store instance if no store was passed in + store = configureStore({ + reducer: extensionReducer, + preloadedState, + middleware: (getDefaultMiddleware) => getDefaultMiddleware(), + }), + ...renderOptions + }: ExtendedRenderOptions = {}, +): RenderResult & { + store: EnhancedStore +} { + function Wrapper({ children }: PropsWithChildren): JSX.Element { + return ( + + + {children} + + + ) + } + + // Return an object with the store and all of RTL's query functions + return { store, ...ReactRender(ui, { wrapper: Wrapper, ...renderOptions }) } +} + +// This type extends the default options for render from RTL, as well +// as allows the user to specify other things such as initialState, store. +type ExtendedRenderHookOptions

= RenderHookOptions

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

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

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

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

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

), + } + + const { ...rest } = ReactRenderHook((args: P) => hook(args), options) + + // Return an object with the store and all of RTL's query functions + return { + store, + ...rest, + } +} diff --git a/apps/extension/src/test/test-utils.ts b/apps/extension/src/test/test-utils.ts new file mode 100644 index 00000000..2abe0a14 --- /dev/null +++ b/apps/extension/src/test/test-utils.ts @@ -0,0 +1,6 @@ +import { renderHookWithProviders, renderWithProviders } from 'src/test/render' + +// re-export everything +export * from '@testing-library/react' +// override render method +export { renderWithProviders as render, renderHookWithProviders as renderHook } diff --git a/apps/extension/tsconfig.eslint.json b/apps/extension/tsconfig.eslint.json new file mode 100644 index 00000000..0af7bb26 --- /dev/null +++ b/apps/extension/tsconfig.eslint.json @@ -0,0 +1,5 @@ +// same as tsconfig.json but without references which caused performance issues with typescript-eslint +{ + "extends": "./tsconfig.json", + "references": [] +} diff --git a/apps/extension/tsconfig.json b/apps/extension/tsconfig.json new file mode 100644 index 00000000..0ed8bf07 --- /dev/null +++ b/apps/extension/tsconfig.json @@ -0,0 +1,44 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "display": "Web App", + "extends": ["../../tsconfig.base.json"], + "include": ["**/*.ts", "**/*.tsx", "**/*.json", "declarations.d.ts"], + "exclude": ["node_modules", ".output", "wxt.config.ts"], + "references": [ + { + "path": "../../packages/wallet" + }, + { + "path": "../../packages/utilities" + }, + { + "path": "../../packages/uniswap" + }, + { + "path": "../../packages/ui" + }, + { + "path": "../../packages/sessions" + }, + { + "path": "../../packages/notifications" + }, + { + "path": "../../packages/gating" + }, + { + "path": "../../packages/api" + } + ], + "compilerOptions": { + "moduleResolution": "Bundler", + "lib": ["dom", "dom.iterable", "esnext"], + "jsx": "preserve", + "resolveJsonModule": true, + "types": ["chrome", "jest", "node"], + "paths": { + "src/*": ["./src/*"], + "e2e/*": ["./e2e/*"] + } + } +} diff --git a/apps/extension/webpack-plugins/immediate-execution-loader.js b/apps/extension/webpack-plugins/immediate-execution-loader.js new file mode 100644 index 00000000..c58dc0a4 --- /dev/null +++ b/apps/extension/webpack-plugins/immediate-execution-loader.js @@ -0,0 +1,69 @@ +/** + * Webpack loader to add immediate function calls to entry points. + * This runs before other transforms, modifying the TypeScript source directly. + * + * We need this for the entrypoint-defining code to run in the webpack build, since + * the default exports are using WXT functions now. + */ + +const path = require('path') + +/** + * Escapes special regex characters in a string to make it safe for use in RegExp constructor + * @param {string} string - The string to escape + * @returns {string} - The escaped string + */ +function escapeRegExp(string) { + return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +// Map of entry point files to their immediate execution functions +const ENTRY_FUNCTIONS = { + 'background.ts': 'makeBackground', + 'injected.content.ts': 'makeInjected', + 'ethereum.content.ts': 'makeEthereum', +} + +module.exports = function immediateExecutionLoader(source) { + const filename = path.basename(this.resourcePath) + const functionName = ENTRY_FUNCTIONS[filename] + + if (!functionName) { + // Not an entry point we care about, return source unchanged + return source + } + + console.log(`🔧 Processing ${filename} for immediate ${functionName}() execution`) + + // Check if we've already added the immediate call + if (source.includes('// Webpack entry point - execute immediately')) { + console.log(`ℹ️ ${filename} already has immediate execution`) + return source + } + + // Check if the function exists in the source + const functionRegex = new RegExp(`function\\s+${escapeRegExp(functionName)}\\s*\\(`) + if (!functionRegex.test(source)) { + console.log(`⚠️ Function ${functionName} not found in ${filename}`) + return source + } + + // Find the export default defineBackground/defineContentScript and insert before it + const exportRegex = /export\s+default\s+define(?:Background|ContentScript)/ + const exportMatch = source.match(exportRegex) + + if (exportMatch) { + const insertPosition = exportMatch.index + const modifiedSource = + source.slice(0, insertPosition) + + `// Webpack entry point - execute immediately\n${functionName}();\n\n` + + source.slice(insertPosition) + + console.log(`✅ Added immediate ${functionName}() call to ${filename}`) + return modifiedSource + } else { + console.log(`⚠️ Could not find export default in ${filename}, appending to end`) + const modifiedSource = source + `\n\n// Webpack entry point - execute immediately\n${functionName}();\n` + return modifiedSource + } +} diff --git a/apps/extension/webpack.config.js b/apps/extension/webpack.config.js new file mode 100644 index 00000000..fdc01598 --- /dev/null +++ b/apps/extension/webpack.config.js @@ -0,0 +1,452 @@ +const { CleanWebpackPlugin } = require('clean-webpack-plugin') +const { ProgressPlugin, ProvidePlugin, DefinePlugin } = require('webpack') +const CopyWebpackPlugin = require('copy-webpack-plugin') +const MiniCssExtractPlugin = require('mini-css-extract-plugin') +const path = require('path') +const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin') +const fs = require('fs') +const DotenvPlugin = require('dotenv-webpack') +const NodePolyfillPlugin = require('node-polyfill-webpack-plugin') + +const NODE_ENV = process.env.NODE_ENV || 'development' +const POLL_ENV = process.env.WEBPACK_POLLING_INTERVAL + +// if not set tamagui wont add nice data-at, data-in etc debug attributes +process.env.NODE_ENV = NODE_ENV + +const isDevelopment = NODE_ENV === 'development' +const isProduction = NODE_ENV === 'production' +const appDirectory = path.resolve(__dirname) +const manifest = require('./src/manifest.json') + +// Add all node modules that have to be compiled +const compileNodeModules = [ + // These libraries export JSX code from files with .js extension, which aren't transpiled + // in the library to code that doesn't use JSX syntax. This file extension is not automatically + // recognized as extension for files containing JSX, so we have to manually add them to + // the build proess (to the appropriate loader) and don't exclude them with other node_modules + 'expo-clipboard', + 'expo-linear-gradient', + 'react-native-image-picker', + 'expo-modules-core', + 'react-native-reanimated', +] + +// This is needed for webpack to compile JavaScript. +// Many OSS React Native packages are not compiled to ES5 before being +// published. If you depend on uncompiled packages they may cause webpack build +// errors. To fix this webpack can be configured to compile to the necessary +// `node_module`. +const babelLoaderConfiguration = { + test: /\.js$/, + // Add every directory that needs to be compiled by Babel during the build. + include: [ + // path.resolve(appDirectory, "index.web.js"), + // path.resolve(appDirectory, "src"), + path.resolve(appDirectory, 'node_modules/react-native-uncompiled'), + ], + use: { + loader: 'babel-loader', + options: { + cacheDirectory: true, + // The 'babel-preset-expo' preset is recommended to match React Native's packager + presets: ['babel-preset-expo'], + // Re-write paths to import only the modules needed by the app + plugins: ['react-native-web'], + }, + }, +} + +const swcLoader = { + loader: 'swc-loader', + options: { + // parseMap: true, // required when using with babel-loader + env: { + targets: require('./package.json').browserslist, + }, + sourceMap: isDevelopment, + jsc: { + parser: { + syntax: 'typescript', + tsx: true, + dynamicImport: true, + }, + transform: { + react: { + development: isDevelopment, + refresh: isDevelopment, + }, + }, + }, + }, +} + +const swcLoaderConfiguration = { + test: ['.jsx', '.js', '.tsx', '.ts'].map((ext) => new RegExp(`${ext}$`)), + exclude: new RegExp(`node_modules/(?!(${compileNodeModules.join('|')})/)`), + use: swcLoader, +} + +const fileExtensions = ['eot', 'gif', 'jpeg', 'jpg', 'otf', 'png', 'ttf', 'woff', 'woff2', 'mp4'] + +const { + dir, + plugins = [], + ...extras +} = isDevelopment + ? { + dir: 'dev', + watchOptions: { + poll: POLL_ENV ? Number(POLL_ENV) : undefined, + }, + devServer: { + // watchFiles: ['src/**/*', 'webpack.config.js'], + host: '127.0.0.1', + port: 9997, + server: fs.existsSync('localhost.pem') + ? { + type: 'https', + options: { + key: 'localhost-key.pem', + cert: 'localhost.pem', + }, + } + : {}, + compress: false, + hot: true, // Enable HMR + static: { + directory: path.join(__dirname, '../dev'), + }, + client: { + // logging: "info", + progress: true, + reconnect: true, + overlay: { + errors: true, + warnings: false, + // disable resize observer error + // NOTE: ideally would use the function format (error) => boolean + // however, I was not able to get past CSP with that solution + runtimeErrors: false, + }, + }, + devMiddleware: { + writeToDisk: true, + }, + }, + devtool: 'cheap-module-source-map', + plugins: [new ReactRefreshWebpackPlugin()], + } + : { + dir: 'build', + plugins: [], + } + +module.exports = (env) => { + // Build env is either 'dev', 'beta', or 'prod' + if (!isDevelopment && env.BUILD_ENV !== 'prod' && env.BUILD_ENV !== 'beta' && env.BUILD_ENV !== 'dev') { + throw new Error('Must set BUILD_ENV env variable to either prod, beta or dev') + } + + // Build num is the fourth number in the extension version (...). It will come from GH actions when building this to publish + if (!isDevelopment && (env.BUILD_NUM === undefined || env.BUILD_NUM < 0)) { + throw new Error('Must set BUILD_NUM env variable to a number >= 0') + } + + const BUILD_ENV = env.BUILD_ENV + const BUILD_NUM = env.BUILD_NUM || 0 + + const publicAssetsVariant = isDevelopment + ? 'local' + : BUILD_ENV === 'dev' + ? 'dev' + : BUILD_ENV === 'beta' + ? 'beta' + : 'prod' + + // Title Postfix + const EXTENSION_NAME_POSTFIX = isDevelopment + ? 'LOCAL' + : BUILD_ENV === 'dev' + ? 'DEV' + : BUILD_ENV === 'beta' + ? 'BETA' + : '' + + // Description + let EXTENSION_DESCRIPTION = manifest.description + if (BUILD_ENV === 'beta') { + EXTENSION_DESCRIPTION = 'THIS EXTENSION IS FOR BETA TESTING' + } + if (BUILD_ENV === 'dev') { + EXTENSION_DESCRIPTION = 'THIS EXTENSION IS FOR DEV TESTING' + } + + // Version + const EXTENSION_VERSION = manifest.version + '.' + BUILD_NUM + + return { + mode: NODE_ENV, + entry: { + background: './src/entrypoints/background.ts', + onboarding: './src/entrypoints/onboarding/main.tsx', + loadSidebar: './src/entrypoints/sidepanel/loadSidebar.ts', + sidebar: './src/entrypoints/sidepanel/main.tsx', + injected: './src/entrypoints/injected.content.ts', + ethereum: './src/entrypoints/ethereum.content.ts', + popup: './src/entrypoints/fallback-popup/main.tsx', + unitagClaim: './src/entrypoints/unitagClaim/main.tsx', + }, + output: { + filename: '[name].js', + chunkFilename: '[name].js', + path: path.resolve(__dirname, dir), + clean: true, + publicPath: '', + }, + // https://webpack.js.org/configuration/other-options/#level + infrastructureLogging: { level: 'warn' }, + module: { + rules: [ + // Use this rule together with other rules specified for the same pattern + { + test: /\.m?js$/, + resolve: { + fullySpecified: false, // disable the behaviour + }, + }, + // Add immediate execution calls to entry point files + { + test: /\/(background|injected\.content|ethereum\.content)\.ts$/, + use: [ + { + loader: path.resolve(__dirname, 'webpack-plugins/immediate-execution-loader.js'), + }, + ], + }, + { + oneOf: [ + { + test: /\.(woff|woff2)$/, + use: { loader: 'file-loader' }, + }, + + { + test: /\.css$/, + use: [ + { + loader: 'style-loader', + }, + { + loader: 'css-loader', + }, + ], + }, + + { + type: 'javascript/auto', + test: /\.json$/, + use: ['file-loader'], + include: /tokenlist/, + }, + + // Used for creating SVG React components (similar to react=native-svg-transformer on mobile) + { + test: /\.svg$/, + use: ['@svgr/webpack'], + }, + + { + test: new RegExp('.(' + fileExtensions.join('|') + ')$'), + type: 'asset/resource', + }, + + { + test: /.tsx?$/, + exclude: (file) => file.includes('node_modules'), + use: [ + // one after to remove the jsx + swcLoader, + + // tamagui optimizes the jsx + { + loader: 'tamagui-loader', + options: { + config: '../../packages/ui/src/tamagui.config.ts', + components: ['ui'], + // add files here that should be parsed by the compiler from within any of the apps/* + // for example if you have constants.ts then constants.js goes here and it will eval them + // at build time and if it can flatten views even if they use imports from that file + importsWhitelist: ['constants.js'], + // TODO: test re-enabling extraction in production when #27138 merges + disableExtraction: true, + }, + }, + + // one before to remove types + { + loader: 'esbuild-loader', + options: { + target: 'es2022', + jsx: 'preserve', + minify: false, + }, + }, + ], + }, + + babelLoaderConfiguration, + swcLoaderConfiguration, + ], + }, + ], + }, + resolve: { + alias: { + 'react-native$': 'react-native-web', + 'react-native-reanimated$': require.resolve('react-native-reanimated'), + 'react-native-vector-icons$': 'react-native-vector-icons/dist', + src: path.resolve(__dirname, 'src'), // absolute imports in apps/web + 'react-native-gesture-handler$': require.resolve('react-native-gesture-handler'), + 'expo-blur': require.resolve('./__mocks__/expo-blur.jsx'), + 'react-router': path.resolve( + __dirname, + isProduction + ? '../../node_modules/react-router/dist/production/index.mjs' + : '../../node_modules/react-router/dist/development/index.mjs', + ), + }, + // Add support for web-based extensions so we can share code between mobile/extension + extensions: [ + '.web.js', + '.web.jsx', + '.web.ts', + '.web.tsx', + ...fileExtensions.map((e) => `.${e}`), + ...['.js', '.jsx', '.ts', '.tsx', '.css'], + ], + fallback: { + fs: false, + }, + }, + devtool: 'source-map', + plugins: [ + new DotenvPlugin({ + path: '../../.env', + defaults: true, + }), + new DefinePlugin({ + __DEV__: NODE_ENV === 'development' ? 'true' : 'false', + 'process.env.IS_STATIC': '""', + 'process.env.NODE_ENV': JSON.stringify(NODE_ENV), + 'process.env.DEBUG': JSON.stringify(process.env.DEBUG || '0'), + 'process.env.VERSION': JSON.stringify(EXTENSION_VERSION), + 'process.env.IS_UNISWAP_EXTENSION': '"true"', + }), + new CleanWebpackPlugin(), + new NodePolyfillPlugin(), // necessary to compile with reactnative-dotenv + ...plugins, + new MiniCssExtractPlugin(), + new ProgressPlugin(), + new ProvidePlugin({ + process: 'process/browser', + React: 'react', + Buffer: ['buffer', 'Buffer'], + }), + new CopyWebpackPlugin({ + patterns: [ + { + from: 'src/manifest.json', + force: true, + // oxlint-disable-next-line no-unused-vars -- biome-parity: oxlint is stricter here + transform(content) { + const transformedManifest = { + ...manifest, + description: EXTENSION_DESCRIPTION, + version: EXTENSION_VERSION, + name: EXTENSION_NAME_POSTFIX ? manifest.name + ' ' + EXTENSION_NAME_POSTFIX : manifest.name, + externally_connectable: { + ...manifest.externally_connectable, + matches: + BUILD_ENV === 'prod' + ? ['https://app.uniswap.org/*'] + : ['https://app.uniswap.org/*', 'https://ew.unihq.org/*', 'https://*.ew.unihq.org/*'], + }, + // Ensure content scripts are registered in the webpack build (WXT handles this automatically). + // These mirror the matches/runAt used in the TS entrypoints. + content_scripts: [ + { + id: 'injected', + matches: ['http://127.0.0.1/*', 'http://localhost/*', 'https://*/*'], + js: ['injected.js'], + run_at: 'document_start', + all_frames: true, + }, + { + id: 'ethereum', + matches: ['http://127.0.0.1/*', 'http://localhost/*', 'https://*/*'], + js: ['ethereum.js'], + run_at: 'document_start', + // Ethereum provider must run in the MAIN world to attach to window.ethereum + world: 'MAIN', + all_frames: true, + }, + ], + } + + return Buffer.from(JSON.stringify(transformedManifest, null, 2)) + }, + }, + { + from: 'src/public/assets/fonts/*.{woff,woff2,ttf}', + to: 'assets/fonts/[name][ext]', + force: true, + }, + { + from: 'src/public/assets/*.{png,svg}', + to: 'assets/[name][ext]', + force: true, + }, + { + from: `src/publicAssetsByEnv/${publicAssetsVariant}/*.{png,svg}`, + to: 'assets/[name][ext]', + force: true, + }, + { + from: 'src/entrypoints/sidepanel/index.html', + to: 'sidepanel.html', + force: true, + transform(content) { + return content.toString().replace('src="loadSidebar.ts"', 'src="loadSidebar.js"') + }, + }, + { + from: 'src/entrypoints/fallback-popup/index.html', + to: 'fallback-popup.html', + force: true, + transform(content) { + return content.toString().replace('src="main.tsx"', 'src="popup.js"') + }, + }, + { + from: 'src/entrypoints/onboarding/index.html', + to: 'onboarding.html', + force: true, + transform(content) { + return content.toString().replace('src="main.tsx"', 'src="onboarding.js"') + }, + }, + { + from: 'src/entrypoints/unitagClaim/index.html', + to: 'unitagClaim.html', + force: true, + transform(content) { + return content.toString().replace('src="main.tsx"', 'src="unitagClaim.js"') + }, + }, + ], + }), + ], + ...extras, + } +} diff --git a/apps/extension/wxt.config.ts b/apps/extension/wxt.config.ts new file mode 100644 index 00000000..92842031 --- /dev/null +++ b/apps/extension/wxt.config.ts @@ -0,0 +1,332 @@ +import fs from 'fs' +import { createHash } from 'node:crypto' +import path from 'path' +import { loadEnv, transformWithEsbuild } from 'vite' +import commonjs from 'vite-plugin-commonjs' +import { nodePolyfills } from 'vite-plugin-node-polyfills' +import svgr from 'vite-plugin-svgr' +import tsconfigPaths from 'vite-tsconfig-paths' +import { defineConfig } from 'wxt' +const BASE_NAME = 'Uniswap Extension' +const BASE_DESCRIPTION = "The Uniswap Extension is a self-custody crypto wallet that's built for swapping." +const BASE_VERSION = '1.69.0' + +const BUILD_NUM = parseInt(process.env.BUILD_NUM || '0') +const EXTENSION_VERSION = `${BASE_VERSION}.${BUILD_NUM}` + +/** + * Vite's optimizeDeps cache hash doesn't include `define` values, so changing env vars + * (which are injected via `define` as `process.env.X` replacements) won't invalidate the + * pre-bundled deps cache. This compares a hash of the resolved env defines against a stored + * hash and forces a re-bundle only when env values actually changed. + */ +function shouldInvalidateOptimizeDepsForEnv({ + defines, + cacheDir, +}: { + defines: Record + cacheDir: string +}): boolean { + const hash = createHash('md5').update(JSON.stringify(defines)).digest('hex').slice(0, 16) + const hashFile = path.join(cacheDir, '.env-defines-hash') + + try { + if (fs.existsSync(hashFile)) { + const stored = fs.readFileSync(hashFile, 'utf-8').trim() + if (stored === hash) { + return false + } + } + } catch { + return true + } + + try { + if (!fs.existsSync(cacheDir)) { + fs.mkdirSync(cacheDir, { recursive: true }) + } + fs.writeFileSync(hashFile, hash) + } catch { + return true + } + + return true +} + +// oxlint-disable-next-line import/no-unused-modules +export default defineConfig({ + // WXT Configuration + srcDir: 'src', + entrypointsDir: 'entrypoints', + publicDir: 'src/public', + + // Use absolute output directory if specified via environment variable + outDir: process.env.WXT_ABSOLUTE_OUTDIR || undefined, + + // Remove browser/version subdirectory when using absolute paths + outDirTemplate: process.env.WXT_ABSOLUTE_OUTDIR ? '' : undefined, + + imports: false, + // Enable React support + modules: ['@wxt-dev/module-react'], + + hooks: { + // Hook for dynamic asset copying based on build variant. + // All assets in `src/publicAssetsByEnv/` will be copied to `assets/` at build time. + 'build:publicAssets': (_wxt, files) => { + const envDir = path.resolve(import.meta.dirname, 'src/publicAssetsByEnv', publicAssetsVariant) + const entries = fs.readdirSync(envDir) + for (const entry of entries) { + const absoluteSrc = path.resolve(envDir, entry) + if (fs.statSync(absoluteSrc).isFile()) { + files.push({ + relativeDest: `assets/${entry}`, + absoluteSrc, + }) + } + } + }, + // Validate build output after dev builds complete + 'build:done': async (wxt) => { + // Only validate in development mode (dev server) + if (wxt.config.mode !== 'development') { + return + } + const { execSync } = await import('node:child_process') + try { + // Run script directly to avoid Nx dependsOn chain that would trigger a full rebuild + execSync('bunx tsx scripts/validateBuildOutput.ts --dev', { + cwd: wxt.config.root, + stdio: 'inherit', + }) + } catch { + // oxlint-disable-next-line no-console -- CLI output for build validation + console.error('Build validation failed!') + process.exit(1) + } + }, + }, + + // Dynamic manifest generation + ? ['https://app.uniswap.org/*'] + : ['https://app.uniswap.org/*', 'https://ew.unihq.org/*', 'https://*.ew.unihq.org/*'], + }, + } + }, + + // Vite configuration copied from web project + vite: (env) => { + // Load ALL env variables (including those without VITE_ prefix) + const envVars = loadEnv(env.mode, process.cwd(), '') + + const __dirname = path.dirname(new URL(import.meta.url).pathname) + const isProduction = process.env.NODE_ENV === 'production' + const isPreparePhase = process.env.WXT_PREPARE === 'true' + + // Create process.env definitions for ALL environment variables + const envDefines = Object.fromEntries( + Object.entries(envVars).map(([key, value]) => [`process.env.${key}`, JSON.stringify(value)]), + ) + + const defines = { + __DEV__: !isProduction, + global: 'globalThis', + 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'), + 'process.env.DEBUG': JSON.stringify(process.env.DEBUG || '0'), + 'process.env.VERSION': JSON.stringify(EXTENSION_VERSION), + 'process.env.IS_STATIC': '""', + 'process.env.EXPO_OS': '"web"', + ...envDefines, + 'process.env.REACT_APP_IS_UNISWAP_INTERFACE': '"false"', + 'process.env.IS_UNISWAP_EXTENSION': '"true"', + } + + const cacheDir = path.resolve(__dirname, 'node_modules/.vite') + const forceOptimize = shouldInvalidateOptimizeDepsForEnv({ defines, cacheDir }) + + // External package aliases from web config + const overrides = { + buffer: 'buffer', + // External package aliases + 'react-native': 'react-native-web', + // Skip expo-crypto alias during prepare phase since it imports react-native-web + crypto: isPreparePhase ? 'crypto' : 'expo-crypto', + 'expo-clipboard': path.resolve(__dirname, '../web/src/lib/expo-clipboard.jsx'), + jsbi: path.resolve(__dirname, '../../node_modules/jsbi/dist/jsbi.mjs'), // force consistent ESM build + // Dynamically load all monorepo package aliases from tsconfig.base.json + ...getTsconfigAliases(), + } + + return { + define: defines, + + resolve: { + extensions: ['.web.tsx', '.web.ts', '.web.js', '.tsx', '.ts', '.js'], + preserveSymlinks: true, + modules: [path.resolve(__dirname, 'node_modules')], + dedupe: [ + '@uniswap/sdk-core', + '@uniswap/v2-sdk', + '@uniswap/v3-sdk', + '@uniswap/v4-sdk', + '@uniswap/router-sdk', + '@uniswap/universal-router-sdk', + '@uniswap/uniswapx-sdk', + '@uniswap/permit2-sdk', + 'jsbi', + 'ethers', + 'react', + 'react-dom', + 'react-router', + 'cookie', + ], + alias: { + ...overrides, + }, + }, + + plugins: [ + { + name: 'transform-react-native-jsx', + async transform(code, id) { + // Transform JSX in react-native libraries that ship JSX in .js files + const needsJsxTransform = ['node_modules/expo-blur', 'node_modules/react-native-reanimated'].some((path) => + id.includes(path), + ) + + if (!needsJsxTransform || !id.endsWith('.js')) { + return null + } + + // Use Vite's transformWithEsbuild to handle JSX + return transformWithEsbuild(code, id, { + loader: 'jsx', + jsx: 'automatic', + }) + }, + }, + tsconfigPaths({ + // ignores tsconfig files in Nx generator template directories + skip: (dir) => dir.includes('files'), + }), + // TODO(INFRA-299): enable tamagui in production once building works + // !isPreparePhase && isProduction + // ? tamaguiPlugin({ + // config: '../../packages/ui/src/tamagui.config.ts', + // components: ['ui', 'uniswap', 'utilities'], + // optimize: true, + // importsWhitelist: ['constants.js'], + // }) + // : undefined, + svgr({ + svgrOptions: { + icon: false, + ref: true, + titleProp: true, + exportType: 'named', + svgo: true, + svgoConfig: { + plugins: [ + { + name: 'preset-default', + params: { + overrides: { removeViewBox: false }, + }, + }, + 'removeDimensions', + ], + }, + }, + include: '**/*.svg', + }), + // SVG import fix from web config + { + name: 'svg-import-fix', + transform(code: string) { + const regex = /import\s+([a-zA-Z0-9_$]+)\s+from\s+['"]([^'"]+\.svg)['"]/g + 'tamagui', + '@tamagui/web', + 'ui', + '@uniswap/sdk-core', + '@uniswap/v2-sdk', + '@uniswap/v3-sdk', + '@uniswap/v4-sdk', + '@uniswap/router-sdk', + '@uniswap/universal-router-sdk', + '@uniswap/uniswapx-sdk', + '@uniswap/permit2-sdk', + 'jsbi', + 'ethers', + 'react-router', + 'cookie', + 'cookie-es', + 'void-elements', + 'semver', + 'eventemitter3', + 'html-parse-stringify', + 'dayjs', + 'js-sha3', + 'hash.js', + 'elliptic', + 'bn.js', + ], + exclude: ['expo-clipboard', 'vite-plugin-node-polyfills'], + esbuildOptions: { + // Prefer .web.* extensions so react-native packages resolve to their web variants + // (e.g. react-native-svg/ReactNativeSVG.web.js instead of ReactNativeSVG.js which + // imports Fabric/codegen internals that don't exist on web). + resolveExtensions: ['.web.tsx', '.web.ts', '.web.js', '.tsx', '.ts', '.js'], + loader: { + '.js': 'jsx', + '.ts': 'ts', + '.tsx': 'tsx', + }, + }, + }, + + build: { + sourcemap: isProduction ? false : 'hidden', + minify: isProduction ? 'esbuild' : undefined, + rollupOptions: { + output: { + entryFileNames: 'assets/[name]-[hash].js', + chunkFileNames: 'assets/[name]-[hash].js', + assetFileNames: 'assets/[name]-[hash].[ext]', + }, + }, + chunkSizeWarningLimit: 800, + commonjsOptions: { + include: [/buffer/, /jsbi/, /node_modules/, /cookie/, /void-elements/, /@apollo\/client/], + transformMixedEsModules: true, + }, + }, + + // Support all prefixes (including no prefix) + envPrefix: [], + } + }, + + // Development server configuration + dev: { + server: { + port: 9998, // Different from webpack (9997) to avoid conflicts + }, + }, + + // Do not edit these defaults. Instead create the file apps/extension/web-ext.config.ts to override them. + // See the README for more information. + // https://wxt.dev/guide/essentials/config/browser-startup.html + webExt: { + startUrls: ['https://app.uniswap.org'], + + chromiumArgs: ['--user-data-dir=./.wxt/chrome-data'], + + // Optional: Open devtools in the browser automatically + // openDevtools: true, + + // Save profile changes between builds. + keepProfileChanges: true, + // Optional: Firefox profile for Firefox development + firefoxProfile: './.wxt/firefox-data', + }, +}) diff --git a/apps/mobile/.depcheckrc b/apps/mobile/.depcheckrc new file mode 100644 index 00000000..ee64ed59 --- /dev/null +++ b/apps/mobile/.depcheckrc @@ -0,0 +1,60 @@ +ignores: [ + # Dependencies that depcheck thinks are unused but are actually used + "@react-native-community/cli", + "@react-native-community/cli-platform-android", + "@react-native-community/cli-platform-ios", +<<<<<<< HEAD + "@luxamm/ethers-rs-mobile", +======= + "@uniswap/ethers-rs-mobile", +>>>>>>> upstream/main + "babel-loader", + "babel-jest", + "ts-jest", + "ts-node", + "babel-plugin-react-native-web", + "babel-plugin-transform-remove-console", + "cross-fetch", + "@datadog/datadog-ci", + "dotenv", + "expo-image", + "expo-localization", + "expo-linking", + "expo-modules-core", + "madge", + "postinstall-postinstall", + ## React Native Usage. These are modules imported by other packages in our repo, + ## but must be declared in `apps/mobile/package.json` too for proper RN autolinking. + "@amplitude/analytics-react-native", + "@react-native-masked-view/masked-view", + "@shopify/react-native-skia", + "react-native-asset", + "react-native-clean-project", + "react-native-image-colors", + "react-native-passkey", + "react-native-restart", + "sp-react-native-in-app-updates", + "typescript", +<<<<<<< HEAD +======= + "eslint", +>>>>>>> upstream/main + "@typescript/native-preview", + # Dependencies that depcheck thinks are missing but are actually present or never used + ## Internal packages / workspaces + "e2e", + "src", + "ui", + "tsconfig", + "config", + ## Subpackages of installed packages + "@redux-saga/core", + "@ethersproject/constants", + "@react-navigation/elements", + "metro-config", + ## used in sessions/api packages + "expo-secure-store", + ## used for expo remote build caching + "eas-build-cache-provider", + "expo-dev-client", + ] diff --git a/apps/mobile/.eslintignore b/apps/mobile/.eslintignore new file mode 100644 index 00000000..f065a060 --- /dev/null +++ b/apps/mobile/.eslintignore @@ -0,0 +1,19 @@ +.eslintrc.js +babel.config.js +jest.config.js +metro.config.js +node_modules + +storybook-static + +coverage + +# Ignore compiled and generated Maestro JavaScript files +.maestro/scripts/dist/ +.maestro/scripts/performance/dist/ + +# Ignore Maestro JavaScript files (these run in Maestro's GraalJS environment, not Node.js) +.maestro/scripts/performance/**/*.js + +# Don't ignore Maestro TypeScript source files +!.maestro/scripts/**/*.ts diff --git a/apps/mobile/.eslintrc.js b/apps/mobile/.eslintrc.js new file mode 100644 index 00000000..89b4e3f7 --- /dev/null +++ b/apps/mobile/.eslintrc.js @@ -0,0 +1,66 @@ +const rulesDirPlugin = require('eslint-plugin-rulesdir') +rulesDirPlugin.RULES_DIR = '../../pkgs/lx/eslint_rules' + +module.exports = { + root: true, + extends: ['@luxfi/eslint-config/mobile'], + plugins: ['rulesdir'], + ignorePatterns: [ + '.storybook/storybook.requires.ts', + '!.maestro', // Don't ignore .maestro directory + '!.maestro/**', // Don't ignore files in .maestro + ], + parserOptions: { + project: 'tsconfig.eslint.json', + tsconfigRootDir: __dirname, + ecmaFeatures: { + jsx: true, + }, + ecmaVersion: 2018, + sourceType: 'module', + }, + overrides: [ + { + files: ['index.js', 'src/index.ts', 'src/polyfills/index.ts', 'src/test/fixtures/*'], + rules: { + 'check-file/no-index': 'off', + }, + }, + { + files: ['*.ts', '*.tsx'], + rules: { + 'no-relative-import-paths/no-relative-import-paths': [ + 'error', + { + allowSameFolder: false, + prefix: 'src', + }, + ], + }, + }, + { + files: ['.maestro/scripts/**/*.ts'], + rules: { + // Maestro scripts have different import requirements + 'no-relative-import-paths/no-relative-import-paths': 'off', + // Allow console.log for Maestro scripts (needed for metrics output) + 'no-console': 'off', + // These scripts run in GraalJS environment, not React Native + 'react-native/no-unused-styles': 'off', + 'react-native/no-color-literals': 'off', + // Triple-slash references are needed for globals in Maestro environment + '@typescript-eslint/triple-slash-reference': 'off', + // Don't require React in scope for these non-React files + 'react/react-in-jsx-scope': 'off', + // Allow any for error handling in compile script + '@typescript-eslint/no-explicit-any': 'warn', + // These are utility modules that may not all be used immediately + 'import/no-unused-modules': 'off', + }, + }, + ], + rules: { + 'rulesdir/i18n': 'error', + 'rulesdir/no-redux-modals': 'error', + }, +} diff --git a/apps/mobile/.fingerprintignore b/apps/mobile/.fingerprintignore new file mode 100644 index 00000000..2bcbe3b6 --- /dev/null +++ b/apps/mobile/.fingerprintignore @@ -0,0 +1,49 @@ +# Generated files that shouldn't trigger rebuilds +ios/WidgetsCore/MobileSchema/**/*.swift +ios/WidgetsCore/Env.swift +ios/OneSignalNotificationServiceExtension/Env.swift +.maestro/scripts/dist/**/* +.maestro/scripts/performance/dist/**/* + +# Cache/temporary files +.expo/**/* +coverage/**/* +.tamagui/**/* +storybook-static/**/* +dist/**/* +build/**/* + +# Environment files +.env* +!.env.example + +# All node_modules - native deps are tracked via lock files +node_modules/**/* +../../node_modules/**/* + +# Build configuration that doesn't affect native +.gitignore + +# Autolinking outputs (redundant with lock files) +# These are derived from node_modules and package.json +expoAutolinkingConfig:android +expoAutolinkingConfig:ios +rncoreAutolinkingConfig:android +rncoreAutolinkingConfig:ios + +# Android build artifacts (if not already in native .gitignore) +android/app/build/**/* +android/.gradle/**/* +android/build/**/* +android/.cxx/**/* + +# iOS build artifacts (if not already in native .gitignore) +ios/build/**/* +ios/Pods/**/* +!ios/Podfile +!ios/Podfile.lock +ios/*.xcworkspace/**/* +!ios/*.xcworkspace/contents.xcworkspacedata + +# ensure patches are tracked +!../../patches/**/* diff --git a/apps/mobile/.gitignore b/apps/mobile/.gitignore new file mode 100644 index 00000000..6d1ce5cd --- /dev/null +++ b/apps/mobile/.gitignore @@ -0,0 +1,136 @@ +# OSX +# +.DS_Store + +.tamagui + +# Xcode +# +build/ +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 +xcuserdata +*.xccheckout +*.moved-aside +DerivedData +*.hmap +*.ipa +*.xcuserstate +**/.xcode.env.local +ios/GoogleService-Info.plist + +# Android/IntelliJ +# +build/ +.idea +.gradle +local.properties +*.iml +*.hprof +*.jks +keystore.properties +*.aab +.kotlin/ + +# node.js +# +node_modules/ +npm-debug.log +yarn-error.log + +# BUCK +buck-out/ +\.buckd/ +*.keystore +!debug.keystore + +# Yarn +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/sdks +!.yarn/versions + +# fastlane +# +# It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the +# screenshots whenever they are needed. +# For more information about the recommended setup visit: +# https://docs.fastlane.tools/best-practices/source-control/ + +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots +fastlane/.env +fastlane/.env.* +fastlane/builds + +# firebase +firebase-debug.log +firestore-debug.log +ui-debug.log + +# Bundle artifact +*.jsbundle +*.jsbundle.map + +# Detox artifacts +artifacts/ + +# CocoaPods +**/Pods/ + +# Android jsbundle +*.android.bundle + +# ccache +.ccache + +# hardhat network fork +/cache/ + +# Storybook +build-storybook.log +storybook-static/* + +# Private keys +.env.local + +# Built Items +ios/assets/ +./lib/ + +# Jest +coverage/ + +# Swift GraphQL codegen +# Ignores everything inside the schema folder except the README.md +!ios/WidgetsCore/MobileSchema/ +ios/WidgetsCore/MobileSchema/* +!ios/WidgetsCore/MobileSchema/README.md + +# Swift env +ios/WidgetsCore/Env.swift +ios/OneSignalNotificationServiceExtension/Env.swift + +# Expo +.expo +dist/ +web-build/ + +# Maestro E2E Scripts (compiled/generated) +.maestro/scripts/dist/ +.maestro/scripts/performance/dist/ + +# rnef (deprecated) +.rnef/ + +coverage/ + diff --git a/apps/mobile/.maestro/config.yaml b/apps/mobile/.maestro/config.yaml new file mode 100644 index 00000000..84cc69ec --- /dev/null +++ b/apps/mobile/.maestro/config.yaml @@ -0,0 +1,10 @@ +flows: + - 'flows/onboarding/*' + - 'flows/swap/*' + - 'flows/restore/*' + - 'flows/deeplinks/*' + - 'flows/explore/*' + - 'flows/portfolio/*' +baselineBranch: main +executionOrder: + continueOnFailure: true diff --git a/apps/mobile/.maestro/flows/deeplinks/deeplink-comprehensive.yaml b/apps/mobile/.maestro/flows/deeplinks/deeplink-comprehensive.yaml new file mode 100644 index 00000000..b67fed64 --- /dev/null +++ b/apps/mobile/.maestro/flows/deeplinks/deeplink-comprehensive.yaml @@ -0,0 +1,233 @@ +appId: com.uniswap.mobile.dev +jsEngine: graaljs +tags: + - ios-only +env: + E2E_RECOVERY_PHRASE: ${E2E_RECOVERY_PHRASE} + DATADOG_API_KEY: ${DATADOG_API_KEY} +--- +# Initialize tracking at the very beginning +- runScript: + file: ../../scripts/performance/dist/actions/init-tracking.js + +# Start flow tracking +- runScript: + file: ../../scripts/performance/dist/actions/start-flow.js + env: + FLOW_NAME: "deeplink-comprehensive" + +# Run prerequisite flows (tracked as sub-flows) +- runFlow: ../../shared-flows/start.yaml +- runFlow: ../../shared-flows/recover-fast.yaml + +# Open onramp deeplink +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: "openLink" + TARGET: "onramp-deeplink" + PHASE: "start" +- openLink: + link: "uniswap://app/fiatonramp?userAddress=0xEEf806b3Cae8fcecAe1793EE1e0B2c738F61C6bB&source=push" + autoVerify: true +# Handle iOS deeplink permission dialog (optional - only appears on first run) +- tapOn: + text: "Open" + optional: true +- waitForAnimationToEnd: + timeout: 5000 +- assertVisible: + id: ${output.testIds.ForFormTokenSelected} +- assertVisible: + id: ${output.testIds.BuyFormAmountInput} +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: "openLink" + TARGET: "onramp-deeplink" + PHASE: "end" +- killApp + +# Open widget deeplink +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: "openLink" + TARGET: "widget-deeplink" + PHASE: "start" +- openLink: + link: "uniswap://widget/#/tokens/ethereum/0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984" + autoVerify: true +- waitForAnimationToEnd: + timeout: 3000 +- assertVisible: + id: ${output.testIds.TokenDetailsHeaderText} + text: "Uniswap" +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: "openLink" + TARGET: "widget-deeplink" + PHASE: "end" +- killApp + +# Open swap deeplink +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: "openLink" + TARGET: "swap-deeplink" + PHASE: "start" +- openLink: + link: "uniswap://redirect?screen=swap&userAddress=0xEEf806b3Cae8fcecAe1793EE1e0B2c738F61C6bB&inputCurrencyId=1-0x6B175474E89094C44Da98b954EedeAC495271d0F&outputCurrencyId=1-0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984¤cyField=input&amount=100" + autoVerify: true +- waitForAnimationToEnd: + timeout: 3000 +- assertVisible: + id: ${output.testIds.SwapFormHeader} +- assertVisible: + id: ${output.testIds.AmountInputIn} +- assertVisible: + id: ${output.testIds.ChooseInputToken} +- assertVisible: + id: ${output.testIds.ChooseOutputToken} +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: "openLink" + TARGET: "swap-deeplink" + PHASE: "end" +- killApp + +# Open token details deeplink +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: "openLink" + TARGET: "token-details-deeplink" + PHASE: "start" +- openLink: + link: "uniswap://app/tokendetails?currencyId=10-0x6fd9d7ad17242c41f7131d257212c54a0e816691&source=push" + autoVerify: true +- waitForAnimationToEnd: + timeout: 3000 +- assertVisible: + id: ${output.testIds.TokenDetailsHeaderText} +- assertVisible: + id: ${output.testIds.TokenDetailsSwapButton} +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: "openLink" + TARGET: "token-details-deeplink" + PHASE: "end" +- killApp + +# Open transaction history deeplink +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: "openLink" + TARGET: "transaction-history-deeplink" + PHASE: "start" +- openLink: + link: "uniswap://redirect?screen=transaction&fiatOnRamp=true&userAddress=0xEEf806b3Cae8fcecAe1793EE1e0B2c738F61C6bB" + autoVerify: true +- waitForAnimationToEnd: + timeout: 3000 +- assertVisible: + id: ${output.testIds.ActivityContent} +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: "openLink" + TARGET: "transaction-history-deeplink" + PHASE: "end" +- killApp + +# Invalid deeplink (should fail gracefully and remain functional) +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: "openLink" + TARGET: "invalid-deeplink" + PHASE: "start" +- openLink: + link: "uniswap://invalid-path" + autoVerify: true +- waitForAnimationToEnd: + timeout: 3000 +- assertVisible: + id: ${output.testIds.AccountHeaderAvatar} +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: "openLink" + TARGET: "invalid-deeplink" + PHASE: "end" +- killApp + +# Open moonpayOnly onramp deeplink +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: "openLink" + TARGET: "moonpay-onramp-deeplink" + PHASE: "start" +- openLink: + link: "uniswap://app/fiatonramp?source=push&moonpayOnly=true&moonpayCurrencyCode=usdc&amount=200" + autoVerify: true +- waitForAnimationToEnd: + timeout: 5000 +- assertVisible: + id: ${output.testIds.ForFormTokenSelected} + text: "USD Coin" +- assertVisible: + id: ${output.testIds.BuyFormAmountInput} + text: "200" +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: "openLink" + TARGET: "moonpay-onramp-deeplink" + PHASE: "end" +- killApp + +# Open scantastic deeplink (when user scans QR code on the extension) +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: "openLink" + TARGET: "scantastic-deeplink" + PHASE: "start" +- openLink: + link: "uniswap://scantastic?pubKey=%7B%22alg%22%3A%22RSA-OAEP-256%22%2C%22kty%22%3A%22RSA%22%2C%22n%22%3A%224X4nRAEZ8FWoVmoQ5KrxcssIR7XpdcVo_y7yD1SgmYuXekvHMIYuLxxkxVTjsyxj2s9jctIHOhZ-g96w4oM8-HXjCJG_v55w6FZyDskllcmaGeUlZFwWkiqZ-PKkHCWxCe_dZGvL33sazS_L8P3eAxXEPEJMG9p9lxsIlPp7ki0GSyVjq4rrHgW0lIz6qy6WqHbnyJWQAMSPnZTGM697ZCdkW_GTD3MyqitBwK5xNQN8Pxgbu6S7xbQglanYNBbeMYpJ3X1PDl37sp16YwPm6ryGaX1ESDPHa3M7-_we_yQEUQvtU5t2dd8chISJX8L1D7s8iNxM1LxG_nZTwKnccRPtrzKj-osBMbfCoU4fiNS2LC7q6zsyHxgDpeFlrV--iboQ9TsaQ7RGaFOSKs0l74_dt8GvX2JtNJ0ah8K__eNg9q0xBD8DTdeY2duMTEKJZIKgEyX0KUiRpsbsNmm_76iqhhZyYvcb6mwvNnVcXPg_TabX7lQEEippd7JTWVnF2LKzldlUonchQSsbLEUlN_ALa0Nuq6GG1MVJ0JjSsNMcpin6rH9fPzmDKkqzM2qvhdyuV66vkS82Wj9tQpqXL_jkRk7bQsDlB-HiVbzM2oNPk6or5u6p5tJni0th6BZm4z-sYgmMj3D5xHeusyap-8dmS9J4mXDxGLL_NloaHY8%22%2C%22e%22%3A%22AQAB%22%7D&uuid=28c01911-8e69-46e9-b2f0-f5e719bb714b&vendor=Apple&model=Macintosh&browser=Chrome" + autoVerify: true +- waitForAnimationToEnd: + timeout: 3000 +- assertVisible: + id: ${output.testIds.ScantasticConfirmationTitle} +- assertVisible: + id: ${output.testIds.ScantasticDevice} + text: "Apple Macintosh" +- assertVisible: + id: ${output.testIds.ScantasticBrowser} + text: "Chrome" +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: "openLink" + TARGET: "scantastic-deeplink" + PHASE: "end" +- killApp + +# End flow tracking +- runScript: + file: ../../scripts/performance/dist/actions/end-flow.js + +# Upload metrics to Datadog (for Maestro Cloud) +- runScript: + file: ../../scripts/performance/upload-metrics.js + env: + DATADOG_API_KEY: ${DATADOG_API_KEY} + ENVIRONMENT: "maestro_cloud" diff --git a/apps/mobile/.maestro/flows/explore/favorite-token.yaml b/apps/mobile/.maestro/flows/explore/favorite-token.yaml new file mode 100644 index 00000000..f50cb274 --- /dev/null +++ b/apps/mobile/.maestro/flows/explore/favorite-token.yaml @@ -0,0 +1,190 @@ +appId: com.uniswap.mobile.dev +jsEngine: graaljs +tags: + - language-agnostic +env: + E2E_RECOVERY_PHRASE: ${E2E_RECOVERY_PHRASE} + DATADOG_API_KEY: ${DATADOG_API_KEY} +--- +# Initialize Performance Tracking +- runScript: + file: ../../scripts/performance/dist/actions/init-tracking.js + +# Start Flow Tracking +- runScript: + file: ../../scripts/performance/dist/actions/start-flow.js + env: + FLOW_NAME: 'explore-favorite-token' + +# Setup: Start app, recover wallet, and navigate to explore +- runFlow: ../../shared-flows/start.yaml +- runFlow: ../../shared-flows/recover-fast.yaml +- runFlow: ../../shared-flows/navigate-to-explore.yaml + +# --- Test Case: Search for and favorite a token --- +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tap' + TARGET: 'ExploreSearchInput' + PHASE: 'start' + +# Tap on the search input field +- tapOn: + id: ${output.testIds.ExploreSearchInput} + +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tap' + TARGET: 'ExploreSearchInput' + PHASE: 'end' + +# Search for a specific token (using USDC as it's popular and should be available) +- inputText: "USDC" + +# Wait for search results to load +- waitForAnimationToEnd +- extendedWaitUntil: + visible: + text: ".*USDC.*" + timeout: 5000 + +# Tap on the USDC token result +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tap' + TARGET: 'USDC_Token_Result' + PHASE: 'start' + +- tapOn: + text: "USDC" + index: 1 + +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tap' + TARGET: 'USDC_Token_Result' + PHASE: 'end' + +- waitForAnimationToEnd + +# Assert navigation to token details page +- assertVisible: + id: ${output.testIds.TokenDetailsHeaderText} + +# Wait for page to load +- waitForAnimationToEnd + +# --- Test Case: Favorite the token --- +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tap' + TARGET: 'FavoriteToken' + PHASE: 'start' + +# Tap the favorite heart icon +- tapOn: + id: 'token-details-favorite-button' + +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tap' + TARGET: 'FavoriteToken' + PHASE: 'end' + +- waitForAnimationToEnd + +# Go back to explore by tapping back twice +- tapOn: + id: ${output.testIds.Back} + +- extendedWaitUntil: + visible: 'noop' + timeout: 2000 + optional: true + +- tapOn: + id: ${output.testIds.Back} +- waitForAnimationToEnd + +# Look for Favorites section header +- extendedWaitUntil: + visible: + id: ${output.testIds.FavoriteTokensHeader} + timeout: 3000 + +# Verify USDC appears in the favorites section +- extendedWaitUntil: + visible: + id: "${output.testIds.FavoriteTokenCardPrefix}USDC" + timeout: 3000 + +# --- Test Case: Unfavorite the token --- +# Tap on the favorited USDC token from the favorites section +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tap' + TARGET: 'USDC_From_Favorites' + PHASE: 'start' + +- tapOn: + text: "USDC" + index: 0 + +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tap' + TARGET: 'USDC_From_Favorites' + PHASE: 'end' + +- waitForAnimationToEnd + +# Tap on the favorite button to unfavorite the token +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tap' + TARGET: 'UnfavoriteToken' + PHASE: 'start' + +# Tap the favorite heart icon to unfavorite +- tapOn: + id: ${output.testIds.TokenDetailsFavoriteButton} + +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tap' + TARGET: 'UnfavoriteToken' + PHASE: 'end' + +- waitForAnimationToEnd + +# Go back to explore +- tapOn: + id: ${output.testIds.Back} +- waitForAnimationToEnd + +# Verify USDC is no longer in favorites +- extendedWaitUntil: + notVisible: + id: "${output.testIds.FavoriteTokenCardPrefix}USDC" + timeout: 3000 + +# End Flow Tracking +- runScript: + file: ../../scripts/performance/dist/actions/end-flow.js + +# Upload Metrics (for CI/Maestro Cloud) +- runScript: + file: ../../scripts/performance/upload-metrics.js + env: + DATADOG_API_KEY: ${DATADOG_API_KEY} + ENVIRONMENT: 'maestro_cloud' diff --git a/apps/mobile/.maestro/flows/explore/filters-and-sorts.yaml b/apps/mobile/.maestro/flows/explore/filters-and-sorts.yaml new file mode 100644 index 00000000..76c7fa63 --- /dev/null +++ b/apps/mobile/.maestro/flows/explore/filters-and-sorts.yaml @@ -0,0 +1,267 @@ +appId: com.uniswap.mobile.dev +jsEngine: graaljs +env: + E2E_RECOVERY_PHRASE: ${E2E_RECOVERY_PHRASE} + DATADOG_API_KEY: ${DATADOG_API_KEY} +--- +# Initialize Performance Tracking +- runScript: + file: ../../scripts/performance/dist/actions/init-tracking.js + +# Start Flow Tracking +- runScript: + file: ../../scripts/performance/dist/actions/start-flow.js + env: + FLOW_NAME: 'explore-filters-and-sorts' + +# Setup: Start app, recover wallet, and navigate to explore +- runFlow: ../../shared-flows/start.yaml +- runFlow: ../../shared-flows/recover-fast.yaml +- runFlow: ../../shared-flows/navigate-to-explore.yaml + +# --- Test Case: Filter by chain --- +# Note: The network pills act as the filter mechanism directly +# There's no separate filter modal - just tap the network pill to filter + +- waitForAnimationToEnd + +# Select Unichain chain filter (chainId 130) +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tap' + TARGET: 'ExploreFilterChain130' + PHASE: 'start' + +# Tap on Unichain filter (chainId 130) +- tapOn: + id: 'explore-filter-chain-130' + +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tap' + TARGET: 'ExploreFilterChain130' + PHASE: 'end' + +- waitForAnimationToEnd + +# Assert that the filter has been applied +# Look for Unichain-specific token in the results +- extendedWaitUntil: + visible: + text: ".*Unichain ETH.*" + timeout: 5000 + +# --- Test Case: Sort by Market Cap --- +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tap' + TARGET: 'ExploreSortByMarketCap' + PHASE: 'start' + +# Tap on the sort button +- tapOn: + id: ${output.testIds.ExploreSortButton} + +- waitForAnimationToEnd + +# Wait for sort options to appear +- extendedWaitUntil: + visible: + text: ".*Market.*" + timeout: 3000 + +- tapOn: + text: ".*Market.*" + +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tap' + TARGET: 'ExploreSortByMarketCap' + PHASE: 'end' + +- waitForAnimationToEnd + +# --- Test Case: Sort by TVL --- +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tap' + TARGET: 'ExploreSortByTVL' + PHASE: 'start' + +- extendedWaitUntil: + visible: 'noop' + timeout: 2000 + optional: true + +- tapOn: + id: ${output.testIds.ExploreSortButton} + +- waitForAnimationToEnd +- extendedWaitUntil: + visible: + text: ".*TVL.*" + timeout: 3000 + +- tapOn: + text: ".*TVL.*" + +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tap' + TARGET: 'ExploreSortByTVL' + PHASE: 'end' + +- waitForAnimationToEnd + +# --- Test Case: Sort by Price Increase --- +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tap' + TARGET: 'ExploreSortByPriceIncrease' + PHASE: 'start' + +- extendedWaitUntil: + visible: 'noop' + timeout: 2000 + optional: true + +- tapOn: + id: ${output.testIds.ExploreSortButton} + +- waitForAnimationToEnd + +# Wait for sort options to appear +- extendedWaitUntil: + visible: + text: ".*Price.*increase.*" + timeout: 3000 + +- tapOn: + text: ".*Price.*increase.*" + +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tap' + TARGET: 'ExploreSortByPriceIncrease' + PHASE: 'end' + +- waitForAnimationToEnd + +# --- Test Case: Sort by Price Decrease --- +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tap' + TARGET: 'ExploreSortByPriceDecrease' + PHASE: 'start' + +- extendedWaitUntil: + visible: 'noop' + timeout: 2000 + optional: true + +- tapOn: + id: ${output.testIds.ExploreSortButton} + +- waitForAnimationToEnd + +# Wait for sort options to appear +- extendedWaitUntil: + visible: + text: ".*Price.*decrease.*" + timeout: 3000 + +- tapOn: + text: ".*Price.*decrease.*" + +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tap' + TARGET: 'ExploreSortByPriceDecrease' + PHASE: 'end' + +- waitForAnimationToEnd + +# --- Test Case: Sort by Volume --- +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tap' + TARGET: 'ExploreSortByVolume' + PHASE: 'start' + +- extendedWaitUntil: + visible: 'noop' + timeout: 2000 + optional: true + +- tapOn: + id: ${output.testIds.ExploreSortButton} + +- waitForAnimationToEnd + +# Wait for sort options to appear +- extendedWaitUntil: + visible: + text: ".*Volume.*" + timeout: 3000 + +- tapOn: + text: ".*Volume.*" + +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tap' + TARGET: 'ExploreSortByVolume' + PHASE: 'end' + +- waitForAnimationToEnd + +# --- Test Case: Clear filters --- +# Tap on "All" networks to reset to default state +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tap' + TARGET: 'ExploreFilterChainAll' + PHASE: 'start' + +# Tap on Ethereum filter to scroll all chains filter back into view +- tapOn: + id: 'explore-filter-chain-1' + +- waitForAnimationToEnd + +# Tap on All networks filter to show all chains +- tapOn: + id: 'explore-filter-chain-all' + +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tap' + TARGET: 'ExploreFilterChainAll' + PHASE: 'end' + +- waitForAnimationToEnd + +# End Flow Tracking +- runScript: + file: ../../scripts/performance/dist/actions/end-flow.js + +# Upload Metrics (for CI/Maestro Cloud) +- runScript: + file: ../../scripts/performance/upload-metrics.js + env: + DATADOG_API_KEY: ${DATADOG_API_KEY} + ENVIRONMENT: 'maestro_cloud' diff --git a/apps/mobile/.maestro/flows/explore/search.yaml b/apps/mobile/.maestro/flows/explore/search.yaml new file mode 100644 index 00000000..51bbc466 --- /dev/null +++ b/apps/mobile/.maestro/flows/explore/search.yaml @@ -0,0 +1,231 @@ +appId: com.uniswap.mobile.dev +jsEngine: graaljs +tags: + - language-agnostic +env: + E2E_RECOVERY_PHRASE: ${E2E_RECOVERY_PHRASE} + DATADOG_API_KEY: ${DATADOG_API_KEY} +--- +# Initialize Performance Tracking +- runScript: + file: ../../scripts/performance/dist/actions/init-tracking.js + +# Start Flow Tracking +- runScript: + file: ../../scripts/performance/dist/actions/start-flow.js + env: + FLOW_NAME: 'explore-search' + +# Setup: Start app, recover wallet, and navigate to explore +- runFlow: ../../shared-flows/start.yaml +- runFlow: ../../shared-flows/recover-fast.yaml +- runFlow: ../../shared-flows/navigate-to-explore.yaml + +# --- Test Case: Search for a token --- +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tap' + TARGET: 'ExploreSearchInput' + PHASE: 'start' + +# Tap on the search input field +- tapOn: + id: ${output.testIds.ExploreSearchInput} + +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tap' + TARGET: 'ExploreSearchInput' + PHASE: 'end' + +# Enter search text for a token +- inputText: "Uniswap" + +# Wait for search results to load +- waitForAnimationToEnd +- extendedWaitUntil: + visible: + text: ".*UNI.*" + timeout: 5000 + +# Tap on the UNI token result (looking for UNI text in the result) +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tap' + TARGET: 'UNI_Token_Result' + PHASE: 'start' + +- tapOn: + text: "UNI" + index: 0 + +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tap' + TARGET: 'UNI_Token_Result' + PHASE: 'end' + +- waitForAnimationToEnd + +# Assert navigation to token details page +- assertVisible: + id: ${output.testIds.TokenDetailsHeaderText} + +# Verify swap button is visible for the token +- assertVisible: + id: ${output.testIds.TokenDetailsSwapButton} + +# Go back to main page +- tapOn: + id: ${output.testIds.Back} +- waitForAnimationToEnd + +# --- Test Case: Search for a wallet --- +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tap' + TARGET: 'ExploreSearchInput_Wallet' + PHASE: 'start' + +# Tap on the search input field again +- tapOn: + id: ${output.testIds.ExploreSearchInput} + +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tap' + TARGET: 'ExploreSearchInput_Wallet' + PHASE: 'end' + +# Switch to Wallets tab +- tapOn: + id: "Wallets" + optional: true + +# Enter a wallet address or ENS name +- eraseText: 30 + +- extendedWaitUntil: + visible: 'noop' + timeout: 2000 + optional: true + +- inputText: "hayden.eth" + +# Wait for search results to load +- waitForAnimationToEnd +- extendedWaitUntil: + visible: + text: "hayden.eth" + timeout: 7000 + +# Tap on the wallet result +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tap' + TARGET: 'hayden_eth_Wallet' + PHASE: 'start' + +# Tap on the second occurrence (search result, not search bar) +# or alternatively tap on the partial address which should be unique +- tapOn: + text: "hayden.eth" + index: 1 + +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tap' + TARGET: 'hayden_eth_Wallet' + PHASE: 'end' + +- waitForAnimationToEnd + +# Assert navigation to wallet/profile details page +# Check for some wallet-specific UI elements +- extendedWaitUntil: + visible: + text: ".*hayden.*" + timeout: 3000 + +# Verify the send action button is visible (confirms we're on a wallet details page) +- assertVisible: + id: ${output.testIds.Send} + +# Go back to explore +- tapOn: + id: ${output.testIds.Back} +- waitForAnimationToEnd + +# --- Test Case: Search for a unitag --- + +# Tap on the search input field again +- tapOn: + id: ${output.testIds.ExploreSearchInput} + +# Enter a unitag +- eraseText: 30 +- extendedWaitUntil: + visible: 'noop' + timeout: 2000 + optional: true + +- inputText: "test" + +# Wait for search results to load +- waitForAnimationToEnd +- extendedWaitUntil: + visible: + id: "test.uni.eth" + timeout: 8000 + +# Tap on the wallet result +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tap' + TARGET: 'test_uni_eth_Wallet' + PHASE: 'start' + +# Tap on the second occurrence (search result, not search bar) +# or alternatively tap on the partial address which should be unique +- tapOn: + id: "test.uni.eth" + +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tap' + TARGET: 'test_uni_eth_Wallet' + PHASE: 'end' + +- waitForAnimationToEnd + +# Assert navigation to wallet/profile details page +# Check for some wallet-specific UI elements +- extendedWaitUntil: + visible: + text: ".*test.*" + timeout: 3000 + +# Verify the Send button is visible (confirms we're on a wallet details page) +- assertVisible: + id: ${output.testIds.Send} + +# End Flow Tracking +- runScript: + file: ../../scripts/performance/dist/actions/end-flow.js + +# Upload Metrics (for CI/Maestro Cloud) +- runScript: + file: ../../scripts/performance/upload-metrics.js + env: + DATADOG_API_KEY: ${DATADOG_API_KEY} + ENVIRONMENT: 'maestro_cloud' diff --git a/apps/mobile/.maestro/flows/onboarding/new-wallet.yaml b/apps/mobile/.maestro/flows/onboarding/new-wallet.yaml new file mode 100644 index 00000000..bb6f4697 --- /dev/null +++ b/apps/mobile/.maestro/flows/onboarding/new-wallet.yaml @@ -0,0 +1,116 @@ +appId: com.uniswap.mobile.dev +jsEngine: graaljs +tags: + - language-agnostic +env: + DATADOG_API_KEY: ${DATADOG_API_KEY} +--- +# Initialize tracking at the very beginning +- runScript: + file: ../../scripts/performance/dist/actions/init-tracking.js + +# Start flow tracking (includes setup time) +- runScript: + file: ../../scripts/performance/dist/actions/start-flow.js + env: + FLOW_NAME: 'onboarding-new-wallet' + +# Run prerequisite flows (tracked as sub-flows) +- runFlow: ../../shared-flows/start.yaml + +# Track Create Account tap +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tapOn' + TARGET: 'CreateAccount' + PHASE: 'start' +- tapOn: + id: ${output.testIds.CreateAccount} +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tapOn' + TARGET: 'CreateAccount' + PHASE: 'end' +- waitForAnimationToEnd + +# Track Skip Unitag tap +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tapOn' + TARGET: 'Skip-Unitag' + PHASE: 'start' +- tapOn: + id: ${output.testIds.Skip} # unitag +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tapOn' + TARGET: 'Skip-Unitag' + PHASE: 'end' +- waitForAnimationToEnd + +# Track Skip Notifications tap +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tapOn' + TARGET: 'Skip-Notifications' + PHASE: 'start' +- tapOn: + id: ${output.testIds.Skip} # notifications +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tapOn' + TARGET: 'Skip-Notifications' + PHASE: 'end' +- waitForAnimationToEnd + +# Track Skip FaceID tap +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tapOn' + TARGET: 'Skip-FaceID' + PHASE: 'start' +- tapOn: + id: ${output.testIds.Skip} # faceid +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tapOn' + TARGET: 'Skip-FaceID' + PHASE: 'end' + +# Run biometrics confirmation (tracked internally as sub-flow) +- runFlow: ../../shared-flows/biometrics-confirm.yaml + +# Track home screen assertion +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'assertVisible' + TARGET: 'AccountHeaderAvatar' + PHASE: 'start' +- assertVisible: + id: ${output.testIds.AccountHeaderAvatar} +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'assertVisible' + TARGET: 'AccountHeaderAvatar' + PHASE: 'end' + +# End flow tracking +- runScript: + file: ../../scripts/performance/dist/actions/end-flow.js + +# Upload metrics to Datadog (for Maestro Cloud) +- runScript: + file: ../../scripts/performance/upload-metrics.js + env: + DATADOG_API_KEY: ${DATADOG_API_KEY} + ENVIRONMENT: 'maestro_cloud' diff --git a/apps/mobile/.maestro/flows/portfolio/portfolio-chart-happy-path.yaml b/apps/mobile/.maestro/flows/portfolio/portfolio-chart-happy-path.yaml new file mode 100644 index 00000000..cf24e993 --- /dev/null +++ b/apps/mobile/.maestro/flows/portfolio/portfolio-chart-happy-path.yaml @@ -0,0 +1,151 @@ +appId: com.uniswap.mobile.dev +jsEngine: graaljs +env: + E2E_RECOVERY_PHRASE: ${E2E_RECOVERY_PHRASE} + DATADOG_API_KEY: ${DATADOG_API_KEY} +--- +- runScript: + file: ../../scripts/performance/dist/actions/init-tracking.js + +- runScript: + file: ../../scripts/performance/dist/actions/start-flow.js + env: + FLOW_NAME: 'portfolio-chart-happy-path' + +- runFlow: ../../shared-flows/start.yaml +- runFlow: ../../shared-flows/recover-fast.yaml +- openLink: uniswap://e2e/override-gates?gates=profit_loss +- tapOn: + text: "Open" + optional: true +- waitForAnimationToEnd + +- extendedWaitUntil: + visible: + id: ${output.testIds.PortfolioChartCollapsed} + timeout: 15000 + +- assertVisible: + id: ${output.testIds.PortfolioChartCollapsed} + +- assertNotVisible: + id: ${output.testIds.PortfolioChartExpanded} + +- assertNotVisible: + id: ${output.testIds.PortfolioChartPeriodPrefix}1w + +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tapOn' + TARGET: 'PortfolioChartToggleExpand' + PHASE: 'start' +- tapOn: + id: ${output.testIds.PortfolioChartToggle} +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tapOn' + TARGET: 'PortfolioChartToggleExpand' + PHASE: 'end' +- waitForAnimationToEnd + +- assertVisible: + id: ${output.testIds.PortfolioChartExpanded} + +- assertNotVisible: + id: ${output.testIds.PortfolioChartCollapsed} + +- assertVisible: + id: ${output.testIds.PortfolioPerformance} + +- assertVisible: + id: ${output.testIds.PortfolioChartPeriodPrefix}1h + +- assertVisible: + id: ${output.testIds.PortfolioChartPeriodPrefix}1d + +- assertVisible: + id: ${output.testIds.PortfolioChartPeriodPrefix}1w + +- assertVisible: + id: ${output.testIds.PortfolioChartPeriodPrefix}1m + +- assertVisible: + id: ${output.testIds.PortfolioChartPeriodPrefix}1y + +- assertVisible: + id: ${output.testIds.PortfolioChartPeriodPrefix}all + +- assertVisible: + id: ${output.testIds.PortfolioChartSelectedPeriodPrefix}1d + +- tapOn: + id: ${output.testIds.PortfolioChartPeriodPrefix}1h +- waitForAnimationToEnd +- assertVisible: + id: ${output.testIds.PortfolioChartSelectedPeriodPrefix}1h + +- tapOn: + id: ${output.testIds.PortfolioChartPeriodPrefix}1w +- waitForAnimationToEnd +- assertVisible: + id: ${output.testIds.PortfolioChartSelectedPeriodPrefix}1w + +- tapOn: + id: ${output.testIds.PortfolioChartPeriodPrefix}1m +- waitForAnimationToEnd +- assertVisible: + id: ${output.testIds.PortfolioChartSelectedPeriodPrefix}1m + +- tapOn: + id: ${output.testIds.PortfolioChartPeriodPrefix}1y +- waitForAnimationToEnd +- assertVisible: + id: ${output.testIds.PortfolioChartSelectedPeriodPrefix}1y + +- tapOn: + id: ${output.testIds.PortfolioChartPeriodPrefix}all +- waitForAnimationToEnd +- assertVisible: + id: ${output.testIds.PortfolioChartSelectedPeriodPrefix}all + +- tapOn: + id: ${output.testIds.PortfolioChartPeriodPrefix}1d +- waitForAnimationToEnd +- assertVisible: + id: ${output.testIds.PortfolioChartSelectedPeriodPrefix}1d + +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tapOn' + TARGET: 'PortfolioChartToggleCollapse' + PHASE: 'start' +- tapOn: + id: ${output.testIds.PortfolioChartToggle} +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tapOn' + TARGET: 'PortfolioChartToggleCollapse' + PHASE: 'end' +- waitForAnimationToEnd + +- assertVisible: + id: ${output.testIds.PortfolioChartCollapsed} + +- assertNotVisible: + id: ${output.testIds.PortfolioChartExpanded} + +- assertNotVisible: + id: ${output.testIds.PortfolioChartPeriodPrefix}1w + +- runScript: + file: ../../scripts/performance/dist/actions/end-flow.js + +- runScript: + file: ../../scripts/performance/upload-metrics.js + env: + DATADOG_API_KEY: ${DATADOG_API_KEY} + ENVIRONMENT: 'maestro_cloud' diff --git a/apps/mobile/.maestro/flows/portfolio/view-token-details.yaml b/apps/mobile/.maestro/flows/portfolio/view-token-details.yaml new file mode 100644 index 00000000..8218f944 --- /dev/null +++ b/apps/mobile/.maestro/flows/portfolio/view-token-details.yaml @@ -0,0 +1,154 @@ +appId: com.uniswap.mobile.dev +jsEngine: graaljs +env: + E2E_RECOVERY_PHRASE: ${E2E_RECOVERY_PHRASE} + DATADOG_API_KEY: ${DATADOG_API_KEY} +--- +# Initialize Performance Tracking +- runScript: + file: ../../scripts/performance/dist/actions/init-tracking.js + +# Start Flow Tracking +- runScript: + file: ../../scripts/performance/dist/actions/start-flow.js + env: + FLOW_NAME: 'portfolio-view-token-details' + +# Setup: Start app and recover wallet +- runFlow: ../../shared-flows/start.yaml +- runFlow: ../../shared-flows/recover-fast.yaml + +# Wait for home screen to be ready +- extendedWaitUntil: + visible: 'noop' + timeout: 2000 + optional: true + +# --- Test Case: Native Asset (ETH) Details --- +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tap' + TARGET: 'TokenBalanceItem_ETH' + PHASE: 'start' + +- tapOn: + id: 'TokenBalanceItem_ETH' + +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tap' + TARGET: 'TokenBalanceItem_ETH' + PHASE: 'end' + +- waitForAnimationToEnd + +# Verify we're on the token details screen +- assertVisible: + id: ${output.testIds.TokenDetailsHeaderText} + +# Assert expected action buttons are shown for a native token +- assertVisible: + id: ${output.testIds.TokenDetailsSwapButton} + +# Note: Copy address button is present but disabled for native tokens + +# Chain pill checks removed - test wallet doesn't have balance on other chains +# Could be re-enabled if test wallet has multi-chain balances + +- waitForAnimationToEnd + +# Go back to portfolio +- tapOn: + id: ${output.testIds.Back} +- waitForAnimationToEnd + +# Wait for portfolio to be ready +- extendedWaitUntil: + visible: + id: 'TokenBalanceItem_cbBTC' + timeout: 3000 + +# --- Test Case: Standard Token (cbBTC) Details --- +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tap' + TARGET: 'TokenBalanceItem_cbBTC' + PHASE: 'start' + +# Wait for cbBTC token to be visible (it might need to load) +- extendedWaitUntil: + visible: + id: 'TokenBalanceItem_cbBTC' + timeout: 5000 + +- tapOn: + id: 'TokenBalanceItem_cbBTC' + +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tap' + TARGET: 'TokenBalanceItem_cbBTC' + PHASE: 'end' + +- waitForAnimationToEnd + +# Verify we're on the token details screen +- assertVisible: + id: ${output.testIds.TokenDetailsHeaderText} + +# Assert expected action buttons are shown for a standard token +- assertVisible: + id: ${output.testIds.TokenDetailsSwapButton} + +# Standard tokens have a contract address to copy +- assertVisible: + id: ${output.testIds.TokenDetailsCopyAddressButton} + +# Verify copy address functionality +- tapOn: + id: ${output.testIds.TokenDetailsCopyAddressButton} + +- waitForAnimationToEnd + +# Handle contract address warning modal (may appear on first copy attempt) +# Modal shows: "Do not send crypto to this address" +- extendedWaitUntil: + visible: + id: ${output.testIds.ContractAddressExplainerModalWarning} + timeout: 2000 + optional: true + +# If modal is present, click "I understand" to acknowledge and copy +- tapOn: + id: ${output.testIds.Confirm} + optional: true + +- waitForAnimationToEnd + +# Check for copy confirmation - may show after modal is dismissed +# Toast notification might be too quick to catch reliably +- extendedWaitUntil: + visible: + text: ".*[Cc]opied.*" + timeout: 2000 + optional: true + +# Go back to portfolio +- tapOn: + id: ${output.testIds.Back} +- waitForAnimationToEnd + +# End Flow Tracking +- runScript: + file: ../../scripts/performance/dist/actions/end-flow.js + +# Upload Metrics (for CI/Maestro Cloud) +- runScript: + file: ../../scripts/performance/upload-metrics.js + env: + DATADOG_API_KEY: ${DATADOG_API_KEY} + ENVIRONMENT: 'maestro_cloud' diff --git a/apps/mobile/.maestro/flows/restore/restore-missing-seed.yaml b/apps/mobile/.maestro/flows/restore/restore-missing-seed.yaml new file mode 100644 index 00000000..a5c3b4a5 --- /dev/null +++ b/apps/mobile/.maestro/flows/restore/restore-missing-seed.yaml @@ -0,0 +1,338 @@ +appId: com.uniswap.mobile.dev +jsEngine: graaljs +env: + DATADOG_API_KEY: ${DATADOG_API_KEY} +--- +# Initialize tracking at the very beginning +- runScript: + file: ../../scripts/performance/dist/actions/init-tracking.js + +# Start flow tracking (includes setup time) +- runScript: + file: ../../scripts/performance/dist/actions/start-flow.js + env: + FLOW_NAME: 'restore-missing-seed' + +# Run prerequisite flows (tracked as sub-flows) +- runFlow: ../../shared-flows/start.yaml +- runFlow: ../../shared-flows/recover-fast.yaml +- runFlow: ../../shared-flows/delete-seed-phrase.yaml + +# Track app restart +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'killApp' + TARGET: 'app' + PHASE: 'start' +- killApp +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'killApp' + TARGET: 'app' + PHASE: 'end' + +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'launchApp' + TARGET: 'app' + PHASE: 'start' +- launchApp +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'launchApp' + TARGET: 'app' + PHASE: 'end' + +- assertVisible: + text: 'Restore your wallet' + +# Track cancel tap +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tapOn' + TARGET: 'cancel' + PHASE: 'start' +- tapOn: + id: 'cancel' +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tapOn' + TARGET: 'cancel' + PHASE: 'end' +- waitForAnimationToEnd + +# Track settings navigation +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tapOn' + TARGET: 'settings-icon' + PHASE: 'start' +- tapOn: + id: 'account-header-settings-icon' +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tapOn' + TARGET: 'settings-icon' + PHASE: 'end' +- waitForAnimationToEnd + +# Track recovery phrase navigation +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tapOn' + TARGET: 'recovery-phrase' + PHASE: 'start' +- tapOn: + id: 'wallet-settings-recovery-phrase' +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tapOn' + TARGET: 'recovery-phrase' + PHASE: 'end' +- waitForAnimationToEnd +- assertVisible: + text: 'Restore your wallet' + +# Track continue tap +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tapOn' + TARGET: 'continue' + PHASE: 'start' +- tapOn: + id: 'continue' +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tapOn' + TARGET: 'continue' + PHASE: 'end' + +- assertVisible: + text: 'Select how to restore your wallet' +- assertVisible: + id: 'onboarding-import-seed-phrase' +- assertVisible: + id: 'restore-from-cloud' +- assertVisible: + id: 'onboarding-view-private-keys' + +# Track restore from cloud attempt +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tapOn' + TARGET: 'restore-from-cloud' + PHASE: 'start' +- tapOn: + id: 'restore-from-cloud' +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tapOn' + TARGET: 'restore-from-cloud' + PHASE: 'end' + +- extendedWaitUntil: + visible: + text: 'No backups found' + timeout: 5000 # wait for cloud back to fail + +# Track incorrect seed phrase input +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'inputText' + TARGET: 'incorrect-seed-phrase' + PHASE: 'start' +- inputText: 'purchase arrest rotate cave alone walk naive claim day tube pact soap' # random incorrect phrase +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'inputText' + TARGET: 'incorrect-seed-phrase' + PHASE: 'end' +- waitForAnimationToEnd: # workaround for a native bug where the validation check has a delay + timeout: 1000 +- tapOn: + id: 'continue' +- assertVisible: + text: 'Wrong recovery phrase' + +# Track back navigation +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tapOn' + TARGET: 'back' + PHASE: 'start' +- tapOn: + id: 'back' +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tapOn' + TARGET: 'back' + PHASE: 'end' +- waitForAnimationToEnd + +# Track import seed phrase selection +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tapOn' + TARGET: 'import-seed-phrase' + PHASE: 'start' +- tapOn: + id: 'onboarding-import-seed-phrase' +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tapOn' + TARGET: 'import-seed-phrase' + PHASE: 'end' +- waitForAnimationToEnd + +# Another incorrect seed phrase attempt +# Track second incorrect seed phrase input +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'inputText' + TARGET: 'incorrect-seed-phrase-retry' + PHASE: 'start' +- inputText: 'purchase arrest rotate cave alone walk naive claim day tube pact soap' # random incorrect phrase +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'inputText' + TARGET: 'incorrect-seed-phrase-retry' + PHASE: 'end' + +# workaround for a native bug where the validation check has a delay +- extendedWaitUntil: + visible: 'noop' + timeout: 1000 + optional: true + +- tapOn: + id: 'continue' +- assertVisible: + text: 'Wrong recovery phrase' + +# Track correct seed phrase input +- eraseText: 150 +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'inputText' + TARGET: 'correct-seed-phrase' + PHASE: 'start' +- inputText: ${E2E_RECOVERY_PHRASE} +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'inputText' + TARGET: 'correct-seed-phrase' + PHASE: 'end' +- tapOn: + id: 'continue' +- assertVisible: + id: 'account-header-avatar' +- runFlow: ../../shared-flows/delete-seed-phrase.yaml + +# Second part - view private keys flow +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'killApp' + TARGET: 'app' + PHASE: 'start' +- killApp +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'killApp' + TARGET: 'app' + PHASE: 'end' + +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'launchApp' + TARGET: 'app' + PHASE: 'start' +- launchApp +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'launchApp' + TARGET: 'app' + PHASE: 'end' + +- waitForAnimationToEnd +- tapOn: + id: 'cancel' +- waitForAnimationToEnd +- tapOn: + id: 'account-header-settings-icon' +- waitForAnimationToEnd +- assertNotVisible: + id: 'wallet-settings-private-keys' +- tapOn: + id: 'wallet-settings-recovery-phrase' +- waitForAnimationToEnd +- tapOn: + id: 'continue' +- waitForAnimationToEnd + +# Track view private keys selection +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tapOn' + TARGET: 'view-private-keys' + PHASE: 'start' +- tapOn: + id: 'onboarding-view-private-keys' +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tapOn' + TARGET: 'view-private-keys' + PHASE: 'end' +- waitForAnimationToEnd +- tapOn: + id: 'continue' +- waitForAnimationToEnd +- assertNotVisible: + id: 'view-native-private-key' +- tapOn: + id: 'continue' +- waitForAnimationToEnd +- assertVisible: + id: 'view-native-private-key' +- tapOn: + id: 'view-native-private-keys-on-copied' + +# End flow tracking +- runScript: + file: ../../scripts/performance/dist/actions/end-flow.js + +# Upload metrics to Datadog (for Maestro Cloud) +- runScript: + file: ../../scripts/performance/upload-metrics.js + env: + DATADOG_API_KEY: ${DATADOG_API_KEY} + ENVIRONMENT: 'maestro_cloud' diff --git a/apps/mobile/.maestro/flows/restore/restore-new-device.yaml b/apps/mobile/.maestro/flows/restore/restore-new-device.yaml new file mode 100644 index 00000000..08ab78e2 --- /dev/null +++ b/apps/mobile/.maestro/flows/restore/restore-new-device.yaml @@ -0,0 +1,254 @@ +appId: com.uniswap.mobile.dev +jsEngine: graaljs +env: + DATADOG_API_KEY: ${DATADOG_API_KEY} +--- +# Initialize tracking at the very beginning +- runScript: + file: ../../scripts/performance/dist/actions/init-tracking.js + +# Start flow tracking (includes setup time) +- runScript: + file: ../../scripts/performance/dist/actions/start-flow.js + env: + FLOW_NAME: 'restore-new-device' + +# Run prerequisite flows (tracked as sub-flows) +- runFlow: ../../shared-flows/start.yaml +- runFlow: ../../shared-flows/recover-fast.yaml + +# Wait for the SettingsIcon button to be visible; without this, e2e test sometimes registers a success but the button is not actually tapped. +- extendedWaitUntil: + visible: 'noop' + timeout: 2000 + optional: true + +# Track settings navigation +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tapOn' + TARGET: 'settings-icon' + PHASE: 'start' +- tapOn: + id: 'account-header-settings-icon' +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tapOn' + TARGET: 'settings-icon' + PHASE: 'end' +- waitForAnimationToEnd + +# Swipe to dev modal +- scrollUntilVisible: + element: + id: 'app-settings-dev-modal' + direction: DOWN +- waitForAnimationToEnd + +# Track dev modal tap +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tapOn' + TARGET: 'dev-modal' + PHASE: 'start' +- tapOn: + id: 'app-settings-dev-modal' +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tapOn' + TARGET: 'dev-modal' + PHASE: 'end' +- waitForAnimationToEnd + +# Track seed phrase accordion tap +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tapOn' + TARGET: 'seed-phrase-accordion' + PHASE: 'start' +- tapOn: + id: 'seed-phrase-private-keys-accordion' +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tapOn' + TARGET: 'seed-phrase-accordion' + PHASE: 'end' + +# Track delete seed phrase +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tapOn' + TARGET: 'delete-seed-phrase' + PHASE: 'start' +- tapOn: + id: 'delete-seed-phrase-button' +- tapOn: 'Delete' +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tapOn' + TARGET: 'delete-seed-phrase' + PHASE: 'end' + +# Track delete private keys +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tapOn' + TARGET: 'delete-private-keys' + PHASE: 'start' +- tapOn: + id: 'delete-private-keys-button' +- tapOn: 'Delete' +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tapOn' + TARGET: 'delete-private-keys' + PHASE: 'end' + +# Track app restart +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'killApp' + TARGET: 'app' + PHASE: 'start' +- killApp +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'killApp' + TARGET: 'app' + PHASE: 'end' + +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'launchApp' + TARGET: 'app' + PHASE: 'start' +- launchApp +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'launchApp' + TARGET: 'app' + PHASE: 'end' +- waitForAnimationToEnd + +- assertVisible: + text: 'Recover your wallet' +- assertVisible: + id: 'continue' +- assertNotVisible: + id: 'cancel' + +# Track continue tap +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tapOn' + TARGET: 'continue' + PHASE: 'start' +- tapOn: + id: 'continue' +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tapOn' + TARGET: 'continue' + PHASE: 'end' +- waitForAnimationToEnd + +# Track back navigation +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'back' + TARGET: 'navigation' + PHASE: 'start' +- back +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'back' + TARGET: 'navigation' + PHASE: 'end' +- waitForAnimationToEnd + +# Wait for cloud backup to fail - handle both possible error states +# First try waiting for "No backups found" +- extendedWaitUntil: + visible: + text: 'No backups found' + timeout: 5000 + optional: true + +# If that didn't appear, wait for "Error while importing backups" +- extendedWaitUntil: + visible: + text: 'Error while importing backups' + timeout: 5000 + optional: true + +# If error while importing backups appeared, tap to enter recovery phrase manually +- runFlow: + when: + visible: + text: 'Enter recovery phrase' + commands: + - tapOn: + text: 'Enter recovery phrase' + +# Track seed phrase input +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'inputText' + TARGET: 'seed-phrase' + PHASE: 'start' +- inputText: ${E2E_RECOVERY_PHRASE} +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'inputText' + TARGET: 'seed-phrase' + PHASE: 'end' + +# Track final continue +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tapOn' + TARGET: 'continue-final' + PHASE: 'start' +- tapOn: + id: 'continue' +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tapOn' + TARGET: 'continue-final' + PHASE: 'end' +- waitForAnimationToEnd +- assertVisible: + id: 'account-header-avatar' + +# End flow tracking +- runScript: + file: ../../scripts/performance/dist/actions/end-flow.js + +# Upload metrics to Datadog (for Maestro Cloud) +- runScript: + file: ../../scripts/performance/upload-metrics.js + env: + DATADOG_API_KEY: ${DATADOG_API_KEY} + ENVIRONMENT: 'maestro_cloud' diff --git a/apps/mobile/.maestro/flows/send/send-eth-to-self.yaml b/apps/mobile/.maestro/flows/send/send-eth-to-self.yaml new file mode 100644 index 00000000..2091b274 --- /dev/null +++ b/apps/mobile/.maestro/flows/send/send-eth-to-self.yaml @@ -0,0 +1,248 @@ +appId: com.uniswap.mobile.dev +jsEngine: graaljs +tags: + - send + - language-agnostic +env: + E2E_RECOVERY_PHRASE: ${E2E_RECOVERY_PHRASE} +--- +# Initialize tracking at the very beginning +- runScript: + file: ../../scripts/performance/dist/actions/init-tracking.js + +# Start flow tracking (includes setup time) +- runScript: + file: ../../scripts/performance/dist/actions/start-flow.js + env: + FLOW_NAME: "send-to-self" + +# Step 1: Initialize app and recover wallet +- runFlow: ../../shared-flows/start.yaml +- runFlow: ../../shared-flows/recover-fast.yaml + +# Step 2: Copy our wallet address from the Receive QR modal +- runFlow: ../../shared-flows/copy-wallet-address.yaml + +# Step 3: Tap on Send button to open the send flow +- assertVisible: + id: ${output.testIds.Send} +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: "tapOn" + TARGET: "Send" + PHASE: "start" +- tapOn: + id: ${output.testIds.Send} +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: "tapOn" + TARGET: "Send" + PHASE: "end" +- waitForAnimationToEnd + +# Step 4: Tap on the search input and paste the wallet address +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: "tapOn" + TARGET: "ExploreSearchInput" + PHASE: "start" +- tapOn: + id: ${output.testIds.ExploreSearchInput} +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: "tapOn" + TARGET: "ExploreSearchInput" + PHASE: "end" +- pasteText +- waitForAnimationToEnd + +# Hide keyboard to see search results +- hideKeyboard +- waitForAnimationToEnd + +# Step 5: Tap on the wallet search result to select it as recipient +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: "tapOn" + TARGET: "SelectRecipientRow" + PHASE: "start" +- tapOn: + id: ${output.testIds.SelectRecipientRow} +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: "tapOn" + TARGET: "SelectRecipientRow" + PHASE: "end" +- waitForAnimationToEnd + +# Step 6: Acknowledge the "This is your current wallet" warning +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: "tapOn" + TARGET: "Confirm" + PHASE: "start" +- tapOn: + id: ${output.testIds.Confirm} +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: "tapOn" + TARGET: "Confirm" + PHASE: "end" +- waitForAnimationToEnd + +# Step 7: Open token selector and select Base ETH +# Tap on the token selector button to open the token selector modal +- assertVisible: + id: ${output.testIds.ChooseInputToken} +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: "tapOn" + TARGET: "ChooseInputToken" + PHASE: "start" +- tapOn: + id: ${output.testIds.ChooseInputToken} +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: "tapOn" + TARGET: "ChooseInputToken" + PHASE: "end" +- waitForAnimationToEnd + +# Step 8: Search for Base ETH in token selector +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: "tapOn" + TARGET: "ExploreSearchInput" + PHASE: "start" +- tapOn: + id: ${output.testIds.ExploreSearchInput} +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: "tapOn" + TARGET: "ExploreSearchInput" + PHASE: "end" + +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: "inputText" + TARGET: "Base ETH" + PHASE: "start" +- inputText: "Base ETH" +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: "inputText" + TARGET: "Base ETH" + PHASE: "end" +- waitForAnimationToEnd + +# Step 9: Select Base ETH from search results +- assertVisible: + id: "token-option-8453-ETH" +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: "tapOn" + TARGET: "token-option-8453-ETH" + PHASE: "start" +- tapOn: + id: "token-option-8453-ETH" +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: "tapOn" + TARGET: "token-option-8453-ETH" + PHASE: "end" +- waitForAnimationToEnd + +# Step 10: Enter amount (0.00001 ETH) using decimal pad +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: "inputAmount" + TARGET: "0.00001" + PHASE: "start" +- tapOn: + id: ${output.testIds.DecimalPadNumber0} +- tapOn: + id: ${output.testIds.DecimalPadDecimal} +- tapOn: + id: ${output.testIds.DecimalPadNumber0} +- tapOn: + id: ${output.testIds.DecimalPadNumber0} +- tapOn: + id: ${output.testIds.DecimalPadNumber0} +- tapOn: + id: ${output.testIds.DecimalPadNumber0} +- tapOn: + id: ${output.testIds.DecimalPadNumber1} +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: "inputAmount" + TARGET: "0.00001" + PHASE: "end" +- waitForAnimationToEnd + +# Step 11: Tap Review Transfer button to proceed +- assertVisible: + id: ${output.testIds.ReviewTransfer} +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: "tapOn" + TARGET: "ReviewTransfer" + PHASE: "start" +- tapOn: + id: ${output.testIds.ReviewTransfer} +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: "tapOn" + TARGET: "ReviewTransfer" + PHASE: "end" +- waitForAnimationToEnd + +# Step 12: Wait for review modal and confirm send (testID is 'send' from ElementName.Send) +- assertVisible: + id: ${output.testIds.Send} +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: "tapOn" + TARGET: "ConfirmSend" + PHASE: "start" +- tapOn: + id: ${output.testIds.Send} +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: "tapOn" + TARGET: "ConfirmSend" + PHASE: "end" +- waitForAnimationToEnd + +# Step 13: Wait for transaction to be submitted and return to activity screen +# After submitting, the app navigates to the Activity tab showing the pending transaction +- extendedWaitUntil: + visible: + id: ${output.testIds.HomeTab} + timeout: 30000 +- waitForAnimationToEnd + +# End flow tracking +- runScript: + file: ../../scripts/performance/dist/actions/end-flow.js diff --git a/apps/mobile/.maestro/flows/swap/swap-base.yaml b/apps/mobile/.maestro/flows/swap/swap-base.yaml new file mode 100644 index 00000000..f8a399d0 --- /dev/null +++ b/apps/mobile/.maestro/flows/swap/swap-base.yaml @@ -0,0 +1,213 @@ +appId: com.uniswap.mobile.dev +jsEngine: graaljs +tags: + - performance-tracking + - language-agnostic +env: + E2E_RECOVERY_PHRASE: ${E2E_RECOVERY_PHRASE} + DATADOG_API_KEY: ${DATADOG_API_KEY} +--- +# Initialize tracking at the very beginning +- runScript: + file: ../../scripts/performance/dist/actions/init-tracking.js + +# Start flow tracking (includes setup time) +- runScript: + file: ../../scripts/performance/dist/actions/start-flow.js + env: + FLOW_NAME: 'swap' + +# Run prerequisite flows (tracked as sub-flows) +- runFlow: ../../shared-flows/start.yaml +- runFlow: ../../shared-flows/recover-fast.yaml + +# Wait for home screen to be ready; without it, tapping Swap succeeds but the modal doesn't actually appear. Maybe there's a better way? +- extendedWaitUntil: + visible: 'noop' + timeout: 2000 + optional: true + +# Start of swap flow with action tracking +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tapOn' + TARGET: 'Swap' + PHASE: 'start' +- tapOn: + id: ${output.testIds.Swap} +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tapOn' + TARGET: 'Swap' + PHASE: 'end' + +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tapOn' + TARGET: 'ChooseOutputToken' + PHASE: 'start' +- tapOn: + id: ${output.testIds.ChooseOutputToken} +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tapOn' + TARGET: 'ChooseOutputToken' + PHASE: 'end' + +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tapOn' + TARGET: 'ExploreSearchInput' + PHASE: 'start' +- tapOn: + id: ${output.testIds.ExploreSearchInput} +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tapOn' + TARGET: 'ExploreSearchInput' + PHASE: 'end' + +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'inputText' + TARGET: 'cbbtc' + PHASE: 'start' +- inputText: cbbtc +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'inputText' + TARGET: 'cbbtc' + PHASE: 'end' + +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tapOn' + TARGET: 'token-option-8453-cbBTC' + PHASE: 'start' +- tapOn: + id: 'token-option-8453-cbBTC' # TODO: Replace with id for token +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tapOn' + TARGET: 'token-option-8453-cbBTC' + PHASE: 'end' + +# Amount input tracking +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tapOn' + TARGET: 'AmountInputOut' + PHASE: 'start' +- tapOn: + id: ${output.testIds.AmountInputOut} +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tapOn' + TARGET: 'AmountInputOut' + PHASE: 'end' + +# Number pad input tracking (consolidated for brevity) +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'inputAmount' + TARGET: '0.000001' + PHASE: 'start' +- tapOn: + id: ${output.testIds.DecimalPadNumber0} +- tapOn: + id: ${output.testIds.DecimalPadDecimal} +- tapOn: + id: ${output.testIds.DecimalPadNumber0} +- tapOn: + id: ${output.testIds.DecimalPadNumber0} +- tapOn: + id: ${output.testIds.DecimalPadNumber0} +- tapOn: + id: ${output.testIds.DecimalPadNumber0} +- tapOn: + id: ${output.testIds.DecimalPadNumber0} +- tapOn: + id: ${output.testIds.DecimalPadNumber1} +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'inputAmount' + TARGET: '0.000001' + PHASE: 'end' + +# Wait for the ReviewSwap button to be visible; without this, e2e test sometimes registers a success but the button is not actually tapped. +- extendedWaitUntil: + visible: 'noop' + timeout: 2000 + optional: true + +# Review swap tracking +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tapOn' + TARGET: 'ReviewSwap' + PHASE: 'start' +- tapOn: + id: ${output.testIds.ReviewSwap} +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tapOn' + TARGET: 'ReviewSwap' + PHASE: 'end' + +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tapOn' + TARGET: 'ListSeparatorToggle' + PHASE: 'start' +- tapOn: + id: ${output.testIds.ListSeparatorToggle} +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tapOn' + TARGET: 'ListSeparatorToggle' + PHASE: 'end' + +# Execute swap tracking +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tapOn' + TARGET: 'ConfirmSwap' + PHASE: 'start' +- tapOn: + id: ${output.testIds.Swap} +- runScript: + file: ../../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tapOn' + TARGET: 'ConfirmSwap' + PHASE: 'end' + +# End flow tracking +- runScript: + file: ../../scripts/performance/dist/actions/end-flow.js + +# Upload metrics to Datadog (for Maestro Cloud) +- runScript: + file: ../../scripts/performance/upload-metrics.js + env: + DATADOG_API_KEY: ${DATADOG_API_KEY} + ENVIRONMENT: 'maestro_cloud' diff --git a/apps/mobile/.maestro/performance/list-perf.yaml b/apps/mobile/.maestro/performance/list-perf.yaml new file mode 100644 index 00000000..55086267 --- /dev/null +++ b/apps/mobile/.maestro/performance/list-perf.yaml @@ -0,0 +1,9 @@ +appId: com.uniswap.mobile.dev +--- +- launchApp +- tapOn: + id: 'search-tokens-and-wallets' +- 'scroll' +- 'scroll' +- 'scroll' +- 'back' diff --git a/apps/mobile/.maestro/performance/settings-perf.yaml b/apps/mobile/.maestro/performance/settings-perf.yaml new file mode 100644 index 00000000..a047b6d2 --- /dev/null +++ b/apps/mobile/.maestro/performance/settings-perf.yaml @@ -0,0 +1,22 @@ +appId: com.uniswap.mobile.dev +--- +- launchApp +- extendedWaitUntil: + visible: + id: 'account-header-settings-icon' + timeout: 10000 +- tapOn: + id: 'account-header-settings-icon' +- 'scroll' +- 'scroll' +- 'back' +- tapOn: + id: 'account-header-settings-icon' +- 'scroll' +- 'scroll' +- 'back' +- tapOn: + id: 'account-header-settings-icon' +- 'scroll' +- 'scroll' +- 'back' diff --git a/apps/mobile/.maestro/scripts/e2e-interactive.ts b/apps/mobile/.maestro/scripts/e2e-interactive.ts new file mode 100644 index 00000000..ed19e15f --- /dev/null +++ b/apps/mobile/.maestro/scripts/e2e-interactive.ts @@ -0,0 +1,340 @@ +#!/usr/bin/env ts-node + +/** + * Interactive script to run E2E tests with flow selection and metro bundler option + * This script is called by the bun e2e:interactive command + */ + +import type { ChildProcess } from 'child_process' +import { execSync, spawn } from 'child_process' +import * as fs from 'fs' +import * as path from 'path' +import * as readline from 'readline' + +const escapeVariable = (variable: string): string => variable.replace(/'/g, "'\\''") + +// ANSI color codes +const colors = { + reset: '\x1b[0m', + cyan: '\x1b[36m', + green: '\x1b[32m', + yellow: '\x1b[33m', + red: '\x1b[31m', + dim: '\x1b[2m', +} as const + +// Create readline interface for user input +const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, +}) + +// Helper function to ask questions +const askQuestion = (question: string): Promise => { + return new Promise((resolve) => { + rl.question(question, (answer) => { + resolve(answer) + }) + }) +} + +// Helper function to find all YAML files recursively +function findYamlFiles(dir: string, baseDir: string = dir): string[] { + let results: string[] = [] + const files = fs.readdirSync(dir) + + for (const file of files) { + const filePath = path.join(dir, file) + const stat = fs.statSync(filePath) + + if (stat.isDirectory()) { + results = results.concat(findYamlFiles(filePath, baseDir)) + } else if (file.endsWith('.yaml') || file.endsWith('.yml')) { + // Get relative path from flows directory + const relativePath = path.relative(baseDir, filePath) + results.push(relativePath) + } + } + + return results +} + +// Track Metro process globally for cleanup +let globalMetroProcess: ChildProcess | undefined + +// Helper function to validate environment +function validateEnvironment(): { E2E_RECOVERY_PHRASE: string; DATADOG_API_KEY?: string } { + const E2E_RECOVERY_PHRASE = process.env.E2E_RECOVERY_PHRASE + const DATADOG_API_KEY = process.env.DATADOG_API_KEY + + if (!E2E_RECOVERY_PHRASE) { + console.error(`${colors.red}Error: E2E_RECOVERY_PHRASE environment variable is required${colors.reset}`) + console.error('Please set it before running this command:') + console.error(` ${colors.yellow}export E2E_RECOVERY_PHRASE="your recovery phrase here"${colors.reset}`) + process.exit(1) + } + + return { E2E_RECOVERY_PHRASE, DATADOG_API_KEY } +} + +// Helper function to get test files +function getTestFiles(): string[] { + const flowsDir = path.join(process.cwd(), '.maestro/flows') + console.log(`${colors.dim}Scanning for test flows in: ${flowsDir}${colors.reset}\n`) + + let yamlFiles: string[] = [] + try { + yamlFiles = findYamlFiles(flowsDir, flowsDir) + } catch (error) { + console.error(`${colors.red}Error scanning for YAML files: ${(error as Error).message}${colors.reset}`) + process.exit(1) + } + + if (yamlFiles.length === 0) { + console.error(`${colors.red}No YAML test files found in ${flowsDir}${colors.reset}`) + process.exit(1) + } + + yamlFiles.sort() + return yamlFiles +} + +// Helper function to select flows +async function selectFlows(yamlFiles: string[]): Promise<{ selectedFlows: string[]; selectionDescription: string }> { + // Display available flows + console.log(`${colors.green}Available test flows:${colors.reset}`) + console.log(` ${colors.cyan}0)${colors.reset} ${colors.green}Run all tests${colors.reset}`) + yamlFiles.forEach((file, index) => { + console.log(` ${colors.cyan}${index + 1})${colors.reset} ${file}`) + }) + console.log('') + + // Ask user to select a flow + let selection: number + while (true) { + const answer = await askQuestion(`${colors.yellow}Select a flow to run (0-${yamlFiles.length}): ${colors.reset}`) + selection = parseInt(answer, 10) + + if (selection >= 0 && selection <= yamlFiles.length) { + break + } + console.log( + `${colors.red}Invalid selection. Please enter a number between 0 and ${yamlFiles.length}.${colors.reset}`, + ) + } + + let selectedFlows: string[] + let selectionDescription: string + + if (selection === 0) { + selectedFlows = yamlFiles + selectionDescription = 'all tests' + } else { + const selectedFile = yamlFiles[selection - 1] + if (!selectedFile) { + throw new Error(`Invalid selection: ${selection}`) + } + selectedFlows = [selectedFile] + selectionDescription = selectedFile + } + + console.log(`\n${colors.green}Selected:${colors.reset} ${selectionDescription}\n`) + + return { selectedFlows, selectionDescription } +} + +// Helper function to start Metro bundler +async function startMetro(): Promise { + const answer = await askQuestion(`${colors.yellow}Start Metro bundler for E2E environment? (y/n): ${colors.reset}`) + const shouldStartMetro = answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes' + rl.close() + + if (!shouldStartMetro) { + return undefined + } + + console.log(`\n${colors.cyan}Starting Metro bundler...${colors.reset}`) + console.log(`${colors.dim}Metro logs will appear below. The E2E test will start in 8 seconds...${colors.reset}\n`) + + // Start Metro in a child process but keep it attached to show logs + const metroProcess = spawn('bun', ['start:e2e'], { + stdio: ['inherit', 'inherit', 'inherit'], + shell: true, + detached: true, + }) + + // Set global reference for cleanup + globalMetroProcess = metroProcess + + // Handle Metro process errors + metroProcess.on('error', (error) => { + console.error(`${colors.red}Failed to start Metro: ${error.message}${colors.reset}`) + process.exit(1) + }) + + // Give Metro time to start and show initial logs + await new Promise((resolve) => setTimeout(resolve, 8000)) + console.log(`\n${colors.green}Metro bundler should be running. Starting E2E test...${colors.reset}\n`) + + return metroProcess +} + +interface TestRunOptions { + selectedFlows: string[] + selectionDescription: string + E2E_RECOVERY_PHRASE: string + DATADOG_API_KEY?: string +} + +// Helper function to run tests +async function runTests(options: TestRunOptions): Promise { + const { selectedFlows, selectionDescription, E2E_RECOVERY_PHRASE, DATADOG_API_KEY } = options + console.log(`\n${colors.cyan}${'='.repeat(60)}${colors.reset}`) + console.log( + `${colors.cyan}Running E2E test${selectedFlows.length > 1 ? 's' : ''}: ${selectionDescription}${colors.reset}`, + ) + console.log(`${colors.cyan}${'='.repeat(60)}${colors.reset}\n`) + + // Properly escape the recovery phrase to prevent command injection + const escapedRecoveryPhrase = escapeVariable(E2E_RECOVERY_PHRASE) + const escapedDatadogApiKey = DATADOG_API_KEY ? escapeVariable(DATADOG_API_KEY) : '' + + const failedTests: string[] = [] + const passedTests: string[] = [] + + // Run tests sequentially + for (let i = 0; i < selectedFlows.length; i++) { + const testFile = selectedFlows[i] + if (!testFile) { + throw new Error(`Invalid test file at index ${i}`) + } + + if (selectedFlows.length > 1) { + console.log(`\n${colors.cyan}[${i + 1}/${selectedFlows.length}] Running: ${testFile}${colors.reset}\n`) + } + + try { + execSync( + `maestro test -e E2E_RECOVERY_PHRASE='${escapedRecoveryPhrase}' -e DATADOG_API_KEY='${escapedDatadogApiKey}' .maestro/flows/${testFile}`, + { + stdio: 'inherit', + env: { + ...process.env, + DATADOG_API_KEY, + E2E_RECOVERY_PHRASE, + MAESTRO_DRIVER_STARTUP_TIMEOUT: '120000', + }, + }, + ) + passedTests.push(testFile) + if (selectedFlows.length > 1) { + console.log(`${colors.green}✅ ${testFile} passed${colors.reset}`) + } + } catch (error) { + failedTests.push(testFile) + if (selectedFlows.length > 1) { + console.error(`${colors.red}❌ ${testFile} failed${colors.reset}`) + } else { + throw error // Re-throw for single test to maintain existing behavior + } + } + } + + // Print summary for multiple tests + if (selectedFlows.length > 1) { + console.log(`\n${colors.cyan}${'='.repeat(60)}${colors.reset}`) + console.log(`${colors.cyan}Test Summary:${colors.reset}`) + console.log(`${colors.green}Passed: ${passedTests.length}${colors.reset}`) + console.log(`${colors.red}Failed: ${failedTests.length}${colors.reset}`) + + if (failedTests.length > 0) { + console.log(`\n${colors.red}Failed tests:${colors.reset}`) + failedTests.forEach((test) => console.log(` ${colors.red}- ${test}${colors.reset}`)) + } + + console.log(`${colors.cyan}${'='.repeat(60)}${colors.reset}`) + + if (failedTests.length > 0) { + throw new Error(`${failedTests.length} test(s) failed`) + } + } + + console.log( + `\n${colors.green}✅ E2E test${selectedFlows.length > 1 ? 's' : ''} completed successfully!${colors.reset}`, + ) +} + +// Main function +async function main(): Promise { + console.log(`${colors.cyan}🎭 Maestro E2E Interactive Test Runner${colors.reset}\n`) + + // Validate environment + const { E2E_RECOVERY_PHRASE, DATADOG_API_KEY } = validateEnvironment() + + // Change to apps/mobile directory + const mobileDir = path.resolve(__dirname, '../../') + process.chdir(mobileDir) + + // Get test files + const yamlFiles = getTestFiles() + + // Select flows + const { selectedFlows, selectionDescription } = await selectFlows(yamlFiles) + + // Start metro if requested + const metroProcess = await startMetro() + + try { + // Run the selected test(s) + await runTests({ selectedFlows, selectionDescription, E2E_RECOVERY_PHRASE, DATADOG_API_KEY }) + } catch (_error) { + console.error(`\n${colors.red}❌ E2E test${selectedFlows.length > 1 ? 's' : ''} failed${colors.reset}`) + if (metroProcess) { + console.log(`${colors.yellow}Stopping Metro bundler...${colors.reset}`) + metroProcess.kill() + } + process.exit(1) + } + + // Clean up Metro process if it was started + if (metroProcess) { + console.log(`\n${colors.yellow}Stopping Metro bundler...${colors.reset}`) + metroProcess.kill() + } + + console.log(`\n${colors.cyan}🎭 E2E test session complete!${colors.reset}`) +} + +// Handle cleanup on exit +process.on('SIGINT', () => { + console.log(`\n${colors.yellow}Interrupted by user${colors.reset}`) + if (globalMetroProcess) { + console.log(`${colors.yellow}Stopping Metro bundler...${colors.reset}`) + try { + if (globalMetroProcess.pid) { + process.kill(-globalMetroProcess.pid) + } + } catch (_e) { + globalMetroProcess.kill('SIGTERM') + } + } + process.exit(0) +}) + +process.on('exit', () => { + if (globalMetroProcess) { + try { + if (globalMetroProcess.pid) { + process.kill(-globalMetroProcess.pid) + } + } catch (_e) { + globalMetroProcess.kill('SIGTERM') + } + } +}) + +// Run the main function +main().catch((error) => { + console.error(`${colors.red}Unexpected error: ${error.message}${colors.reset}`) + process.exit(1) +}) diff --git a/apps/mobile/.maestro/scripts/performance/BUILD.md b/apps/mobile/.maestro/scripts/performance/BUILD.md new file mode 100644 index 00000000..047f3062 --- /dev/null +++ b/apps/mobile/.maestro/scripts/performance/BUILD.md @@ -0,0 +1,79 @@ +# Building Performance Scripts for GraalJS + +## Overview + +The performance tracking scripts need to be compiled from TypeScript to JavaScript and bundled for compatibility with GraalJS (Maestro's JavaScript runtime). + +## Build Process + +The build process uses **esbuild** to: + +1. Compile TypeScript to JavaScript +2. Bundle all dependencies into self-contained files +3. Target ES2015 for GraalJS compatibility +4. Generate source maps for debugging + +## How to Build + +Run the build command: + +```bash +bun e2e:build-js +``` + +This executes `.maestro/scripts/tooling/buildPerformanceScripts.ts` which: + +- Bundles each action script with all its dependencies +- Outputs to `.maestro/scripts/performance/dist/actions/` +- Creates IIFE (Immediately Invoked Function Expression) format for isolation + +## Configuration + +The build script uses these esbuild settings: + +- **target**: `es2015` - Compatible with GraalJS +- **format**: `iife` - Self-contained execution +- **platform**: `neutral` - No Node.js or browser assumptions +- **bundle**: `true` - Include all dependencies +- **minify**: `false` - Keep code readable for debugging +- **sourcemap**: `true` - Enable debugging + +## Output Structure + +``` +dist/ +├── actions/ +│ ├── init-tracking.js +│ ├── init-tracking.js.map +│ ├── start-flow.js +│ ├── start-flow.js.map +│ ├── end-flow.js +│ ├── end-flow.js.map +│ ├── track-action.js +│ ├── track-action.js.map +│ ├── start-sub-flow.js +│ ├── start-sub-flow.js.map +│ ├── end-sub-flow.js +│ └── end-sub-flow.js.map +└── utils/ +``` + +## Adding New Scripts + +To add a new script: + +1. Create the TypeScript file in `src/actions/` or `src/utils/` +2. Run `bun e2e:build-js` to build + +The build script automatically discovers and builds: + +- All TypeScript files in `src/actions/` +- Executable utility scripts in `src/utils/` (those containing 'upload', 'submit', or 'extract' in their names) + +## Troubleshooting + +If you encounter build errors: + +- Check that all imports are resolvable +- Ensure TypeScript syntax is compatible with ES2015 target +- Verify that no Node.js-specific APIs are used diff --git a/apps/mobile/.maestro/scripts/performance/LOCAL_INSTRUMENTATION_GUIDE.md b/apps/mobile/.maestro/scripts/performance/LOCAL_INSTRUMENTATION_GUIDE.md new file mode 100644 index 00000000..9e417f27 --- /dev/null +++ b/apps/mobile/.maestro/scripts/performance/LOCAL_INSTRUMENTATION_GUIDE.md @@ -0,0 +1,355 @@ +# Maestro E2E Performance Instrumentation Guide + +## Overview + +We've instrumented our Maestro E2E tests to collect performance metrics across three runtime environments: local development, DeviceCloud (CI), and Maestro Cloud. This comprehensive instrumentation enables us to track test execution times, identify performance regressions, and monitor the health of our test suite across all deployment scenarios. + +## Architecture + +```text +┌─────────────────────────────────────────────────────────────────────────────┐ +│ MAESTRO E2E TEST EXECUTION │ +│ (Local / DeviceCloud / Maestro Cloud) │ +└─────────────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────────┐ +│ INSTRUMENTED TEST FLOWS │ +│ │ +│ ┌─────────────────┐ ┌──────────────────┐ ┌────────────────────┐ │ +│ │ init-tracking.js│───▶│ start-flow.js │───▶│ track-action.js │ │ +│ │ (init buffer) │ │ (flow_start) │ │ (action metrics) │ │ +│ └─────────────────┘ └──────────────────┘ └────────────────────┘ │ +│ │ │ │ +│ │ ┌──────────────────┐ │ │ +│ └─────────────▶│ start-sub-flow.js│◀────────────┘ │ +│ │ (shared flows) │ │ +│ └──────────────────┘ │ +│ │ │ +│ ┌──────────────────┐ │ +│ │ end-sub-flow.js │ │ +│ └──────────────────┘ │ +│ │ │ +│ ┌──────────────────┐ │ +│ │ end-flow.js │ │ +│ │ (flow_end) │ │ +│ └──────────────────┘ │ +└─────────────────────────────────────────────────────────────────────────────┘ + │ + ┌────────────────┴────────────────┐ + │ │ + LOCAL/DEVICECLOUD MAESTRO CLOUD + │ │ + ▼ ▼ +┌────────────────────────────────┐ ┌────────────────────────────────────┐ +│ CONSOLE OUTPUT │ │ IN-MEMORY BUFFER │ +│ │ │ │ +│ MAESTRO_METRIC:{...} │ │ output.METRICS_BUFFER = [ │ +│ MAESTRO_METRIC:{...} │ │ {"type":"flow",...}, │ +│ │ │ {"type":"action",...} │ +│ │ │ ] │ +└────────────────────────────────┘ └────────────────────────────────────┘ + │ │ + ▼ ▼ +┌────────────────────────────────┐ ┌────────────────────────────────────┐ +│ EXTRACT & PROCESS │ │ DIRECT UPLOAD │ +│ │ │ │ +│ - extract-metrics.sh │ │ - upload-metrics.js │ +│ - process-metrics.ts │ │ - HTTP POST to Datadog │ +│ - submit-metrics.js │ │ - Per-flow execution │ +└────────────────────────────────┘ └────────────────────────────────────┘ + │ │ + └────────────────┬────────────────┘ + │ + ▼ + ┌─────────────────────┐ + │ DATADOG METRICS │ + │ │ + │ Tagged by: │ + │ - Environment │ + │ - Flow name │ + │ - Platform │ + │ - CI metadata │ + └─────────────────────┘ +``` + +## Quick Start + +### Running Tests with Metrics + +#### Local Development + +```bash +# Run all e2e tests or a specific test +bun e2e:interactive + +# Process and submit metrics +export DATADOG_API_KEY=your-key-here +bun e2e:local:process-metrics +``` + +#### Maestro Cloud + +```bash +# Flows automatically upload metrics when DATADOG_API_KEY is provided +maestro cloud --api-key $MAESTRO_CLOUD_KEY \ + -e DATADOG_API_KEY=$DATADOG_API_KEY \ + apps/mobile/.maestro/flows/swap/swap-base.yaml +``` + +### How It Works + +#### Local/DeviceCloud + +1. **Test Execution**: Maestro runs instrumented test flows +2. **Metric Logging**: Scripts log metrics with `MAESTRO_METRIC:` prefix +3. **Extraction**: Shell script extracts metrics from Maestro logs +4. **Processing**: JavaScript adds synthetic events for failed flows +5. **Submission**: Metrics are sent to Datadog via HTTP API + +#### Maestro Cloud + +1. **Test Execution**: Maestro runs instrumented test flows +2. **Metric Collection**: Scripts append metrics to `output.METRICS_BUFFER` +3. **Direct Upload**: `upload-metrics.js` sends buffer contents to Datadog +4. **Per-Flow Submission**: Each flow uploads its own metrics at completion + +## Instrumentation Example + +```yaml +# swap-base.yaml +appId: com.uniswap.mobile.dev + +# Initialize tracking +- runScript: + file: .maestro/scripts/performance/dist/actions/init-tracking.js +- runScript: + file: .maestro/scripts/performance/dist/actions/start-flow.js + env: + FLOW_NAME: swap-base + +# Track shared flow (automatically tracked internally) +- runFlow: ../shared/start.yaml + +# Track individual action +- runScript: + file: .maestro/scripts/performance/dist/actions/track-action.js + env: + ACTION: tap + TARGET: swap-button + PHASE: start +- tapOn: + id: "swap-button" +- runScript: + file: .maestro/scripts/performance/dist/actions/track-action.js + env: + ACTION: tap + TARGET: swap-button + PHASE: end + +# End flow tracking +- runScript: + file: .maestro/scripts/performance/dist/actions/end-flow.js + +# Upload metrics to Datadog (for Maestro Cloud) +- runScript: + file: .maestro/scripts/performance/upload-metrics.js + env: + DATADOG_API_KEY: ${DATADOG_API_KEY} + ENVIRONMENT: 'maestro_cloud' +``` + +## Metrics Collected + +### Flow Metrics + +- **Metric**: `maestro.e2e.flow.duration` +- **Purpose**: Total test execution time +- **Tags**: `flow_name`, `platform`, `status` + +### Action Metrics + +- **Metric**: `maestro.e2e.action.duration` +- **Purpose**: Individual UI action timing +- **Tags**: `action_type`, `action_target`, `step_number` + +### Sub-Flow Metrics + +- **Metric**: `maestro.e2e.sub_flow.duration` +- **Purpose**: Shared component performance +- **Tags**: `parent_flow_name`, `sub_flow_name` + +## Best Practices + +### DO ✅ + +- Initialize tracking at the start of every flow +- Track meaningful user actions (taps, inputs, swipes) +- Let shared flows track themselves internally +- Use descriptive action targets +- Clear logs regularly with `bun e2e:clear-logs` + +### DON'T ❌ + +- Double-track shared flows (they track themselves) +- Track every minor interaction +- Forget to end flow tracking +- Mix local and CI metrics without proper tags + +### Shared Flow Example + +**Correct Implementation:** + +```yaml +# Shared flow tracks itself internally +- runFlow: ../../shared-flows/biometrics-confirm.yaml +``` + +**Incorrect (creates duplicate metrics):** + +```yaml +# Don't wrap with track-action +- runScript: + file: track-action.js + env: + ACTION: 'runFlow' + PHASE: 'start' +- runFlow: ../../shared-flows/biometrics-confirm.yaml +- runScript: + file: track-action.js + env: + ACTION: 'runFlow' + PHASE: 'end' +``` + +## Design Rationale + +### Why Instrument E2E Tests Instead of Application Code? + +We chose to instrument the E2E test files themselves rather than the application code for several key reasons: + +#### 1. **Non-Invasive Testing** + +- **Zero production impact**: No performance overhead in the actual app +- **No code pollution**: Production code remains clean and focused on functionality +- **Risk-free**: Can't accidentally ship instrumentation code to users + +#### 2. **Test-Specific Insights** + +- **User journey timing**: Measures real user workflows, not individual render cycles +- **Interaction-based**: Tracks actual tap-to-response times as users experience them +- **Flow-level metrics**: Provides holistic view of feature performance + +#### 3. **Practical Constraints** + +- **Maestro limitations**: Can only execute scripts between test actions +- **No runtime access**: Cannot inject code into the running React Native app +- **Platform agnostic**: Works identically on iOS and Android simulators + +### Alternative Approaches Considered + +#### ❌ **React Native Performance API** + +```javascript +// Would require modifying app code +const observer = new PerformanceObserver((list) => { + list.getEntries().forEach((entry) => { + // Send metrics... + }); +}); +``` + +**Why not**: Requires production code changes, only captures render performance, not user workflows + +#### ❌ **Native Performance Monitoring** + +```swift +// iOS: Using Instruments API +// Android: Using System Trace +``` + +**Why not**: Platform-specific, requires native code changes, complex integration with E2E tests + +#### ❌ **Maestro Built-in Metrics** + +```yaml +# Maestro's native timing (if it existed) +- tapOn: + id: button + recordMetrics: true +``` + +**Why not**: Maestro doesn't provide built-in performance tracking or metric export + +#### ❌ **Video Analysis** + +- Record test execution +- Analyze frame-by-frame for timing +- Extract metrics from visual changes + +**Why not**: Computationally expensive, less accurate, requires additional infrastructure + +#### ❌ **Network Proxy Instrumentation** + +- Intercept API calls during tests +- Measure request/response times +- Correlate with user actions + +**Why not**: Only captures network performance, misses UI responsiveness + +### Current Approach Benefits + +1. **Separation of Concerns** + - Tests measure performance + - App code focuses on features + - Metrics are test artifacts, not production data + +2. **Flexibility** + - Easy to add/remove tracking without touching app code + - Can track any Maestro-supported action + - Simple to customize metrics per test flow + +3. **Reliability** + - Consistent measurement approach + - No dependency on app internals + - Works with any React Native version + +4. **Developer Experience** + - No special builds required + - Same app binary for all tests + - Easy to understand and debug + +## Technical Details + +### GraalJS Compatibility + +All tracking scripts are ES2020-compatible for Maestro's GraalJS runtime: + +- Supports ES2020 features (async/await, optional chaining, etc.) +- Use `output` object for data persistence (especially for Maestro Cloud) +- Environment variables passed directly (not via `process.env`) +- HTTP requests use Maestro's `http` API (not XMLHttpRequest) + +### Environment Detection + +Scripts automatically detect the runtime environment: + +```javascript +// In action scripts +if (typeof output !== 'undefined' && output.METRICS_BUFFER !== undefined) { + // Maestro Cloud mode - append to buffer + let buffer = JSON.parse(output.METRICS_BUFFER || '[]'); + buffer.push(metric); + output.METRICS_BUFFER = JSON.stringify(buffer); +} +// Always log to console for backward compatibility +console.log('MAESTRO_METRIC:' + JSON.stringify(metric)); +``` + +### Synthetic Events + +Failed flows automatically receive synthetic `flow_end` events during processing to ensure complete metrics even when tests fail. This happens in: + +- `process-metrics.ts` (compiled to JS) for DeviceCloud/local environments + +- Not needed for Maestro Cloud as flows handle their own completion diff --git a/apps/mobile/.maestro/scripts/performance/README.md b/apps/mobile/.maestro/scripts/performance/README.md new file mode 100644 index 00000000..a7e0934c --- /dev/null +++ b/apps/mobile/.maestro/scripts/performance/README.md @@ -0,0 +1,154 @@ +# Maestro Performance Scripts + +This directory contains TypeScript scripts for tracking performance metrics in Maestro E2E tests. The scripts are compiled to GraalJS-compatible JavaScript for execution within Maestro's runtime environment. + +## Architecture + +### Source Files (`src/`) + +- **actions/** - Individual action scripts executed by Maestro YAML flows + - `init-tracking.ts` - Initialize performance tracking session + - `start-flow.ts` - Mark the beginning of a test flow + - `end-flow.ts` - Mark the end of a test flow + - `start-sub-flow.ts` - Mark the beginning of a sub-flow + - `end-sub-flow.ts` - Mark the end of a sub-flow + - `track-action.ts` - Track individual UI actions +- **utils/** - Shared utility functions + - `bufferMetric.ts` - Buffer metrics for batch upload + - `emitMetric.ts` - Emit metrics to console + - `getTimestamp.ts` - Timestamp utilities + - `metricCreators.ts` - Factory functions for creating metrics + - `validateEnv.ts` - Environment variable validation +- **types.ts** - TypeScript type definitions +- **globals.d.ts** - Global type declarations for Maestro environment + +### Compiled Files (`dist/`) + +Generated JavaScript files optimized for GraalJS ES2020 runtime. + +## Build System + +### GraalJS Compilation + +The build system compiles TypeScript to GraalJS-compatible JavaScript: + +1. **Target**: ES2020 (fully supported by GraalJS) +2. **Module System**: None (GraalJS uses global scope) +3. **Optimizations**: + - Removes all module imports/exports + - Inlines utility functions + - Wraps in IIFE for isolation + - Removes TypeScript-specific constructs + +### Build Commands + +```bash +# Install dependencies +npm install + +# Build for GraalJS +npm run build + +# Watch mode for development +npm run build:watch + +# Clean build directory +npm run clean + +# Full rebuild +npm run rebuild + +# Type checking only +npm run typecheck +``` + +## GraalJS Compatibility + +### Supported Features + +- ES2020 syntax (async/await, optional chaining, etc.) +- JSON operations +- Date and Math objects +- Console logging +- Global variables + +### Limitations + +- No ES modules (import/export) +- No Node.js APIs +- No npm packages +- Limited to Maestro-provided globals + +## Maestro Integration + +### Global Variables + +Maestro provides these globals at runtime: + +- `output` - Persistent state between script runs +- `FLOW_NAME`, `SUB_FLOW_NAME`, `ACTION`, `TARGET`, `PHASE` - Environment variables + +### Usage in YAML + +```yaml +- runScript: + file: .maestro/scripts/performance/dist/actions/init-tracking.js + +- runScript: + file: .maestro/scripts/performance/dist/actions/start-flow.js + env: + FLOW_NAME: swap + +- tapOn: "Swap Button" +- runScript: + file: .maestro/scripts/performance/dist/actions/track-action.js + env: + ACTION: tap + TARGET: "Swap Button" + PHASE: end + +- runScript: + file: .maestro/scripts/performance/dist/actions/end-flow.js + env: + FLOW_NAME: swap +``` + +## Metrics Format + +Metrics follow this structure: + +```typescript +interface Metric { + timestamp: number + platform: string + testRunId: string + // Additional fields based on metric type +} +``` + +## Development Workflow + +1. **Edit TypeScript files** in `src/` +2. **Run build** to compile to GraalJS +3. **Test locally** with Maestro +4. **Commit both** source and dist files + +## Troubleshooting + +### Compilation Errors + +- Ensure no ES module syntax in source files +- Check for Node.js-specific APIs +- Verify TypeScript version compatibility + +### Runtime Errors + +- Check Maestro console output +- Verify global variables are defined +- Ensure scripts are in correct order + +### Performance Issues + +- Minimize JSON operations +- Avoid large string concatenations +- Keep metrics buffer reasonable size diff --git a/apps/mobile/.maestro/scripts/performance/src/actions/end-flow.ts b/apps/mobile/.maestro/scripts/performance/src/actions/end-flow.ts new file mode 100644 index 00000000..d21ebf7a --- /dev/null +++ b/apps/mobile/.maestro/scripts/performance/src/actions/end-flow.ts @@ -0,0 +1,55 @@ +/// + +/** + * Maestro E2E Performance Tracking - Flow End Script + * + * Marks the completion of a test flow and emits a flow_end metric. + * This script should be called at the end of each test flow to calculate + * and report the total flow execution time. + * + * @requires Maestro GraalJS environment + * @requires start-flow.ts to have been run first + * + * Output Variables Used: + * - CURRENT_FLOW_NAME: Name of the current flow + * - FLOW_START_TIME: Timestamp when the flow started + * - CURRENT_PLATFORM: Platform being tested + * - CURRENT_TEST_RUN_ID: Unique test run identifier + * + * Metrics Emitted: + * - flow_end event with flow name, duration, status, and context + * + * Note: This script always reports status as 'success'. Failed flows that + * terminate early will have synthetic flow_end events added during metric extraction. + */ + +import { bufferMetric } from '../utils/bufferMetric' +import { emitMetric } from '../utils/emitMetric' +import { getTimestamp } from '../utils/getTimestamp' +import { createFlowEndMetric } from '../utils/metricCreators' + +// Retrieve flow information from output variables +const flowName = output.CURRENT_FLOW_NAME || 'unknown' +const startTime = parseInt(output.FLOW_START_TIME || '0', 10) + +// Calculate total flow duration +const duration = getTimestamp() - startTime + +// Create flow_end metric +const metric = createFlowEndMetric({ + flowName, + timestamp: getTimestamp(), + duration, + status: 'success', // Failed flows handled by extraction script + platform: output.CURRENT_PLATFORM, + testRunId: output.CURRENT_TEST_RUN_ID, +}) + +// Emit flow_end metric for local processing +emitMetric(metric) + +// Append metric to buffer for cloud upload +bufferMetric(metric, output) + +// Log completion for debugging +console.log(`${flowName} flow completed in ${duration}ms`) diff --git a/apps/mobile/.maestro/scripts/performance/src/actions/end-sub-flow.ts b/apps/mobile/.maestro/scripts/performance/src/actions/end-sub-flow.ts new file mode 100644 index 00000000..17d484b4 --- /dev/null +++ b/apps/mobile/.maestro/scripts/performance/src/actions/end-sub-flow.ts @@ -0,0 +1,73 @@ +/// + +/** + * Maestro E2E Performance Tracking - Sub-Flow End Script + * + * Marks the completion of a sub-flow and restores the parent flow context. + * This script calculates the sub-flow duration and emits metrics while + * ensuring the parent flow tracking continues correctly. + * + * @requires Maestro GraalJS environment + * @requires start-sub-flow.ts to have been run first + * + * Environment Variables: + * - SUB_FLOW_NAME: Name of the sub-flow being ended + * + * Output Variables Used: + * - PARENT_FLOW_NAME: Name of the parent flow to restore + * - SUB_FLOW_START_TIME: Start timestamp for duration calculation + * - CURRENT_PLATFORM: Platform being tested + * - CURRENT_TEST_RUN_ID: Unique test run identifier + * + * Output Variables Cleaned: + * - PARENT_FLOW_NAME: Deleted after restoring context + * - SUB_FLOW_START_TIME: Deleted after calculating duration + * + * Metrics Emitted: + * - sub_flow_end event with parent flow, sub-flow name, duration, and context + */ + +import { bufferMetric } from '../utils/bufferMetric' +import { emitMetric } from '../utils/emitMetric' +import { getTimestamp } from '../utils/getTimestamp' +import { createSubFlowEndMetric } from '../utils/metricCreators' +import { getEnvVar } from '../utils/validateEnv' + +// Get sub-flow name with safe fallback +const subFlowName = getEnvVar('SUB_FLOW_NAME', 'unknown-sub-flow') + +// Get parent flow name, with fallback to current flow if not set +const parentFlowName = output.PARENT_FLOW_NAME || output.CURRENT_FLOW_NAME || 'unknown' + +// Calculate sub-flow duration +const startTime = parseInt(output.SUB_FLOW_START_TIME || '0', 10) +const duration = getTimestamp() - startTime + +// Create sub_flow_end metric +const metric = createSubFlowEndMetric({ + parentFlowName, + subFlowName, + timestamp: getTimestamp(), + duration, + status: 'success', + platform: output.CURRENT_PLATFORM, + testRunId: output.CURRENT_TEST_RUN_ID, +}) + +// Emit sub_flow_end metric for local processing +emitMetric(metric) + +// Append metric to buffer for cloud upload +bufferMetric(metric, output) + +// Restore parent flow context for continued tracking +if (output.PARENT_FLOW_NAME) { + output.CURRENT_FLOW_NAME = output.PARENT_FLOW_NAME + delete output.PARENT_FLOW_NAME +} + +// Clean up sub-flow specific tracking variables +delete output.SUB_FLOW_START_TIME + +// Log completion for debugging +console.log(`${subFlowName} sub-flow completed in ${duration}ms`) diff --git a/apps/mobile/.maestro/scripts/performance/src/actions/init-tracking.ts b/apps/mobile/.maestro/scripts/performance/src/actions/init-tracking.ts new file mode 100644 index 00000000..ea1e5922 --- /dev/null +++ b/apps/mobile/.maestro/scripts/performance/src/actions/init-tracking.ts @@ -0,0 +1,39 @@ +/** + * Maestro E2E Performance Tracking - Initialization Script + * + * This script initializes the performance tracking system for a Maestro test flow. + * It sets up essential tracking variables that will be used throughout the test run + * to correlate metrics and identify test sessions. + * + * @requires Maestro GraalJS environment with 'output' object available + * + * Output Variables Set: + * - CURRENT_FLOW_NAME: Name of the current flow (initialized to 'unknown') + * - CURRENT_TEST_RUN_ID: Unique identifier for this test run (timestamp-based) + * - CURRENT_STEP_NUMBER: Counter for tracking action sequence (starts at '0') + * - CURRENT_PLATFORM: The platform being tested (defaults to 'ios') + * - METRICS_BUFFER: JSON string array for collecting metrics + */ + +/// +import { getTimestampString } from '../utils/getTimestamp' + +// Initialize flow name to 'unknown' - will be set by start-flow.ts +output.CURRENT_FLOW_NAME = 'unknown' + +// Generate unique test run ID using current timestamp +// This ensures each test run can be uniquely identified in metrics +output.CURRENT_TEST_RUN_ID = getTimestampString() + +// Initialize step counter - will be incremented by track-action.ts +output.CURRENT_STEP_NUMBER = '0' + +// Set platform - TODO: Make this dynamic based on actual platform +output.CURRENT_PLATFORM = 'ios' + +// Initialize metrics buffer for collecting all metrics during the test +// This will be used to upload metrics to Datadog at the end of the flow +output.METRICS_BUFFER = '[]' + +// Log initialization for debugging purposes +console.log(`Initialized tracking with test run ID: ${output.CURRENT_TEST_RUN_ID}`) diff --git a/apps/mobile/.maestro/scripts/performance/src/actions/start-flow.ts b/apps/mobile/.maestro/scripts/performance/src/actions/start-flow.ts new file mode 100644 index 00000000..11442403 --- /dev/null +++ b/apps/mobile/.maestro/scripts/performance/src/actions/start-flow.ts @@ -0,0 +1,53 @@ +/// + +/** + * Maestro E2E Performance Tracking - Flow Start Script + * + * Marks the beginning of a test flow and emits a flow_start metric. + * This script should be called at the beginning of each test flow to track + * overall flow execution time. + * + * @requires Maestro GraalJS environment + * @requires init-tracking.ts to have been run first + * + * Environment Variables: + * - FLOW_NAME: Name of the flow being started (e.g., 'swap', 'onboarding') + * + * Output Variables Set: + * - CURRENT_FLOW_NAME: Updated with the actual flow name + * - FLOW_START_TIME: Timestamp when the flow started + * + * Metrics Emitted: + * - flow_start event with flow name, timestamp, platform, and test run ID + */ + +import { bufferMetric } from '../utils/bufferMetric' +import { emitMetric } from '../utils/emitMetric' +import { getTimestampString } from '../utils/getTimestamp' +import { createFlowStartMetric } from '../utils/metricCreators' +import { getEnvVar } from '../utils/validateEnv' + +// Get flow name from Maestro environment variable +// Falls back to 'unknown' if not provided +const flowName = getEnvVar('FLOW_NAME', 'unknown') + +// Update output variables for use by other scripts +output.CURRENT_FLOW_NAME = flowName +output.FLOW_START_TIME = getTimestampString() + +// Create flow_start metric +const metric = createFlowStartMetric({ + flowName: output.CURRENT_FLOW_NAME, + timestamp: parseInt(output.FLOW_START_TIME, 10), + platform: output.CURRENT_PLATFORM, + testRunId: output.CURRENT_TEST_RUN_ID, +}) + +// Emit flow_start metric for local processing +emitMetric(metric) + +// Append metric to buffer for cloud upload +bufferMetric(metric, output) + +// Log for debugging +console.log(`Started tracking ${flowName} flow`) diff --git a/apps/mobile/.maestro/scripts/performance/src/actions/start-sub-flow.ts b/apps/mobile/.maestro/scripts/performance/src/actions/start-sub-flow.ts new file mode 100644 index 00000000..8afd73d1 --- /dev/null +++ b/apps/mobile/.maestro/scripts/performance/src/actions/start-sub-flow.ts @@ -0,0 +1,59 @@ +/// + +/** + * Maestro E2E Performance Tracking - Sub-Flow Start Script + * + * Marks the beginning of a sub-flow (shared flow) within a parent flow. + * Sub-flows are reusable flow components like authentication, navigation, + * or common setup procedures that are called from multiple main flows. + * + * @requires Maestro GraalJS environment + * @requires start-flow.ts to have been run first (parent flow must be active) + * + * Environment Variables: + * - SUB_FLOW_NAME: Name of the sub-flow being started (e.g., 'shared-login') + * + * Output Variables Set: + * - PARENT_FLOW_NAME: Preserved name of the parent flow + * - SUB_FLOW_START_TIME: Timestamp when the sub-flow started + * + * Metrics Emitted: + * - sub_flow_start event with parent flow, sub-flow name, and context + * + * Note: Sub-flows track their own duration while preserving the parent flow context. + * This allows for hierarchical performance analysis. + */ + +import { bufferMetric } from '../utils/bufferMetric' +import { emitMetric } from '../utils/emitMetric' +import { getTimestampString } from '../utils/getTimestamp' +import { createSubFlowStartMetric } from '../utils/metricCreators' +import { getEnvVar } from '../utils/validateEnv' + +// Get sub-flow name from environment, with safe fallback +const subFlowName = getEnvVar('SUB_FLOW_NAME', 'unknown-sub-flow') + +// Preserve current flow as parent for context +const parentFlowName = output.CURRENT_FLOW_NAME || 'unknown' + +// Store parent flow context and sub-flow start time +output.PARENT_FLOW_NAME = parentFlowName +output.SUB_FLOW_START_TIME = getTimestampString() + +// Create sub_flow_start metric +const metric = createSubFlowStartMetric({ + parentFlowName, + subFlowName, + timestamp: parseInt(output.SUB_FLOW_START_TIME, 10), + platform: output.CURRENT_PLATFORM, + testRunId: output.CURRENT_TEST_RUN_ID, +}) + +// Emit sub_flow_start metric for local processing +emitMetric(metric) + +// Append metric to buffer for cloud upload +bufferMetric(metric, output) + +// Log for debugging with parent context +console.log(`Started sub-flow: ${subFlowName} (parent: ${parentFlowName})`) diff --git a/apps/mobile/.maestro/scripts/performance/src/actions/track-action.ts b/apps/mobile/.maestro/scripts/performance/src/actions/track-action.ts new file mode 100644 index 00000000..876e365d --- /dev/null +++ b/apps/mobile/.maestro/scripts/performance/src/actions/track-action.ts @@ -0,0 +1,79 @@ +/// + +/** + * Maestro E2E Performance Tracking - Action Tracking Script + * + * Tracks individual UI actions (taps, inputs, swipes, etc.) within a test flow. + * This script is called twice for each action: once at start and once at end. + * It calculates the duration of each action and emits performance metrics. + * + * @requires Maestro GraalJS environment + * @requires init-tracking.ts and start-flow.ts to have been run first + * + * Environment Variables (passed as direct variables in GraalJS): + * - ACTION: Type of action (e.g., 'tap', 'input', 'swipe') + * - TARGET: Target element or description (e.g., 'LoginButton', 'EmailField') + * - PHASE: Either 'start' or 'end' to mark action boundaries + * + * Output Variables Modified: + * - CURRENT_STEP_NUMBER: Incremented for each completed action + * - [actionKey]_START: Temporary storage for action start times + * + * Metrics Emitted: + * - action metric with type, target, duration, and other context + */ + +import { bufferMetric } from '../utils/bufferMetric' +import { emitMetric } from '../utils/emitMetric' +import { getTimestamp } from '../utils/getTimestamp' +import { createActionMetric } from '../utils/metricCreators' +import { getEnvVar } from '../utils/validateEnv' + +// Get action details from Maestro environment +// In GraalJS, these are available as direct variables +const action = getEnvVar('ACTION', 'unknown') +const target = getEnvVar('TARGET', 'unknown') +const phase = getEnvVar('PHASE', 'unknown') + +// Create unique key for storing start time of this specific action +// This allows tracking multiple concurrent actions if needed +const actionKey = `${action}_${target}` + +if (phase === 'start') { + // Store start timestamp for duration calculation + output[`${actionKey}_START`] = getTimestamp().toString() + console.log(`Started ${action} on ${target}`) +} else if (phase === 'end') { + // Retrieve start time and calculate duration + const startTimeKey = `${actionKey}_START` + const startTime = parseInt(output[startTimeKey] || '0', 10) + const duration = getTimestamp() - startTime + + // Increment step counter to track action sequence + const currentStep = parseInt(output.CURRENT_STEP_NUMBER || '0', 10) + output.CURRENT_STEP_NUMBER = (currentStep + 1).toString() + + // Create metric object + const metric = createActionMetric({ + flowName: output.CURRENT_FLOW_NAME, + actionType: action, + actionTarget: target, + stepNumber: currentStep + 1, + duration, + timestamp: startTime, + status: 'success', // TODO: Add failure tracking + platform: output.CURRENT_PLATFORM, + testRunId: output.CURRENT_TEST_RUN_ID, + }) + + // Emit action metric for local processing + emitMetric(metric) + + // Append metric to buffer for cloud upload + bufferMetric(metric, output) + + console.log(`Completed ${action} on ${target} in ${duration}ms`) + + // Clean up temporary start time to avoid memory buildup + delete output[startTimeKey] +} diff --git a/apps/mobile/.maestro/scripts/performance/src/globals.d.ts b/apps/mobile/.maestro/scripts/performance/src/globals.d.ts new file mode 100644 index 00000000..583b55a2 --- /dev/null +++ b/apps/mobile/.maestro/scripts/performance/src/globals.d.ts @@ -0,0 +1,42 @@ +/** + * Global declarations for Maestro JavaScript environment + * These globals are provided by Maestro at runtime + */ + +interface MaestroOutput { + CURRENT_FLOW_NAME: string + CURRENT_TEST_RUN_ID: string + CURRENT_STEP_NUMBER: string + CURRENT_PLATFORM: string + METRICS_BUFFER: string + FLOW_START_TIME?: string + PARENT_FLOW_NAME?: string + SUB_FLOW_START_TIME?: string + [key: string]: string | undefined +} + +declare global { + /** + * Maestro output object for state persistence between script runs + */ + const output: MaestroOutput + + /** + * Environment variables passed from Maestro YAML files + */ + const FLOW_NAME: string | undefined + const SUB_FLOW_NAME: string | undefined + const ACTION: string | undefined + const TARGET: string | undefined + const PHASE: 'start' | 'end' | undefined + + /** + * Console object available in GraalJS + */ + const console: { + log(...args: unknown[]): void + error(...args: unknown[]): void + } +} + +export {} diff --git a/apps/mobile/.maestro/scripts/performance/src/types.ts b/apps/mobile/.maestro/scripts/performance/src/types.ts new file mode 100644 index 00000000..ca217142 --- /dev/null +++ b/apps/mobile/.maestro/scripts/performance/src/types.ts @@ -0,0 +1,83 @@ +/** + * Type definitions for Maestro E2E Performance Tracking + */ + +/** + * Maestro output object for storing variables between script executions + */ +export interface MaestroOutput { + CURRENT_FLOW_NAME: string + CURRENT_TEST_RUN_ID: string + CURRENT_STEP_NUMBER: string + CURRENT_PLATFORM: string + METRICS_BUFFER: string + FLOW_START_TIME?: string + PARENT_FLOW_NAME?: string + SUB_FLOW_START_TIME?: string + [key: string]: string | undefined // Allow dynamic action start times +} + +/** + * Base metric structure + */ +export interface BaseMetric { + timestamp: number + platform: string + testRunId: string +} + +/** + * Flow start event metric + */ +export interface FlowStartMetric extends BaseMetric { + event: 'flow_start' + flowName: string +} + +/** + * Flow end event metric + */ +export interface FlowEndMetric extends BaseMetric { + event: 'flow_end' + flowName: string + duration: number + status: 'success' | 'failure' +} + +/** + * Sub-flow start event metric + */ +export interface SubFlowStartMetric extends BaseMetric { + event: 'sub_flow_start' + parentFlowName: string + subFlowName: string +} + +/** + * Sub-flow end event metric + */ +export interface SubFlowEndMetric extends BaseMetric { + event: 'sub_flow_end' + parentFlowName: string + subFlowName: string + duration: number + status: 'success' | 'failure' +} + +/** + * Action metric for UI interactions + */ +export interface ActionMetric extends BaseMetric { + type: 'action' + flowName: string + actionType: string + actionTarget: string + stepNumber: number + duration: number + status: 'success' | 'failure' +} + +/** + * Union type of all metric types + */ +export type Metric = FlowStartMetric | FlowEndMetric | SubFlowStartMetric | SubFlowEndMetric | ActionMetric diff --git a/apps/mobile/.maestro/scripts/performance/src/utils/bufferMetric.ts b/apps/mobile/.maestro/scripts/performance/src/utils/bufferMetric.ts new file mode 100644 index 00000000..6e337f85 --- /dev/null +++ b/apps/mobile/.maestro/scripts/performance/src/utils/bufferMetric.ts @@ -0,0 +1,15 @@ +import type { MaestroOutput, Metric } from '../types' + +/** + * Add a metric to the metrics buffer for cloud upload + * Buffer is stored as a JSON string in the output object + */ +export function bufferMetric(metric: Metric, output: MaestroOutput): void { + try { + const buffer: Metric[] = JSON.parse(output.METRICS_BUFFER || '[]') + buffer.push(metric) + output.METRICS_BUFFER = JSON.stringify(buffer) + } catch (error) { + console.log(`Error appending to metrics buffer: ${error instanceof Error ? error.message : String(error)}`) + } +} diff --git a/apps/mobile/.maestro/scripts/performance/src/utils/emitMetric.ts b/apps/mobile/.maestro/scripts/performance/src/utils/emitMetric.ts new file mode 100644 index 00000000..c956aba3 --- /dev/null +++ b/apps/mobile/.maestro/scripts/performance/src/utils/emitMetric.ts @@ -0,0 +1,9 @@ +import type { Metric } from '../types' + +/** + * Emit a metric to the console for local processing + * Metrics are prefixed with MAESTRO_METRIC: for parsing + */ +export function emitMetric(metric: Metric): void { + console.log(`MAESTRO_METRIC:${JSON.stringify(metric)}`) +} diff --git a/apps/mobile/.maestro/scripts/performance/src/utils/extract-metrics.ts b/apps/mobile/.maestro/scripts/performance/src/utils/extract-metrics.ts new file mode 100644 index 00000000..a6f1662f --- /dev/null +++ b/apps/mobile/.maestro/scripts/performance/src/utils/extract-metrics.ts @@ -0,0 +1,247 @@ +/** + * Extract MAESTRO_METRIC lines from Maestro test logs + */ + +import { exec } from 'child_process' +import * as fs from 'fs' +import * as path from 'path' +import { promisify } from 'util' + +const execAsync = promisify(exec) + +// Type for exec errors which include stderr +interface ExecError extends Error { + stderr?: string + stdout?: string +} + +/** + * Find all log files in the test directory + */ +async function findLogFiles(testDir: string): Promise { + const logFiles: string[] = [] + + async function walkDir(dir: string): Promise { + if (!fs.existsSync(dir)) { + return + } + + const entries = await fs.promises.readdir(dir, { withFileTypes: true }) + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name) + + if (entry.isDirectory()) { + // Look for logs directories in CI artifacts + if (entry.name === 'logs' || fullPath.includes(`${path.sep}logs${path.sep}`)) { + await walkDir(fullPath) + } else { + await walkDir(fullPath) + } + } else if (entry.isFile()) { + // Include .log files and .txt files in logs directories + if (entry.name.endsWith('.log') || (fullPath.includes('/logs/') && entry.name.endsWith('.txt'))) { + logFiles.push(fullPath) + } + } + } + } + + await walkDir(testDir) + return logFiles +} + +/** + * Extract JSON objects from a line containing MAESTRO_METRIC + */ +function extractMetricsFromLine(line: string): string[] { + const metrics: string[] = [] + let currentPos = 0 + + while (true) { + const metricIndex = line.indexOf('MAESTRO_METRIC:', currentPos) + if (metricIndex === -1) { + break + } + + const jsonStart = metricIndex + 'MAESTRO_METRIC:'.length + const jsonStr = line.substring(jsonStart) + + // Parse JSON object by counting braces + let braceCount = 0 + let inString = false + let escapeNext = false + let jsonEnd = -1 + + for (let i = 0; i < jsonStr.length; i++) { + const char = jsonStr[i] + + if (escapeNext) { + escapeNext = false + continue + } + + if (char === '\\') { + escapeNext = true + continue + } + + if (char === '"' && !inString) { + inString = true + } else if (char === '"' && inString) { + inString = false + } + + if (!inString) { + if (char === '{') { + braceCount++ + } else if (char === '}') { + braceCount-- + if (braceCount === 0) { + jsonEnd = i + 1 + break + } + } + } + } + + if (jsonEnd > 0) { + const metric = jsonStr.substring(0, jsonEnd) + try { + // Validate that it's valid JSON + JSON.parse(metric) + metrics.push(metric) + } catch { + // Skip invalid JSON + } + currentPos = jsonStart + jsonEnd + } else { + break + } + } + + return metrics +} + +/** + * Extract metrics from all log files + */ +async function extractMetrics(logFiles: string[]): Promise> { + const allMetrics = new Set() + + for (const logFile of logFiles) { + console.error(`Processing: ${logFile}`) + + try { + const content = await fs.promises.readFile(logFile, 'utf-8') + const lines = content.split('\n') + + for (const line of lines) { + if (line.includes('MAESTRO_METRIC:')) { + const metrics = extractMetricsFromLine(line) + metrics.forEach((m) => allMetrics.add(m)) + } + } + } catch (error) { + console.error(`Error reading ${logFile}:`, error) + } + } + + return allMetrics +} + +/** + * Process metrics to add missing flow_end events for failed flows + */ +async function processMetrics(tempFile: string, outputFile: string): Promise { + // The process-metrics.js is in the same directory after compilation + const processMetricsScript = path.join(__dirname, 'process-metrics.js') + + if (!fs.existsSync(processMetricsScript)) { + throw new Error(`process-metrics.js not found at ${processMetricsScript}`) + } + + try { + await execAsync(`node "${processMetricsScript}" "${tempFile}" "${outputFile}"`) + } catch (error) { + const execError = error as ExecError + const errorMessage = execError.stderr || execError.message || String(error) + throw new Error(`Failed to process metrics: ${errorMessage}`) + } + + if (!fs.existsSync(outputFile)) { + throw new Error(`Output file ${outputFile} was not created`) + } +} + +/** + * Main function + */ +async function main(): Promise { + // Parse command line arguments + const args = process.argv.slice(2) + const testDir = args[0] || path.join(process.env.HOME || '', '.maestro', 'tests') + const outputFile = args[1] || 'metrics.jsonl' + const tempFile = `${outputFile}.tmp` + + console.log('Extracting metrics from Maestro test logs...') + console.log(`Test directory: ${testDir}`) + console.log(`Output file: ${outputFile}`) + + try { + // Find all log files + const logFiles = await findLogFiles(testDir) + + if (logFiles.length === 0) { + console.log('No log files found') + return + } + + console.log(`Found ${logFiles.length} log files`) + + // Extract metrics from all files + const metrics = await extractMetrics(logFiles) + + // Write unique metrics to temp file + await fs.promises.writeFile(tempFile, Array.from(metrics).join('\n') + '\n') + + // Process metrics to add missing flow_end events + await processMetrics(tempFile, outputFile) + + // Clean up temp file + await fs.promises.unlink(tempFile) + + // Count and display results + const outputContent = await fs.promises.readFile(outputFile, 'utf-8') + const metricLines = outputContent + .trim() + .split('\n') + .filter((line) => line.length > 0) + const metricCount = metricLines.length + + console.log(`Extracted ${metricCount} metrics to ${outputFile}`) + + if (metricCount > 0) { + console.log('') + console.log('Sample metrics:') + metricLines.slice(0, 5).forEach((line) => console.log(line)) + } + } catch (error) { + console.error('Error:', error) + + // Clean up temp file on error + if (fs.existsSync(tempFile)) { + await fs.promises.unlink(tempFile) + } + + process.exit(1) + } +} + +// Run if executed directly +if (require.main === module) { + main().catch((error) => { + console.error('Fatal error:', error) + process.exit(1) + }) +} diff --git a/apps/mobile/.maestro/scripts/performance/src/utils/getTimestamp.ts b/apps/mobile/.maestro/scripts/performance/src/utils/getTimestamp.ts new file mode 100644 index 00000000..c1905f79 --- /dev/null +++ b/apps/mobile/.maestro/scripts/performance/src/utils/getTimestamp.ts @@ -0,0 +1,32 @@ +/** + * Timestamp utilities for GraalJS/Maestro runtime + * Uses native Date.now() which is supported in GraalJS + */ + +/** + * Get current timestamp in milliseconds + */ +export function getTimestamp(): number { + return Date.now() +} + +/** + * Get current timestamp as string + */ +export function getTimestampString(): string { + return Date.now().toString() +} + +/** + * Calculate elapsed time from a start timestamp + */ +export function getElapsedTime(startTime: number): number { + return Date.now() - startTime +} + +/** + * Format timestamp for logging + */ +export function formatTimestamp(timestamp: number): string { + return new Date(timestamp).toISOString() +} diff --git a/apps/mobile/.maestro/scripts/performance/src/utils/metricCreators.ts b/apps/mobile/.maestro/scripts/performance/src/utils/metricCreators.ts new file mode 100644 index 00000000..a4088b3f --- /dev/null +++ b/apps/mobile/.maestro/scripts/performance/src/utils/metricCreators.ts @@ -0,0 +1,110 @@ +import type { ActionMetric, FlowEndMetric, FlowStartMetric, SubFlowEndMetric, SubFlowStartMetric } from '../types' + +interface BaseMetricParams { + timestamp: number + platform: string + testRunId: string +} + +/** + * Create a flow start metric + */ +export function createFlowStartMetric(params: BaseMetricParams & { flowName: string }): FlowStartMetric { + return { + event: 'flow_start', + flowName: params.flowName, + timestamp: params.timestamp, + platform: params.platform, + testRunId: params.testRunId, + } +} + +/** + * Create a flow end metric + */ +export function createFlowEndMetric( + params: BaseMetricParams & { + flowName: string + duration: number + status: 'success' | 'failure' + }, +): FlowEndMetric { + return { + event: 'flow_end', + flowName: params.flowName, + timestamp: params.timestamp, + duration: params.duration, + status: params.status, + platform: params.platform, + testRunId: params.testRunId, + } +} + +/** + * Create a sub-flow start metric + */ +export function createSubFlowStartMetric( + params: BaseMetricParams & { + parentFlowName: string + subFlowName: string + }, +): SubFlowStartMetric { + return { + event: 'sub_flow_start', + parentFlowName: params.parentFlowName, + subFlowName: params.subFlowName, + timestamp: params.timestamp, + platform: params.platform, + testRunId: params.testRunId, + } +} + +/** + * Create a sub-flow end metric + */ +export function createSubFlowEndMetric( + params: BaseMetricParams & { + parentFlowName: string + subFlowName: string + duration: number + status: 'success' | 'failure' + }, +): SubFlowEndMetric { + return { + event: 'sub_flow_end', + parentFlowName: params.parentFlowName, + subFlowName: params.subFlowName, + timestamp: params.timestamp, + duration: params.duration, + status: params.status, + platform: params.platform, + testRunId: params.testRunId, + } +} + +/** + * Create an action metric + */ +export function createActionMetric( + params: BaseMetricParams & { + flowName: string + actionType: string + actionTarget: string + stepNumber: number + duration: number + status: 'success' | 'failure' + }, +): ActionMetric { + return { + type: 'action', + flowName: params.flowName, + actionType: params.actionType, + actionTarget: params.actionTarget, + stepNumber: params.stepNumber, + duration: params.duration, + timestamp: params.timestamp, + status: params.status, + platform: params.platform, + testRunId: params.testRunId, + } +} diff --git a/apps/mobile/.maestro/scripts/performance/src/utils/process-metrics.ts b/apps/mobile/.maestro/scripts/performance/src/utils/process-metrics.ts new file mode 100644 index 00000000..68f97414 --- /dev/null +++ b/apps/mobile/.maestro/scripts/performance/src/utils/process-metrics.ts @@ -0,0 +1,219 @@ +#!/usr/bin/env node +/** + * Process Maestro test metrics from NDJSON format. + * Adds synthetic flow_end events for flows that failed to complete. + */ + +import * as fs from 'fs' +import * as readline from 'readline' +import type { Metric } from '../types' + +type FlowStartMetric = Metric & { + event: 'flow_start' + flowName: string + timestamp: number + platform: string + testRunId: string +} + +type FlowEndMetric = Metric & { + event: 'flow_end' + flowName: string + timestamp: number + duration: number + status: 'success' | 'failure' + platform: string + testRunId: string + synthetic?: boolean +} + +/** + * Ensures a value is a number, converting strings if necessary + */ +function ensureNumber(value: unknown, defaultValue: number = 0): number { + if (typeof value === 'number') { + return value + } + if (typeof value === 'string') { + const parsed = parseInt(value, 10) + return isNaN(parsed) ? defaultValue : parsed + } + return defaultValue +} + +/** + * Process metrics from input file and write to output file + */ +async function processMetrics(inputFile: string, outputFile: string): Promise { + // Data structures for tracking flow states + const flowStarts = new Map() + const flowEnds = new Set() + const metrics: Metric[] = [] + + // Create read stream with error handling + const input = fs.createReadStream(inputFile) + let output: fs.WriteStream | null = null + + try { + // Set up error handlers for input stream + input.on('error', (error) => { + throw new Error(`Failed to read input file: ${error.message}`) + }) + + // Create readline interface + const rl = readline.createInterface({ input }) + + // First pass: Read all metrics and identify incomplete flows + for await (const line of rl) { + if (!line.trim()) { + continue + } + + try { + const metric = JSON.parse(line) as Metric + metrics.push(metric) + + // Track flow lifecycle events (ActionMetric has 'type' instead of 'event') + if ('event' in metric && metric.event === 'flow_start') { + const flowStart = metric as FlowStartMetric + flowStart.timestamp = ensureNumber(flowStart.timestamp) + flowStarts.set(flowStart.testRunId, flowStart) + } else if ('event' in metric && metric.event === 'flow_end') { + const flowEnd = metric as FlowEndMetric + flowEnds.add(flowEnd.testRunId) + } + } catch (_e) { + console.error('Failed to parse metric line:', line) + // Continue processing other lines even if one fails + } + } + + // Create output stream with error handling + output = fs.createWriteStream(outputFile) + + // Set up error handler for output stream + output.on('error', (error) => { + throw new Error(`Failed to write output file: ${error.message}`) + }) + + // Write all original metrics to output + for (const metric of metrics) { + await new Promise((resolve, reject) => { + const line = JSON.stringify(metric) + '\n' + if (!output) { + throw new Error('Output stream not initialized') + } + output.write(line, (error) => { + if (error) { + reject(error) + } else { + resolve() + } + }) + }) + } + + // Second pass: Add synthetic flow_end events for incomplete flows + let addedCount = 0 + for (const [testRunId, startMetric] of Array.from(flowStarts.entries())) { + if (!flowEnds.has(testRunId)) { + // Find the last recorded timestamp for this flow + let lastTimestamp = startMetric.timestamp + + const flowMetrics = metrics.filter((m) => m.testRunId === testRunId) + for (const m of flowMetrics) { + const timestamp = ensureNumber((m as unknown as Record).timestamp, 0) + if (timestamp > lastTimestamp) { + lastTimestamp = timestamp + } + } + + // Ensure we have valid timestamps for duration calculation + const startTime = ensureNumber(startMetric.timestamp) + const endTime = ensureNumber(lastTimestamp, startTime) + const duration = endTime - startTime + + // Create synthetic flow_end event + const endMetric: FlowEndMetric = { + event: 'flow_end', + flowName: startMetric.flowName, + timestamp: endTime, + duration, + status: 'failure', // Mark as failed since it didn't complete naturally + platform: startMetric.platform, + testRunId, + synthetic: true, // Flag to indicate this was artificially generated + } + + // Write synthetic metric + await new Promise((resolve, reject) => { + const line = JSON.stringify(endMetric) + '\n' + if (!output) { + throw new Error('Output stream not initialized') + } + output.write(line, (error) => { + if (error) { + reject(error) + } else { + resolve() + } + }) + }) + + addedCount++ + } + } + + // Report synthetic events added + if (addedCount > 0) { + console.log(`Added ${addedCount} synthetic flow_end events for failed flows`) + } + } finally { + // Ensure output stream is properly closed + if (output) { + await new Promise((resolve) => { + if (!output) { + throw new Error('Output stream not initialized') + } + output.end(() => resolve()) + }) + } + } +} + +/** + * Main entry point + */ +async function main(): Promise { + const [, , inputFile, outputFile] = process.argv + + // Validate command line arguments + if (!inputFile || !outputFile) { + console.error('Usage: ts-node process-metrics.ts ') + process.exit(1) + } + + // Check if input file exists + if (!fs.existsSync(inputFile)) { + console.error(`Input file not found: ${inputFile}`) + process.exit(1) + } + + try { + await processMetrics(inputFile, outputFile) + console.log(`Successfully processed metrics from ${inputFile} to ${outputFile}`) + } catch (error) { + console.error('Error processing metrics:', error) + process.exit(1) + } +} + +// Execute if run directly +if (require.main === module) { + main().catch((error) => { + console.error('Unhandled error:', error) + process.exit(1) + }) +} + +export { ensureNumber, processMetrics } diff --git a/apps/mobile/.maestro/scripts/performance/src/utils/validateEnv.ts b/apps/mobile/.maestro/scripts/performance/src/utils/validateEnv.ts new file mode 100644 index 00000000..353ab1c7 --- /dev/null +++ b/apps/mobile/.maestro/scripts/performance/src/utils/validateEnv.ts @@ -0,0 +1,47 @@ +/** + * Environment variable utilities for GraalJS/Maestro runtime + * In Maestro's GraalJS, env vars are exposed as global variables + */ + +/** + * Safely get an environment variable with a default fallback + */ +export function getEnvVar(name: string, defaultValue: string): string { + // In GraalJS/Maestro, environment variables are available as global variables + const value = (globalThis as Record)[name] + return typeof value !== 'undefined' ? String(value) : defaultValue +} + +/** + * Get required environment variable (throws if not defined) + */ +export function requireEnvVar(name: string): string { + const value = (globalThis as Record)[name] + if (typeof value === 'undefined') { + throw new Error(`Required environment variable ${name} is not defined`) + } + return String(value) +} + +/** + * Check if an environment variable is defined + */ +export function hasEnvVar(name: string): boolean { + return typeof (globalThis as Record)[name] !== 'undefined' +} + +/** + * Get all Maestro-specific environment variables + */ +export function getMaestroEnvVars(): Record { + const vars: Record = {} + const knownVars = ['FLOW_NAME', 'SUB_FLOW_NAME', 'ACTION', 'TARGET', 'PHASE'] + + for (const varName of knownVars) { + if (hasEnvVar(varName)) { + vars[varName] = getEnvVar(varName, '') + } + } + + return vars +} diff --git a/apps/mobile/.maestro/scripts/performance/submit-local.sh b/apps/mobile/.maestro/scripts/performance/submit-local.sh new file mode 100755 index 00000000..5621921f --- /dev/null +++ b/apps/mobile/.maestro/scripts/performance/submit-local.sh @@ -0,0 +1,58 @@ +#!/bin/bash + +# Local submission wrapper for E2E performance metrics +# This script extracts metrics from the latest Maestro test and submits to Datadog + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +MAESTRO_LOGS_DIR="${MAESTRO_LOGS_DIR:-$HOME/.maestro/tests}" +METRICS_FILE="${1:-metrics.jsonl}" + +echo "=== E2E Performance Metrics Submission (Local) ===" +echo "" + +# Check if Datadog API key is set +if [ -z "$DATADOG_API_KEY" ]; then + echo "⚠️ Warning: DATADOG_API_KEY not set" + echo " Metrics will be extracted but not submitted to Datadog" + echo " Set DATADOG_API_KEY environment variable to enable submission" + echo "" + DRY_RUN="--dry-run" +else + echo "✅ Datadog API key found" + DRY_RUN="" +fi + +# Check if metrics file already exists (extracted by e2e:local:extract-metrics) +if [ ! -s "$METRICS_FILE" ]; then + echo "❌ No metrics found in logs" + exit 1 +else + echo "✅ Using existing metrics file: $METRICS_FILE" +fi + +# Add local development tags +TAGS="env:local,source:manual" + +# Add git branch if available +if git rev-parse --git-dir >/dev/null 2>&1; then + BRANCH=$(git rev-parse --abbrev-ref HEAD) + TAGS="$TAGS,branch:$BRANCH" +fi + +# Submit metrics +echo "" +echo "Submitting metrics..." +node "$SCRIPT_DIR/submit-metrics.js" \ + --file "$METRICS_FILE" \ + --tags "$TAGS" \ + $DRY_RUN + +# Optionally clean up metrics file +if [ -z "$KEEP_METRICS_FILE" ]; then + rm -f "$METRICS_FILE" +fi + +echo "" +echo "✅ Done!" diff --git a/apps/mobile/.maestro/scripts/performance/submit-metrics.js b/apps/mobile/.maestro/scripts/performance/submit-metrics.js new file mode 100755 index 00000000..f8e01418 --- /dev/null +++ b/apps/mobile/.maestro/scripts/performance/submit-metrics.js @@ -0,0 +1,487 @@ +#!/usr/bin/env node + +/** + * Maestro E2E Performance Tracking - Datadog Metrics Submission Script + * + * Production-ready script to submit E2E performance metrics to Datadog. + * Supports both local development and CI/CD pipeline usage with automatic + * environment detection and appropriate tagging. + * + * Features: + * - Batch submission of metrics to Datadog API + * - Automatic CI environment detection (GitHub Actions, etc.) + * - Dry-run mode for testing without submission + * - Comprehensive metric summarization + * - Tag deduplication to prevent duplicate tags + * + * Usage: + * node submit-metrics.js [options] + * + * Options: + * --file Path to metrics JSONL file (default: metrics.jsonl) + * --dry-run Show what would be sent without actually sending + * --api-key Datadog API key (can also use DATADOG_API_KEY env var) + * --test-run-id Test run ID (defaults to timestamp) + * --tags Additional tags (comma-separated, e.g., "env:prod,branch:main") + * --help Show help message + * + * Environment Variables: + * DATADOG_API_KEY Required for actual submission (not needed for dry-run) + * CI Automatically detected for CI environment tagging + * GITHUB_REF Used to extract branch name in GitHub Actions + * GITHUB_SHA Used to tag with commit SHA in GitHub Actions + * GITHUB_RUN_ID Used to correlate with GitHub Actions run + * + * Metrics Submitted: + * - maestro.e2e.action.duration: Individual UI action durations + * - maestro.e2e.flow.duration: Complete test flow durations + * - maestro.e2e.flow.count: Flow execution counts (success/failure) + * - maestro.e2e.sub_flow.duration: Sub-flow (shared flow) durations + */ + +const fs = require('fs') +const https = require('https') +const path = require('path') + +/** + * Parse command line arguments into options object + * @param {string[]} args - Process argv array + * @returns {Object} Parsed options with defaults + */ +function parseArgs(args) { + const options = { + file: 'metrics.jsonl', + dryRun: false, + apiKey: process.env.DATADOG_API_KEY, + testRunId: Date.now().toString(), + tags: [], + } + + for (let i = 2; i < args.length; i++) { + switch (args[i]) { + case '--file': + if (i + 1 >= args.length || args[i + 1].startsWith('--')) { + console.error('Error: --file requires a value') + process.exit(1) + } + options.file = args[++i] + break + case '--dry-run': + options.dryRun = true + break + case '--api-key': + if (i + 1 >= args.length || args[i + 1].startsWith('--')) { + console.error('Error: --api-key requires a value') + process.exit(1) + } + options.apiKey = args[++i] + break + case '--test-run-id': + if (i + 1 >= args.length || args[i + 1].startsWith('--')) { + console.error('Error: --test-run-id requires a value') + process.exit(1) + } + options.testRunId = args[++i] + break + case '--tags': + if (i + 1 >= args.length || args[i + 1].startsWith('--')) { + console.error('Error: --tags requires a value') + process.exit(1) + } + const tagValue = args[++i] + options.tags = tagValue ? tagValue.split(',') : [] + break + case '--help': + showHelp() + process.exit(0) + } + } + + return options +} + +/** + * Display help message with usage instructions + */ +function showHelp() { + console.log(` +E2E Performance Metrics Submission Tool + +Usage: node submit-metrics.js [options] + +Options: + --file Path to metrics JSONL file (default: metrics.jsonl) + --dry-run Show what would be sent without actually sending + --api-key Datadog API key (can also use DATADOG_API_KEY env var) + --test-run-id Test run ID (defaults to timestamp) + --tags Additional tags (comma-separated, e.g., "env:prod,branch:main") + --help Show this help message + +Environment Variables: + DATADOG_API_KEY Datadog API key + CI Automatically detected CI environment + GITHUB_REF Git branch (GitHub Actions) + GITHUB_SHA Git commit SHA (GitHub Actions) + GITHUB_RUN_ID GitHub Actions run ID +`) +} + +/** + * Read and parse metrics from JSONL file + * @param {string} filePath - Path to the metrics file + * @returns {{metrics: Array, flows: Array}} Separated action metrics and flow events + * @throws {Error} If file doesn't exist or parsing fails + */ +function readMetrics(filePath) { + if (!fs.existsSync(filePath)) { + throw new Error(`Metrics file not found: ${filePath}`) + } + + const content = fs.readFileSync(filePath, 'utf8') + const lines = content.split('\n').filter((line) => line.trim()) + + const metrics = [] // Action metrics (type: 'action') + const flows = [] // Flow events (event: 'flow_start/end', 'sub_flow_start/end') + + // Parse each line of JSONL + lines.forEach((line, index) => { + try { + const data = JSON.parse(line) + // Separate flow events from action metrics + if (data.event) { + flows.push(data) + } else if (data.type === 'action') { + metrics.push(data) + } + } catch (e) { + console.warn(`Failed to parse line ${index + 1}: ${e.message}`) + } + }) + + return { metrics, flows } +} + +/** + * Remove duplicate tags from an array + * @param {string[]} tags - Array of tag strings + * @returns {string[]} Array with duplicates removed + */ +function deduplicateTags(tags) { + return [...new Set(tags)] +} + +/** + * Build Datadog metric series from parsed metrics and flows + * @param {Array} metrics - Action metrics array + * @param {Array} flows - Flow events array + * @param {Object} options - Command line options including tags + * @returns {Array} Array of Datadog metric series objects + */ +function buildMetricSeries(metrics, flows, options) { + const series = [] + const baseTags = [...options.tags] + + // Add CI-specific tags if running in CI + if (process.env.CI) { + baseTags.push('env:ci') + if (process.env.GITHUB_REF) { + const branch = process.env.GITHUB_REF.replace('refs/heads/', '') + baseTags.push(`branch:${branch}`) + } + if (process.env.GITHUB_SHA) { + baseTags.push(`commit:${process.env.GITHUB_SHA.substring(0, 8)}`) + } + if (process.env.GITHUB_RUN_ID) { + baseTags.push(`github_run_id:${process.env.GITHUB_RUN_ID}`) + } + } else { + baseTags.push('env:local') + } + + // Action duration metrics + metrics.forEach((metric) => { + // Use the testRunId from the metric itself + const metricTags = [...baseTags] + if (metric.testRunId) { + metricTags.push(`test_run_id:${metric.testRunId}`) + } + + series.push({ + metric: 'maestro.e2e.action.duration', + points: [[Math.floor(metric.timestamp / 1000), metric.duration]], + type: 'gauge', + tags: deduplicateTags([ + ...metricTags, + `flow_name:${metric.flowName}`, + `action_type:${metric.actionType}`, + `action_target:${metric.actionTarget}`, + `platform:${metric.platform}`, + `status:${metric.status}`, + `step_number:${metric.stepNumber}`, + ]), + }) + }) + + // Flow duration metrics + flows + .filter((f) => f.event === 'flow_end') + .forEach((flow) => { + // Use the testRunId from the flow itself + const flowTags = [...baseTags] + if (flow.testRunId) { + flowTags.push(`test_run_id:${flow.testRunId}`) + } + + series.push({ + metric: 'maestro.e2e.flow.duration', + points: [[Math.floor(flow.timestamp / 1000), flow.duration]], + type: 'gauge', + tags: deduplicateTags([ + ...flowTags, + `flow_name:${flow.flowName}`, + `platform:${flow.platform}`, + `status:${flow.status}`, + ]), + }) + + // Flow success/failure count + series.push({ + metric: 'maestro.e2e.flow.count', + points: [[Math.floor(flow.timestamp / 1000), 1]], + type: 'count', + tags: deduplicateTags([ + ...flowTags, + `flow_name:${flow.flowName}`, + `platform:${flow.platform}`, + `status:${flow.status}`, + ]), + }) + }) + + // Sub-flow duration metrics + flows + .filter((f) => f.event === 'sub_flow_end') + .forEach((flow) => { + // Use the testRunId from the flow itself + const subFlowTags = [...baseTags] + if (flow.testRunId) { + subFlowTags.push(`test_run_id:${flow.testRunId}`) + } + + series.push({ + metric: 'maestro.e2e.sub_flow.duration', + points: [[Math.floor(flow.timestamp / 1000), flow.duration]], + type: 'gauge', + tags: deduplicateTags([ + ...subFlowTags, + `parent_flow_name:${flow.parentFlowName}`, + `sub_flow_name:${flow.subFlowName}`, + `platform:${flow.platform}`, + `status:${flow.status}`, + ]), + }) + }) + + return series +} + +/** + * Send metrics to Datadog API + * @async + * @param {Array} series - Array of metric series objects + * @param {string} apiKey - Datadog API key + * @param {boolean} dryRun - If true, only log what would be sent + * @returns {Promise} Response object with success status + * @throws {Error} If API request fails + */ +async function sendToDatadog(series, apiKey, dryRun) { + // Dry run mode - just display what would be sent + if (dryRun) { + console.log('\n[DRY RUN] Would send the following metrics:') + console.log(JSON.stringify({ series }, null, 2)) + return { success: true, dryRun: true } + } + + const data = JSON.stringify({ series }) + + // Make HTTPS request to Datadog API + return new Promise((resolve, reject) => { + const options = { + hostname: 'api.datadoghq.com', + port: 443, + path: '/api/v1/series', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': data.length, + 'DD-API-KEY': apiKey, + }, + } + + const req = https.request(options, (res) => { + let body = '' + res.on('data', (chunk) => (body += chunk)) + res.on('end', () => { + // Datadog returns 202 Accepted for successful submissions + if (res.statusCode === 202) { + resolve({ success: true, response: body }) + } else { + reject(new Error(`Datadog API error: ${res.statusCode} - ${body}`)) + } + }) + }) + + req.on('error', (error) => { + reject(error) + }) + + req.write(data) + req.end() + }) +} + +/** + * Generate and display a summary report of the metrics + * @param {Array} metrics - Action metrics array + * @param {Array} flows - Flow events array + */ +function generateSummary(metrics, flows) { + console.log('\n=== E2E Performance Summary ===') + console.log(`Total actions tracked: ${metrics.length}`) + console.log(`Total flows tracked: ${flows.filter((f) => f.event === 'flow_end').length}`) + + // Calculate flow-level statistics + const flowStats = {} + + flows + .filter((f) => f.event === 'flow_end') + .forEach((flow) => { + // Initialize stats for new flow + if (!flowStats[flow.flowName]) { + flowStats[flow.flowName] = { + count: 0, + totalDuration: 0, + minDuration: Infinity, + maxDuration: 0, + statuses: { success: 0, failure: 0 }, + } + } + + // Update statistics + const stats = flowStats[flow.flowName] + stats.count++ + stats.totalDuration += flow.duration + stats.minDuration = Math.min(stats.minDuration, flow.duration) + stats.maxDuration = Math.max(stats.maxDuration, flow.duration) + + // Initialize status counter if it doesn't exist (handles unexpected status values) + if (!stats.statuses[flow.status]) { + stats.statuses[flow.status] = 0 + } + stats.statuses[flow.status]++ + }) + + // Display flow statistics + console.log('\nFlow Statistics:') + Object.entries(flowStats).forEach(([flowName, stats]) => { + console.log(`\n${flowName}:`) + console.log(` Runs: ${stats.count}`) + // Handle case where 'success' status might not exist + const successCount = stats.statuses.success || 0 + console.log(` Success rate: ${((successCount / stats.count) * 100).toFixed(1)}%`) + console.log(` Avg duration: ${(stats.totalDuration / stats.count).toFixed(0)}ms`) + console.log(` Min duration: ${stats.minDuration}ms`) + console.log(` Max duration: ${stats.maxDuration}ms`) + }) + + // Calculate action-level statistics + const actionStats = {} + + metrics.forEach((metric) => { + const key = `${metric.actionType}:${metric.actionTarget}` + if (!actionStats[key]) { + actionStats[key] = { + count: 0, + totalDuration: 0, + failures: 0, + } + } + + actionStats[key].count++ + actionStats[key].totalDuration += metric.duration + if (metric.status === 'failure') { + actionStats[key].failures++ + } + }) + + // Display top 10 slowest actions + console.log('\nTop 10 Slowest Actions:') + const sortedActions = Object.entries(actionStats) + .map(([action, stats]) => ({ + action, + avgDuration: stats.totalDuration / stats.count, + count: stats.count, + failureRate: (stats.failures / stats.count) * 100, + })) + .sort((a, b) => b.avgDuration - a.avgDuration) + .slice(0, 10) + + sortedActions.forEach(({ action, avgDuration, count, failureRate }) => { + console.log(` ${action}: ${avgDuration.toFixed(0)}ms (${count} calls, ${failureRate.toFixed(1)}% failure)`) + }) + + console.log('\n===============================\n') +} + +/** + * Main execution function + * @async + */ +async function main() { + const options = parseArgs(process.argv) + + // Validate API key for non-dry-run mode + if (!options.apiKey && !options.dryRun) { + console.error('Error: Datadog API key is required') + console.error('Set DATADOG_API_KEY environment variable or use --api-key option') + process.exit(1) + } + + try { + // Read and parse metrics file + console.log(`Reading metrics from: ${options.file}`) + const { metrics, flows } = readMetrics(options.file) + + // Check if any metrics were found + if (metrics.length === 0 && flows.length === 0) { + console.log('No metrics found in file') + process.exit(0) + } + + // Display performance summary + generateSummary(metrics, flows) + + // Convert to Datadog format + const series = buildMetricSeries(metrics, flows, options) + console.log(`Prepared ${series.length} metric series for submission`) + + // Submit to Datadog or display dry-run output + if (options.dryRun) { + await sendToDatadog(series, options.apiKey, true) + } else { + console.log('Submitting metrics to Datadog...') + await sendToDatadog(series, options.apiKey, false) + console.log('✅ Successfully submitted metrics to Datadog') + } + } catch (error) { + console.error('❌ Error:', error.message) + process.exit(1) + } +} + +// Run if called directly +if (require.main === module) { + main() +} + +module.exports = { readMetrics, buildMetricSeries, sendToDatadog, generateSummary } diff --git a/apps/mobile/.maestro/scripts/performance/tsconfig.json b/apps/mobile/.maestro/scripts/performance/tsconfig.json new file mode 100644 index 00000000..93977c05 --- /dev/null +++ b/apps/mobile/.maestro/scripts/performance/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "lib": ["ES2020"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": false, + "declarationMap": false, + "sourceMap": true, + "removeComments": false, + "noEmit": false, + "isolatedModules": false, + "allowSyntheticDefaultImports": true, + "moduleResolution": "node", + "types": ["node"], + "composite": false + }, + "include": ["./src/**/*.ts"], + "exclude": ["node_modules", "dist", "**/*.js"] +} diff --git a/apps/mobile/.maestro/scripts/performance/upload-metrics.js b/apps/mobile/.maestro/scripts/performance/upload-metrics.js new file mode 100644 index 00000000..d3682ded --- /dev/null +++ b/apps/mobile/.maestro/scripts/performance/upload-metrics.js @@ -0,0 +1,262 @@ +/** + * Maestro E2E Performance Tracking - Datadog Upload Script + * + * Uploads accumulated metrics from the buffer to Datadog API. + * This script is designed to run at the end of each test flow to submit + * all collected performance metrics to Datadog for monitoring and analysis. + * + * @requires Maestro GraalJS environment + * @requires init-tracking.js to have been run first (initializes METRICS_BUFFER) + * + * Environment Variables (passed as direct variables in GraalJS): + * - DATADOG_API_KEY: Required for API authentication + * - DATADOG_API_URL_OVERRIDE: Optional, override for different Datadog regions (EU, gov cloud) + * - ENVIRONMENT: Optional, defaults to 'maestro_cloud' (vs 'local') + * - DRY_RUN: Optional, if 'true' will only log what would be sent + * + * Output Variables Used: + * - METRICS_BUFFER: JSON string array of all metrics collected during the test + * - CURRENT_TEST_RUN_ID: Unique test run identifier for correlation + * - CURRENT_PLATFORM: Platform being tested (ios/android) + */ + +// Get configuration from environment +const apiKey = DATADOG_API_KEY || '' +const environment = ENVIRONMENT || 'maestro_cloud' +const isDryRun = DRY_RUN === 'true' + +// Datadog API endpoint (configurable for different regions) +const DATADOG_API_URL = DATADOG_API_URL_OVERRIDE || 'https://api.datadoghq.com/api/v1/series' + +/** + * Parse metrics from the buffer + * @returns {Array} Array of metric objects + */ +function parseMetricsBuffer() { + try { + const bufferContent = output.METRICS_BUFFER || '[]' + return JSON.parse(bufferContent) + } catch (e) { + console.log('Error parsing metrics buffer: ' + e.message) + return [] + } +} + +/** + * Convert metrics to Datadog series format + * @param {Array} metrics - Array of raw metrics + * @returns {Array} Array of Datadog series objects + */ +function convertToDatadogFormat(metrics) { + const series = [] + const baseTags = [ + 'env:' + environment, + 'test_run_id:' + (output.CURRENT_TEST_RUN_ID || 'unknown'), + 'platform:' + (output.CURRENT_PLATFORM || 'unknown'), + ] + + for (let i = 0; i < metrics.length; i++) { + const metric = metrics[i] + const timestamp = Math.floor((metric.timestamp || Date.now()) / 1000) + + // Handle action metrics + if (metric.type === 'action') { + const actionTags = [...baseTags] + + // Add tags only if properties exist + if (metric.flowName != null) actionTags.push('flow_name:' + metric.flowName) + if (metric.actionType != null) actionTags.push('action_type:' + metric.actionType) + if (metric.actionTarget != null) actionTags.push('action_target:' + metric.actionTarget) + if (metric.status != null) actionTags.push('status:' + metric.status) + if (metric.stepNumber != null) actionTags.push('step_number:' + metric.stepNumber) + + series.push({ + metric: 'maestro.e2e.action.duration', + points: [[timestamp, metric.duration]], + type: 'gauge', + tags: actionTags, + }) + } + // Handle flow end events + else if (metric.event === 'flow_end') { + const flowTags = [...baseTags] + + // Add tags only if properties exist + if (metric.flowName != null) flowTags.push('flow_name:' + metric.flowName) + if (metric.status != null) flowTags.push('status:' + metric.status) + + series.push({ + metric: 'maestro.e2e.flow.duration', + points: [[timestamp, metric.duration]], + type: 'gauge', + tags: flowTags, + }) + + // Also send flow count metric + series.push({ + metric: 'maestro.e2e.flow.count', + points: [[timestamp, 1]], + type: 'count', + tags: flowTags, + }) + } + // Handle sub-flow end events + else if (metric.event === 'sub_flow_end') { + const subFlowTags = [...baseTags] + + // Add tags only if properties exist + if (metric.parentFlowName != null) subFlowTags.push('parent_flow_name:' + metric.parentFlowName) + if (metric.subFlowName != null) subFlowTags.push('sub_flow_name:' + metric.subFlowName) + if (metric.status != null) subFlowTags.push('status:' + metric.status) + + series.push({ + metric: 'maestro.e2e.sub_flow.duration', + points: [[timestamp, metric.duration]], + type: 'gauge', + tags: subFlowTags, + }) + } + } + + return series +} + +/** + * Send a single batch of metrics to Datadog via HTTP POST + * @param {Array} series - Array of Datadog series objects + * @returns {boolean} Success status + */ +function sendBatchToDatadog(series) { + if (isDryRun) { + console.log('[DRY RUN] Would send ' + series.length + ' metric series to Datadog') + console.log('[DRY RUN] Payload: ' + JSON.stringify({ series: series }, null, 2)) + return true + } + + if (!apiKey) { + console.log('Error: DATADOG_API_KEY is required') + return false + } + + try { + const payload = JSON.stringify({ series: series }) + + // Use Maestro's http API + const response = http.post(DATADOG_API_URL, { + headers: { + 'Content-Type': 'application/json', + 'DD-API-KEY': apiKey, + }, + body: payload, + }) + + // Check response status + if (response.status === 202) { + console.log('Successfully uploaded ' + series.length + ' metrics to Datadog') + return true + } else { + console.log('Failed to upload metrics. Status: ' + response.status) + console.log('Response: ' + response.body) + return false + } + } catch (e) { + console.log('Error uploading metrics to Datadog: ' + e.message) + return false + } +} + +/** + * Send metrics to Datadog with retry logic and batch processing + * @param {Array} series - Array of Datadog series objects + * @returns {boolean} Overall success status + */ +function sendToDatadog(series) { + // Process in chunks to respect API limits (Datadog has a 5MB limit) + const BATCH_SIZE = 100 // Adjust based on typical metric size + let overallSuccess = true + + for (let i = 0; i < series.length; i += BATCH_SIZE) { + const batch = series.slice(i, i + BATCH_SIZE) + const batchNumber = Math.floor(i / BATCH_SIZE) + 1 + const totalBatches = Math.ceil(series.length / BATCH_SIZE) + + console.log('Processing batch ' + batchNumber + '/' + totalBatches + ' (' + batch.length + ' metrics)') + + // Retry logic with exponential backoff + const maxRetries = 3 + let attempt = 0 + let success = false + + while (attempt < maxRetries && !success) { + success = sendBatchToDatadog(batch) + + if (!success) { + attempt++ + if (attempt < maxRetries) { + const delay = Math.pow(2, attempt - 1) * 1000 // 1s, 2s, 4s + console.log( + 'Retry ' + attempt + '/' + maxRetries + ' for batch ' + batchNumber + ' after ' + delay + 'ms delay', + ) + // Note: GraalJS may not have setTimeout, but we'll document this limitation + // In practice, the delay here is more for logging than actual waiting + } + } + } + + if (!success) { + console.log('Failed to upload batch ' + batchNumber + ' after ' + maxRetries + ' attempts') + overallSuccess = false + // Continue with remaining batches rather than failing completely + } + } + + return overallSuccess +} + +/** + * Generate summary of metrics being uploaded + * @param {Array} metrics - Array of raw metrics + */ +function logMetricsSummary(metrics) { + let actionCount = 0 + let flowCount = 0 + let subFlowCount = 0 + + for (let i = 0; i < metrics.length; i++) { + const metric = metrics[i] + if (metric.type === 'action') actionCount++ + else if (metric.event === 'flow_end') flowCount++ + else if (metric.event === 'sub_flow_end') subFlowCount++ + } + + console.log('Metrics summary:') + console.log(' Actions tracked: ' + actionCount) + console.log(' Flows completed: ' + flowCount) + console.log(' Sub-flows completed: ' + subFlowCount) + console.log(' Total metrics: ' + metrics.length) +} + +// Main execution +console.log('Starting Datadog metrics upload...') + +// Parse metrics from buffer +const metrics = parseMetricsBuffer() + +if (metrics.length === 0) { + console.log('No metrics found in buffer, skipping upload') +} else { + // Log summary + logMetricsSummary(metrics) + + // Convert to Datadog format + const series = convertToDatadogFormat(metrics) + + // Send to Datadog + const success = sendToDatadog(series) + + if (success) { + // Clear buffer after successful upload + output.METRICS_BUFFER = '[]' + console.log('Metrics buffer cleared') + } +} diff --git a/apps/mobile/.maestro/scripts/tooling/buildPerformanceScripts.ts b/apps/mobile/.maestro/scripts/tooling/buildPerformanceScripts.ts new file mode 100644 index 00000000..8ff526bb --- /dev/null +++ b/apps/mobile/.maestro/scripts/tooling/buildPerformanceScripts.ts @@ -0,0 +1,189 @@ +#!/usr/bin/env ts-node + +/** + * Build script for Maestro performance tracking scripts + * Uses esbuild to bundle TypeScript files into self-contained JavaScript + * This compiled Javascript is compatible with the GraalJS runtime + */ + +import { build } from 'esbuild' +import * as fs from 'fs' +import * as path from 'path' + +const PERFORMANCE_DIR = path.resolve(__dirname, '..', 'performance') +const SRC_DIR = path.join(PERFORMANCE_DIR, 'src') +const DIST_DIR = path.join(PERFORMANCE_DIR, 'dist') + +// Ensure dist directories exist +const ensureDir = (dir: string): void => { + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }) + } +} + +ensureDir(DIST_DIR) + +// Recursively find all TypeScript files in a directory +const findTypeScriptFiles = (dir: string): string[] => { + const files: string[] = [] + + if (!fs.existsSync(dir)) { + return files + } + + const entries = fs.readdirSync(dir, { withFileTypes: true }) + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name) + + if (entry.isDirectory()) { + // Recursively search subdirectories + files.push(...findTypeScriptFiles(fullPath)) + } else if (entry.isFile() && entry.name.endsWith('.ts') && !entry.name.endsWith('.d.ts')) { + // Include TypeScript files, but not declaration files + files.push(fullPath) + } + } + + return files +} + +// Discover all TypeScript files in src/actions and src/utils +const actionScripts = findTypeScriptFiles(path.join(SRC_DIR, 'actions')) +const utilScripts = findTypeScriptFiles(path.join(SRC_DIR, 'utils')) + +// Separate Node.js scripts that need different build settings (need Node.js platform) +const nodeScripts = utilScripts.filter((script) => { + const basename = path.basename(script, '.ts') + return basename.includes('extract') || basename.includes('process-metrics') +}) + +// Filter utility scripts to only those that should be built as GraalJS executables +// Exclude Node.js scripts from this list +const executableUtils = utilScripts.filter((script) => { + const basename = path.basename(script, '.ts') + // Only build utilities that are likely to be executed directly (excluding Node.js scripts) + const isNodeScript = basename.includes('extract') || basename.includes('process-metrics') + return !isNodeScript && (basename.includes('upload') || basename.includes('submit')) +}) + +// Ensure output directories exist only for files that will actually be built +const scriptsToBeBuilt = [...actionScripts, ...utilScripts] +const outputDirs = new Set() +for (const script of scriptsToBeBuilt) { + const relativePath = path.relative(SRC_DIR, script) + const outputDir = path.join(DIST_DIR, path.dirname(relativePath)) + outputDirs.add(outputDir) +} + +for (const dir of outputDirs) { + ensureDir(dir) +} + +interface BuildOptions { + entryPoint: string + outfile: string + isNodeScript?: boolean +} + +async function buildScript(options: BuildOptions): Promise { + const { entryPoint, outfile, isNodeScript = false } = options + try { + if (isNodeScript) { + // Build for Node.js execution (e.g., extract-metrics, process-metrics) + await build({ + entryPoints: [entryPoint], + bundle: true, + outfile, + platform: 'node', + target: 'node14', + format: 'cjs', + // Don't minify to keep it debuggable + minify: false, + // Include source maps for debugging + sourcemap: true, + // Node built-ins should be external + external: ['fs', 'path', 'util', 'child_process', 'readline'], + }) + } else { + // Build for GraalJS execution (action scripts) + await build({ + entryPoints: [entryPoint], + bundle: true, + outfile, + platform: 'neutral', + target: 'es2015', + format: 'iife', + // Note: globalName removed - not valid with dots in identifier + footer: { + js: '// GraalJS compatible bundle', + }, + // Don't minify to keep it debuggable + minify: false, + // Include source maps for debugging + sourcemap: true, + // Override any external dependencies - we want everything bundled + external: [], + }) + } + console.log(` ✓ Built ${path.basename(outfile)}`) + } catch (error) { + console.error(` ✗ Failed to build ${path.basename(entryPoint)}:`, error) + throw error + } +} + +async function main(): Promise { + console.log('Building performance scripts for GraalJS...') + console.log() + + // Build action scripts + if (actionScripts.length > 0) { + console.log(`Building ${actionScripts.length} action scripts:`) + for (const entryPoint of actionScripts) { + const relativePath = path.relative(SRC_DIR, entryPoint) + const outfile = path.join(DIST_DIR, relativePath.replace('.ts', '.js')) + await buildScript({ entryPoint, outfile }) + } + } else { + console.log('No action scripts found') + } + + // Build GraalJS utility scripts (excluding Node.js scripts) + if (executableUtils.length > 0) { + console.log() + console.log(`Building ${executableUtils.length} GraalJS utility scripts:`) + for (const entryPoint of executableUtils) { + const relativePath = path.relative(SRC_DIR, entryPoint) + const outfile = path.join(DIST_DIR, relativePath.replace('.ts', '.js')) + await buildScript({ entryPoint, outfile, isNodeScript: false }) + } + } + + // Build Node.js scripts with different settings + if (nodeScripts.length > 0) { + console.log() + console.log(`Building ${nodeScripts.length} Node.js scripts:`) + for (const entryPoint of nodeScripts) { + const relativePath = path.relative(SRC_DIR, entryPoint) + const outfile = path.join(DIST_DIR, relativePath.replace('.ts', '.js')) + await buildScript({ entryPoint, outfile, isNodeScript: true }) + + // Add shebang to make it executable (if not already present) + const content = await fs.promises.readFile(outfile, 'utf-8') + if (!content.startsWith('#!/usr/bin/env node')) { + await fs.promises.writeFile(outfile, `#!/usr/bin/env node\n${content}`) + } + await fs.promises.chmod(outfile, 0o755) + } + } + + console.log() + console.log(`Build complete! Built ${actionScripts.length + executableUtils.length + nodeScripts.length} files.`) +} + +// Run the build +main().catch((error) => { + console.error('Build failed:', error) + process.exit(1) +}) diff --git a/apps/mobile/.maestro/scripts/tooling/generateTestIds.ts b/apps/mobile/.maestro/scripts/tooling/generateTestIds.ts new file mode 100644 index 00000000..0856c38e --- /dev/null +++ b/apps/mobile/.maestro/scripts/tooling/generateTestIds.ts @@ -0,0 +1,14 @@ +import { TestID } from '../../../../../packages/uniswap/src/test/fixtures/testIDs' + +const output: Record = {} + +// Convert TestID enum to Maestro-friendly format +Object.entries(TestID).forEach(([key, value]) => { + output[key] = value +}) + +// Output in Maestro-compatible JavaScript format +console.log(` +// Auto-generated from TestID enum +output.testIds = ${JSON.stringify(output, null, 2)} +`) diff --git a/apps/mobile/.maestro/shared-flows/biometrics-confirm.yaml b/apps/mobile/.maestro/shared-flows/biometrics-confirm.yaml new file mode 100644 index 00000000..ead2cfee --- /dev/null +++ b/apps/mobile/.maestro/shared-flows/biometrics-confirm.yaml @@ -0,0 +1,36 @@ +# multiple flows have the biometrics confirm step, so we abstract it here +appId: com.uniswap.mobile.dev +jsEngine: graaljs +--- +# Start tracking shared flow +- runScript: + file: ../scripts/performance/dist/actions/start-sub-flow.js + env: + SUB_FLOW_NAME: 'shared-biometrics-confirm' + +# Wait for and tap confirm +- extendedWaitUntil: + visible: + id: ${output.testIds.Confirm} + timeout: 10000 +- runScript: + file: ../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tapOn' + TARGET: 'BiometricsConfirm' + PHASE: 'start' +- tapOn: + id: ${output.testIds.Confirm} # are you sure? +- runScript: + file: ../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tapOn' + TARGET: 'BiometricsConfirm' + PHASE: 'end' +- waitForAnimationToEnd + +# End tracking shared flow +- runScript: + file: ../scripts/performance/dist/actions/end-sub-flow.js + env: + SUB_FLOW_NAME: 'shared-biometrics-confirm' diff --git a/apps/mobile/.maestro/shared-flows/copy-wallet-address.yaml b/apps/mobile/.maestro/shared-flows/copy-wallet-address.yaml new file mode 100644 index 00000000..cb610e36 --- /dev/null +++ b/apps/mobile/.maestro/shared-flows/copy-wallet-address.yaml @@ -0,0 +1,42 @@ +appId: com.uniswap.mobile.dev +--- +# Shared flow to copy the wallet's own address from the Receive QR modal +# Must run from the home screen +# After running this flow, use `pasteText` to paste the address into an input field +# The address is stored in Maestro's internal clipboard via copyTextFrom + +# Step 1: Ensure the Receive button is visible and tap it +- assertVisible: + id: ${output.testIds.Receive} +- tapOn: + id: ${output.testIds.Receive} +- waitForAnimationToEnd + +# Wait for either the ReceiveCryptoModal (with WalletReceiveCrypto) or the QR modal (with AddressDisplay) +# The app may show ReceiveCryptoModal if CEX providers are available, or skip directly to QR modal +- extendedWaitUntil: + visible: + id: ${output.testIds.WalletReceiveCrypto} + timeout: 8000 + optional: true + +# Step 2: If ReceiveCryptoModal appeared, tap on the wallet card to open QR modal +- tapOn: + id: ${output.testIds.WalletReceiveCrypto} + optional: true +- waitForAnimationToEnd + +# Wait for the QR modal with full address to appear +- extendedWaitUntil: + visible: + id: ${output.testIds.AddressDisplay} + timeout: 8000 + +# Step 3: Copy the full wallet address from the display +- copyTextFrom: + id: ${output.testIds.AddressDisplay} + +# Step 4: Swipe down to dismiss the modal +- swipe: + direction: DOWN + duration: 300 diff --git a/apps/mobile/.maestro/shared-flows/delete-seed-phrase.yaml b/apps/mobile/.maestro/shared-flows/delete-seed-phrase.yaml new file mode 100644 index 00000000..17d628ee --- /dev/null +++ b/apps/mobile/.maestro/shared-flows/delete-seed-phrase.yaml @@ -0,0 +1,83 @@ +# Shared flow for deleting a wallet's seed phrase through the settings menu + +appId: com.uniswap.mobile.dev +--- +# Wait for the SettingsIcon button to be visible; without this, e2e test sometimes registers a success but the button is not actually tapped. +- extendedWaitUntil: + visible: 'noop' + timeout: 2000 + optional: true + +# Start tracking shared flow +- runScript: + file: ../scripts/performance/dist/actions/start-sub-flow.js + env: + SUB_FLOW_NAME: 'shared-delete-seed-phrase' + +# Navigate to settings +- runScript: + file: ../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tapOn' + TARGET: 'SettingsIcon' + PHASE: 'start' +- tapOn: + id: 'account-header-settings-icon' +- runScript: + file: ../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tapOn' + TARGET: 'SettingsIcon' + PHASE: 'end' +- waitForAnimationToEnd + +# Swipe to dev modal +- scrollUntilVisible: + element: + id: 'app-settings-dev-modal' + direction: DOWN +- waitForAnimationToEnd + +# Open dev modal +- runScript: + file: ../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tapOn' + TARGET: 'DevModal' + PHASE: 'start' +- tapOn: + id: 'app-settings-dev-modal' +- runScript: + file: ../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tapOn' + TARGET: 'DevModal' + PHASE: 'end' +- waitForAnimationToEnd + +# Open seed phrase accordion +- tapOn: + id: 'seed-phrase-private-keys-accordion' + +# Delete seed phrase +- runScript: + file: ../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tapOn' + TARGET: 'DeleteSeedPhrase' + PHASE: 'start' +- tapOn: + id: 'delete-seed-phrase-button' +- tapOn: 'Delete' +- runScript: + file: ../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tapOn' + TARGET: 'DeleteSeedPhrase' + PHASE: 'end' + +# End tracking shared flow +- runScript: + file: ../scripts/performance/dist/actions/end-sub-flow.js + env: + SUB_FLOW_NAME: 'shared-delete-seed-phrase' diff --git a/apps/mobile/.maestro/shared-flows/navigate-to-explore.yaml b/apps/mobile/.maestro/shared-flows/navigate-to-explore.yaml new file mode 100644 index 00000000..200f366a --- /dev/null +++ b/apps/mobile/.maestro/shared-flows/navigate-to-explore.yaml @@ -0,0 +1,37 @@ +appId: com.uniswap.mobile.dev +--- +# This flow handles the common action of navigating to the Explore tab +# from the main wallet screen. It supports both bottom tabs navigation (new) +# and the legacy floating navigation bar. + +# Wait for the home screen to be visible +- extendedWaitUntil: + visible: "noop" + timeout: 2000 + optional: true + +# OPTION 1: Try bottom tabs navigation first (new UI pattern) +# This will be available when BottomTabs feature flag is enabled +- tapOn: + id: ${output.testIds.ExploreTab} + optional: true + +# TODO: INFRA-1074 - Remove this fallback when we no longer support the legacy navigation bar +# OPTION 2: Fallback to legacy floating navigation bar +# This will be used when BottomTabs feature flag is disabled +- tapOn: + id: ${output.testIds.SearchTokensAndWallets} + optional: true + +# Wait for tab animations to complete (200ms animation duration) +- waitForAnimationToEnd + +# Verify we're in the Explore screen by checking for the search input +# This verification works for both navigation patterns +- extendedWaitUntil: + visible: + id: ${output.testIds.ExploreSearchInput} + timeout: 3000 + +- assertVisible: + id: ${output.testIds.ExploreSearchInput} diff --git a/apps/mobile/.maestro/shared-flows/recover-fast.yaml b/apps/mobile/.maestro/shared-flows/recover-fast.yaml new file mode 100644 index 00000000..fed548cc --- /dev/null +++ b/apps/mobile/.maestro/shared-flows/recover-fast.yaml @@ -0,0 +1,139 @@ +# this flow is used for flows that test the app with a pre-existing wallet +# check flows/onboarding for all onboarding specific flows +appId: com.uniswap.mobile.dev +jsEngine: graaljs +env: + E2E_RECOVERY_PHRASE: ${E2E_RECOVERY_PHRASE} +--- +# Start tracking shared flow +- runScript: + file: ../scripts/performance/dist/actions/start-sub-flow.js + env: + SUB_FLOW_NAME: 'shared-recover-fast' + +# Wait for and tap Import Account +- extendedWaitUntil: + visible: + id: ${output.testIds.ImportAccount} + timeout: 30000 # Starting the app during local dev loads the JS bundle which can take much longer than a normal build +- runScript: + file: ../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tapOn' + TARGET: 'ImportAccount' + PHASE: 'start' +- tapOn: + id: ${output.testIds.ImportAccount} +- runScript: + file: ../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tapOn' + TARGET: 'ImportAccount' + PHASE: 'end' +- waitForAnimationToEnd + +# Import seed phrase selection +- runScript: + file: ../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tapOn' + TARGET: 'OnboardingImportSeedPhrase' + PHASE: 'start' +- tapOn: + id: ${output.testIds.OnboardingImportSeedPhrase} +- runScript: + file: ../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tapOn' + TARGET: 'OnboardingImportSeedPhrase' + PHASE: 'end' +- waitForAnimationToEnd + +# Input recovery phrase +- runScript: + file: ../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'inputText' + TARGET: 'RecoveryPhrase' + PHASE: 'start' +- inputText: ${E2E_RECOVERY_PHRASE} +- runScript: + file: ../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'inputText' + TARGET: 'RecoveryPhrase' + PHASE: 'end' +- waitForAnimationToEnd + +# Continue after seed phrase +- runScript: + file: ../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tapOn' + TARGET: 'Continue-SeedPhrase' + PHASE: 'start' +- tapOn: + id: ${output.testIds.Continue} +- runScript: + file: ../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tapOn' + TARGET: 'Continue-SeedPhrase' + PHASE: 'end' +- waitForAnimationToEnd + +# Wait for wallet to load - this is a critical performance metric +- runScript: + file: ../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'waitFor' + TARGET: 'WalletLoad' + PHASE: 'start' +- extendedWaitUntil: + visible: + id: ${output.testIds.SelectWalletScreenLoaded} + timeout: 60000 # 1 minute (sometimes it takes longer to load in e2e) +- runScript: + file: ../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'waitFor' + TARGET: 'WalletLoad' + PHASE: 'end' + +# Continue from wallet selection +- runScript: + file: ../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tapOn' + TARGET: 'Continue-WalletSelection' + PHASE: 'start' +- tapOn: + id: ${output.testIds.Continue} +- runScript: + file: ../scripts/performance/dist/actions/track-action.js + env: + ACTION: 'tapOn' + TARGET: 'Continue-WalletSelection' + PHASE: 'end' +- waitForAnimationToEnd + +# Skip screens +- tapOn: + id: ${output.testIds.Skip} +- waitForAnimationToEnd +- tapOn: + id: ${output.testIds.Skip} +- waitForAnimationToEnd +- tapOn: + id: ${output.testIds.Skip} +- waitForAnimationToEnd + +# Biometrics confirmation +- runFlow: biometrics-confirm.yaml +- waitForAnimationToEnd + +# End tracking shared flow +- runScript: + file: ../scripts/performance/dist/actions/end-sub-flow.js + env: + SUB_FLOW_NAME: 'shared-recover-fast' diff --git a/apps/mobile/.maestro/shared-flows/start.yaml b/apps/mobile/.maestro/shared-flows/start.yaml new file mode 100644 index 00000000..cfff9954 --- /dev/null +++ b/apps/mobile/.maestro/shared-flows/start.yaml @@ -0,0 +1,59 @@ +# this flow should be used at the beginning of every flow that tests the app +# it will generate the test ids and launch the app consistently across all flows +appId: com.uniswap.mobile.dev +--- +- runScript: ../scripts/dist/testIds.js + +# Start tracking shared flow +- runScript: + file: ../scripts/performance/dist/actions/start-sub-flow.js + env: + SUB_FLOW_NAME: "shared-start" + +# Track app launch +- runScript: + file: ../scripts/performance/dist/actions/track-action.js + env: + ACTION: "launchApp" + TARGET: "app" + PHASE: "start" +- launchApp: + permissions: + contacts: unset + notifications: unset + clearState: true + clearKeychain: true # optional: clear *entire* iOS keychain +# Handle Expo Dev Client screens (only present in development builds, not CI/prod builds) +- runFlow: + when: + true: ${CI != 'true'} + commands: + # Launch dev client with disableOnboarding to skip onboarding popup (only works for iOS) + - openLink: "uniswap://expo-development-client/?url=http://localhost:8081&disableOnboarding=1" + # Wait for app to fully load after deep link + - waitForAnimationToEnd + # On Android, disableOnboarding doesn't work - tap the Metro server URL to continue + - tapOn: + text: ".*:8081" + optional: true + - waitForAnimationToEnd + # Dismiss the expo dev menu + - tapOn: + text: "continue" + - tapOn: + point: "10%,10%" + retryTapIfNoChange: false + optional: true + - waitForAnimationToEnd +- runScript: + file: ../scripts/performance/dist/actions/track-action.js + env: + ACTION: "launchApp" + TARGET: "app" + PHASE: "end" + +# End tracking shared flow +- runScript: + file: ../scripts/performance/dist/actions/end-sub-flow.js + env: + SUB_FLOW_NAME: "shared-start" diff --git a/apps/mobile/.oxlintrc.json b/apps/mobile/.oxlintrc.json new file mode 100644 index 00000000..f812ebb6 --- /dev/null +++ b/apps/mobile/.oxlintrc.json @@ -0,0 +1,86 @@ +{ + "extends": ["../../.oxlintrc.json"], + "ignorePatterns": [".maestro/**", "scripts/**", "ReactotronConfig.ts", "index.js", ".storybook/**"], + "rules": { + "no-shadow": "error", + "no-restricted-imports": [ + "error", + { + "paths": [ + { + "name": "react-router", + "message": "Do not import react-router in native code. Use react-navigation instead." + } + ] + } + ], + "universe-custom/no-transform-percentage-strings": "error", + "@jambit/typed-redux-saga/use-typed-effects": "error", + "@jambit/typed-redux-saga/delegate-effects": "error", + "complexity": ["error", 20], + "max-depth": ["error", 4], + "max-nested-callbacks": ["error", 3], + "no-restricted-syntax": [ + "error", + { + "selector": "CallExpression[callee.object.name='z'][callee.property.name='any']", + "message": "Avoid using z.any() in favor of more precise custom types." + } + ] + }, + "overrides": [ + { + "files": ["**/*.ts", "**/*.tsx"], + "rules": { + "universe-custom/no-relative-import-paths": [ + "error", + { + "allowSameFolder": false, + "prefix": "src" + } + ], + "typescript/explicit-function-return-type": "off" + } + }, + { + "files": ["**/*.ts", "**/*.tsx"], + "rules": { + "universe-custom/no-nested-component-definitions": "error", + "universe-custom/jsx-prop-order": [ + "error", + { + "groups": ["reserved", "shorthand-prop", "unknown", "callback"], + "reservedPattern": "^(key|ref)$", + "callbackPattern": "^on[A-Z].+" + } + ] + } + }, + { + "files": ["**/*saga*.ts", "**/*Saga.ts", "**/handleDeepLink.ts"], + "rules": { + "typescript/prefer-enum-initializers": "off" + } + }, + { + "files": ["migrations.ts"], + "rules": { + "typescript/prefer-enum-initializers": "off", + "typescript/no-non-null-assertion": "off", + "typescript/no-empty-interface": "off", + "typescript/no-explicit-any": "off" + } + }, + { + "files": [".maestro/scripts/**"], + "rules": { + "no-restricted-imports": "off", + "curly": "off", + "no-console": "off", + "no-lone-blocks": "off", + "no-case-declarations": "off", + "no-unused-vars": "off" + } + } + ] +} diff --git a/apps/mobile/.storybook/index.jest.tsx b/apps/mobile/.storybook/index.jest.tsx new file mode 100644 index 00000000..58978a83 --- /dev/null +++ b/apps/mobile/.storybook/index.jest.tsx @@ -0,0 +1,3 @@ +const StorybookUIRoot = () => null + +export default StorybookUIRoot diff --git a/apps/mobile/.storybook/index.tsx b/apps/mobile/.storybook/index.tsx new file mode 100644 index 00000000..b583e00c --- /dev/null +++ b/apps/mobile/.storybook/index.tsx @@ -0,0 +1,19 @@ +import { MMKV } from 'react-native-mmkv' +// oxlint-disable-next-line universe-custom/no-relative-import-paths -- biome-parity: oxlint is stricter here +import { view } from './storybook.requires' + +const mmkv = new MMKV({ + id: 'storybook-wallet', +}) + +const StorybookUIRoot = view.getStorybookUI({ + storage: { + getItem: (key): Promise => Promise.resolve(mmkv.getString(key) || null), + setItem: (key, value): Promise => { + mmkv.set(key, value) + return Promise.resolve() + }, + }, +}) + +export default StorybookUIRoot diff --git a/apps/mobile/.storybook/main.ts b/apps/mobile/.storybook/main.ts new file mode 100644 index 00000000..7ecadf63 --- /dev/null +++ b/apps/mobile/.storybook/main.ts @@ -0,0 +1,12 @@ +import type { StorybookConfig } from '@storybook/react-native' + +const config: StorybookConfig = { + stories: [ + '../src/**/*.stories.?(ts|tsx|js|jsx)', + '../../../packages/ui/src/**/*.stories.?(ts|tsx|js|jsx)', + '../../../packages/uniswap/src/**/*.stories.?(ts|tsx|js|jsx)', + ], + addons: ['@storybook/addon-ondevice-controls'], +} + +export default config diff --git a/apps/mobile/.storybook/preview.tsx b/apps/mobile/.storybook/preview.tsx new file mode 100644 index 00000000..00730e77 --- /dev/null +++ b/apps/mobile/.storybook/preview.tsx @@ -0,0 +1,5 @@ +import type { Preview } from '@storybook/react' + +const preview: Preview = {} + +export default preview diff --git a/apps/mobile/.storybook/storybook.requires.ts b/apps/mobile/.storybook/storybook.requires.ts new file mode 100644 index 00000000..c73eb0d5 --- /dev/null +++ b/apps/mobile/.storybook/storybook.requires.ts @@ -0,0 +1,73 @@ +/* do not change this file, it is auto generated by storybook. */ + +import { start, updateView } from "@storybook/react-native"; + +import "@storybook/addon-ondevice-controls/register"; + +const normalizedStories = [ + { + titlePrefix: "", + directory: "./src", + files: "**/*.stories.?(ts|tsx|js|jsx)", + importPathMatcher: + /^\.(?:(?:^|\/|(?:(?:(?!(?:^|\/)\.).)*?)\/)(?!\.)(?=.)[^/]*?\.stories\.(?:ts|tsx|js|jsx)?)$/, + // @ts-ignore + req: require.context( + "../src", + true, + /^\.(?:(?:^|\/|(?:(?:(?!(?:^|\/)\.).)*?)\/)(?!\.)(?=.)[^/]*?\.stories\.(?:ts|tsx|js|jsx)?)$/ + ), + }, + { + titlePrefix: "", + directory: "../../packages/ui/src", + files: "**/*.stories.?(ts|tsx|js|jsx)", + importPathMatcher: + /^\.(?:(?:^|\/|(?:(?:(?!(?:^|\/)\.).)*?)\/)(?!\.)(?=.)[^/]*?\.stories\.(?:ts|tsx|js|jsx)?)$/, + // @ts-ignore + req: require.context( + "../../../packages/ui/src", + true, + /^\.(?:(?:^|\/|(?:(?:(?!(?:^|\/)\.).)*?)\/)(?!\.)(?=.)[^/]*?\.stories\.(?:ts|tsx|js|jsx)?)$/ + ), + }, + { + titlePrefix: "", + directory: "../../packages/uniswap/src", + files: "**/*.stories.?(ts|tsx|js|jsx)", + importPathMatcher: + /^\.(?:(?:^|\/|(?:(?:(?!(?:^|\/)\.).)*?)\/)(?!\.)(?=.)[^/]*?\.stories\.(?:ts|tsx|js|jsx)?)$/, + // @ts-ignore + req: require.context( + "../../../packages/uniswap/src", + true, + /^\.(?:(?:^|\/|(?:(?:(?!(?:^|\/)\.).)*?)\/)(?!\.)(?=.)[^/]*?\.stories\.(?:ts|tsx|js|jsx)?)$/ + ), + }, +]; + +declare global { + var view: ReturnType; + var STORIES: typeof normalizedStories; +} + +const annotations = [ + require("./preview"), + require("@storybook/react-native/preview"), +]; + +global.STORIES = normalizedStories; + +// @ts-ignore +module?.hot?.accept?.(); + +if (!global.view) { + global.view = start({ + annotations, + storyEntries: normalizedStories, + }); +} else { + updateView(global.view, annotations, normalizedStories); +} + +export const view = global.view; diff --git a/apps/mobile/.watchmanconfig b/apps/mobile/.watchmanconfig new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/apps/mobile/.watchmanconfig @@ -0,0 +1 @@ +{} diff --git a/apps/mobile/Gemfile b/apps/mobile/Gemfile new file mode 100644 index 00000000..e1d5aa1d --- /dev/null +++ b/apps/mobile/Gemfile @@ -0,0 +1,23 @@ +source "https://rubygems.org" + +gem 'fastlane', '2.228.0' +gem 'cocoapods', '1.16.2' +gem 'activesupport', '7.1.2' +gem 'xcodeproj', '1.27.0' +gem 'concurrent-ruby', '1.3.4' + +# Ruby 3.4.0 removed these from the standard library. +# See: https://github.com/fastlane/fastlane/issues/29183 +# See: https://www.ruby-lang.org/en/news/2024/12/25/ruby-3-4-0-released/ +gem 'abbrev' +gem 'base64' +gem 'bigdecimal' +gem 'benchmark' +gem 'drb' +gem 'logger' +gem 'mutex_m' +gem 'nkf' +gem 'ostruct' + +plugins_path = File.join(File.dirname(__FILE__), 'fastlane', 'Pluginfile') +eval_gemfile(plugins_path) if File.exist?(plugins_path) diff --git a/apps/mobile/Gemfile.lock b/apps/mobile/Gemfile.lock new file mode 100644 index 00000000..16299777 --- /dev/null +++ b/apps/mobile/Gemfile.lock @@ -0,0 +1,367 @@ +GEM + remote: https://rubygems.org/ + specs: + CFPropertyList (3.0.6) + rexml + abbrev (0.1.2) + activesupport (7.1.2) + base64 + bigdecimal + concurrent-ruby (~> 1.0, >= 1.0.2) + connection_pool (>= 2.2.5) + drb + i18n (>= 1.6, < 2) + minitest (>= 5.1) + mutex_m + tzinfo (~> 2.0) +<<<<<<< HEAD + addressable (2.8.8) + public_suffix (>= 2.0.2, < 8.0) +======= + addressable (2.8.6) + public_suffix (>= 2.0.2, < 6.0) +>>>>>>> upstream/main + algoliasearch (1.27.5) + httpclient (~> 2.8, >= 2.8.3) + json (>= 1.5.1) + artifactory (3.0.17) + atomos (0.1.3) + aws-eventstream (1.4.0) + aws-partitions (1.1162.0) + aws-sdk-core (3.232.0) + aws-eventstream (~> 1, >= 1.3.0) + aws-partitions (~> 1, >= 1.992.0) + aws-sigv4 (~> 1.9) + base64 + bigdecimal + jmespath (~> 1, >= 1.6.1) + logger + aws-sdk-kms (1.112.0) + aws-sdk-core (~> 3, >= 3.231.0) + aws-sigv4 (~> 1.5) + aws-sdk-s3 (1.199.0) + aws-sdk-core (~> 3, >= 3.231.0) + aws-sdk-kms (~> 1) + aws-sigv4 (~> 1.5) + aws-sigv4 (1.12.1) + aws-eventstream (~> 1, >= 1.0.2) + babosa (1.0.4) +<<<<<<< HEAD + base64 (0.3.0) + benchmark (0.4.1) + bigdecimal (4.0.1) +======= + base64 (0.2.0) + benchmark (0.4.1) + bigdecimal (3.1.9) +>>>>>>> upstream/main + claide (1.1.0) + cocoapods (1.16.2) + addressable (~> 2.8) + claide (>= 1.0.2, < 2.0) + cocoapods-core (= 1.16.2) + cocoapods-deintegrate (>= 1.0.3, < 2.0) + cocoapods-downloader (>= 2.1, < 3.0) + cocoapods-plugins (>= 1.0.0, < 2.0) + cocoapods-search (>= 1.0.0, < 2.0) + cocoapods-trunk (>= 1.6.0, < 2.0) + cocoapods-try (>= 1.1.0, < 2.0) + colored2 (~> 3.1) + escape (~> 0.0.4) + fourflusher (>= 2.3.0, < 3.0) + gh_inspector (~> 1.0) + molinillo (~> 0.8.0) + nap (~> 1.0) + ruby-macho (>= 2.3.0, < 3.0) + xcodeproj (>= 1.27.0, < 2.0) + cocoapods-core (1.16.2) + activesupport (>= 5.0, < 8) + addressable (~> 2.8) + algoliasearch (~> 1.0) + concurrent-ruby (~> 1.1) + fuzzy_match (~> 2.0.4) + nap (~> 1.0) + netrc (~> 0.11) + public_suffix (~> 4.0) + typhoeus (~> 1.0) + cocoapods-deintegrate (1.0.5) + cocoapods-downloader (2.1) + cocoapods-plugins (1.0.0) + nap + cocoapods-search (1.0.1) + cocoapods-trunk (1.6.0) + nap (>= 0.8, < 2.0) + netrc (~> 0.11) + cocoapods-try (1.2.0) + colored (1.2) + colored2 (3.1.2) + commander (4.6.0) + highline (~> 2.0.0) + concurrent-ruby (1.3.4) +<<<<<<< HEAD + connection_pool (3.0.2) +======= + connection_pool (2.5.0) +>>>>>>> upstream/main + declarative (0.0.20) + digest-crc (0.7.0) + rake (>= 12.0.0, < 14.0.0) + domain_name (0.6.20240107) + dotenv (2.8.1) +<<<<<<< HEAD + drb (2.2.3) +======= + drb (2.2.1) +>>>>>>> upstream/main + emoji_regex (3.2.3) + escape (0.0.4) + ethon (0.15.0) + ffi (>= 1.15.0) + excon (0.112.0) + faraday (1.10.4) + faraday-em_http (~> 1.0) + faraday-em_synchrony (~> 1.0) + faraday-excon (~> 1.1) + faraday-httpclient (~> 1.0) + faraday-multipart (~> 1.0) + faraday-net_http (~> 1.0) + faraday-net_http_persistent (~> 1.0) + faraday-patron (~> 1.0) + faraday-rack (~> 1.0) + faraday-retry (~> 1.0) + ruby2_keywords (>= 0.0.4) + faraday-cookie_jar (0.0.7) + faraday (>= 0.8.0) + http-cookie (~> 1.0.0) + faraday-em_http (1.0.0) + faraday-em_synchrony (1.0.1) + faraday-excon (1.1.0) + faraday-httpclient (1.0.1) + faraday-multipart (1.1.1) + multipart-post (~> 2.0) + faraday-net_http (1.0.2) + faraday-net_http_persistent (1.2.0) + faraday-patron (1.0.0) + faraday-rack (1.0.0) + faraday-retry (1.0.3) + faraday_middleware (1.2.1) + faraday (~> 1.0) + fastimage (2.4.1) + fastlane (2.228.0) + CFPropertyList (>= 2.3, < 4.0.0) + addressable (>= 2.8, < 3.0.0) + artifactory (~> 3.0) + aws-sdk-s3 (~> 1.0) + babosa (>= 1.0.3, < 2.0.0) + bundler (>= 1.12.0, < 3.0.0) + colored (~> 1.2) + commander (~> 4.6) + dotenv (>= 2.1.1, < 3.0.0) + emoji_regex (>= 0.1, < 4.0) + excon (>= 0.71.0, < 1.0.0) + faraday (~> 1.0) + faraday-cookie_jar (~> 0.0.6) + faraday_middleware (~> 1.0) + fastimage (>= 2.1.0, < 3.0.0) + fastlane-sirp (>= 1.0.0) + gh_inspector (>= 1.1.2, < 2.0.0) + google-apis-androidpublisher_v3 (~> 0.3) + google-apis-playcustomapp_v1 (~> 0.1) + google-cloud-env (>= 1.6.0, < 2.0.0) + google-cloud-storage (~> 1.31) + highline (~> 2.0) + http-cookie (~> 1.0.5) + json (< 3.0.0) + jwt (>= 2.1.0, < 3) + mini_magick (>= 4.9.4, < 5.0.0) + multipart-post (>= 2.0.0, < 3.0.0) + naturally (~> 2.2) + optparse (>= 0.1.1, < 1.0.0) + plist (>= 3.1.0, < 4.0.0) + rubyzip (>= 2.0.0, < 3.0.0) + security (= 0.1.5) + simctl (~> 1.6.3) + terminal-notifier (>= 2.0.0, < 3.0.0) + terminal-table (~> 3) + tty-screen (>= 0.6.3, < 1.0.0) + tty-spinner (>= 0.8.0, < 1.0.0) + word_wrap (~> 1.0.0) + xcodeproj (>= 1.13.0, < 2.0.0) + xcpretty (~> 0.4.1) + xcpretty-travis-formatter (>= 0.0.3, < 2.0.0) +<<<<<<< HEAD + fastlane-sirp (1.0.0) + sysrandom (~> 1.0) + ffi (1.17.3-arm64-darwin) +======= + fastlane-plugin-get_version_name (0.2.2) + fastlane-plugin-versioning_android (0.1.1) + fastlane-sirp (1.0.0) + sysrandom (~> 1.0) + ffi (1.17.2-arm64-darwin) +>>>>>>> upstream/main + fourflusher (2.3.1) + fuzzy_match (2.0.4) + gh_inspector (1.1.3) + google-apis-androidpublisher_v3 (0.54.0) + google-apis-core (>= 0.11.0, < 2.a) + google-apis-core (0.11.3) + addressable (~> 2.5, >= 2.5.1) + googleauth (>= 0.16.2, < 2.a) + httpclient (>= 2.8.1, < 3.a) + mini_mime (~> 1.0) + representable (~> 3.0) + retriable (>= 2.0, < 4.a) + rexml + google-apis-iamcredentials_v1 (0.17.0) + google-apis-core (>= 0.11.0, < 2.a) + google-apis-playcustomapp_v1 (0.13.0) + google-apis-core (>= 0.11.0, < 2.a) + google-apis-storage_v1 (0.31.0) + google-apis-core (>= 0.11.0, < 2.a) + google-cloud-core (1.8.0) + google-cloud-env (>= 1.0, < 3.a) + google-cloud-errors (~> 1.0) + google-cloud-env (1.6.0) + faraday (>= 0.17.3, < 3.0) + google-cloud-errors (1.5.0) + google-cloud-storage (1.47.0) + addressable (~> 2.8) + digest-crc (~> 0.4) + google-apis-iamcredentials_v1 (~> 0.1) + google-apis-storage_v1 (~> 0.31.0) + google-cloud-core (~> 1.6) + googleauth (>= 0.16.2, < 2.a) + mini_mime (~> 1.0) + googleauth (1.8.1) + faraday (>= 0.17.3, < 3.a) + jwt (>= 1.4, < 3.0) + multi_json (~> 1.11) + os (>= 0.9, < 2.0) + signet (>= 0.16, < 2.a) + highline (2.0.3) + http-cookie (1.0.8) + domain_name (~> 0.5) +<<<<<<< HEAD + httpclient (2.9.0) + mutex_m + i18n (1.14.8) + concurrent-ruby (~> 1.0) + jmespath (1.6.2) + json (2.18.0) +======= + httpclient (2.8.3) + i18n (1.14.7) + concurrent-ruby (~> 1.0) + jmespath (1.6.2) + json (2.7.1) +>>>>>>> upstream/main + jwt (2.10.2) + base64 + logger (1.7.0) + mini_magick (4.13.2) + mini_mime (1.1.5) +<<<<<<< HEAD + minitest (6.0.1) + prism (~> 1.5) +======= + minitest (5.25.4) +>>>>>>> upstream/main + molinillo (0.8.0) + multi_json (1.17.0) + multipart-post (2.4.1) + mutex_m (0.3.0) + nanaimo (0.4.0) + nap (1.1.0) + naturally (2.3.0) + netrc (0.11.0) + nkf (0.2.0) + optparse (0.6.0) + os (1.1.4) + ostruct (0.6.3) + plist (3.7.2) +<<<<<<< HEAD + prism (1.7.0) +======= +>>>>>>> upstream/main + public_suffix (4.0.7) + rake (13.3.0) + representable (3.2.0) + declarative (< 0.1.0) + trailblazer-option (>= 0.1.1, < 0.2.0) + uber (< 0.2.0) + retriable (3.1.2) +<<<<<<< HEAD + rexml (3.4.4) +======= + rexml (3.4.1) +>>>>>>> upstream/main + rouge (3.28.0) + ruby-macho (2.5.1) + ruby2_keywords (0.0.5) + rubyzip (2.4.1) + security (0.1.5) + signet (0.21.0) + addressable (~> 2.8) + faraday (>= 0.17.5, < 3.a) + jwt (>= 1.5, < 4.0) + multi_json (~> 1.10) + simctl (1.6.10) + CFPropertyList + naturally + sysrandom (1.0.5) + terminal-notifier (2.0.0) + terminal-table (3.0.2) + unicode-display_width (>= 1.1.1, < 3) + trailblazer-option (0.1.2) + tty-cursor (0.7.1) + tty-screen (0.8.2) + tty-spinner (0.9.3) + tty-cursor (~> 0.7) + typhoeus (1.5.0) + ethon (>= 0.9.0, < 0.16.0) + tzinfo (2.0.6) + concurrent-ruby (~> 1.0) + uber (0.1.0) + unicode-display_width (2.6.0) + word_wrap (1.0.0) + xcodeproj (1.27.0) + CFPropertyList (>= 2.3.3, < 4.0) + atomos (~> 0.1.3) + claide (>= 1.0.2, < 2.0) + colored2 (~> 3.1) + nanaimo (~> 0.4.0) + rexml (>= 3.3.6, < 4.0) + xcpretty (0.4.1) + rouge (~> 3.28.0) + xcpretty-travis-formatter (1.0.1) + xcpretty (~> 0.2, >= 0.0.7) + +PLATFORMS + arm64-darwin-23 + arm64-darwin-24 + arm64-darwin-25 + +DEPENDENCIES + abbrev + activesupport (= 7.1.2) + base64 + benchmark + bigdecimal + cocoapods (= 1.16.2) + concurrent-ruby (= 1.3.4) + drb + fastlane (= 2.228.0) +<<<<<<< HEAD +======= + fastlane-plugin-get_version_name + fastlane-plugin-versioning_android +>>>>>>> upstream/main + logger + mutex_m + nkf + ostruct + xcodeproj (= 1.27.0) + +BUNDLED WITH + 2.4.10 diff --git a/apps/mobile/LICENSE b/apps/mobile/LICENSE new file mode 100644 index 00000000..f288702d --- /dev/null +++ b/apps/mobile/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/apps/mobile/README.md b/apps/mobile/README.md new file mode 100644 index 00000000..69a8e1a9 --- /dev/null +++ b/apps/mobile/README.md @@ -0,0 +1,349 @@ +# Uniswap Wallet + +[Uniswap Wallet](https://wallet.uniswap.org/) is the simplest, safest, and most powerful self-custodial crypto wallet. It is developed by the Uniswap Labs team, inventors of the Uniswap Protocol. + +If you have suggestions on how we can improve the app, or would like to report a bug or a problem, check out the [Uniswap Help Center](https://support.uniswap.org/). + +## Table of contents + +- [Setup](#setup) + - [Packages and Software](#packages-and-software) + - [iOS Setup](#ios-setup) + - [Xcode](#xcode) + - [Add Xcode Command Line Tools](#add-xcode-command-line-tools) + - [Android Setup](#android-setup) + - [Deploying to Physical Android Device](#deploying-to-physical-android-device) +- [Development](#development) + - [Environment variables](#environment-variables) + - [Compile contract ABI types](#compile-contract-abi-types) + - [Run the app](#run-the-app) + - [Using Radon IDE](#using-radon-ide-vscodecursor-extension) + - [Running on a Physical iOS Device](#running-on-a-physical-ios-device) +- [Important Libraries and Tools](#important-libraries-and-tools) +- [Migrations](#migrations) +- [Testing & Performance](#testing--performance) + - [Build local app files](./docs/build-app-files.md) + - [E2E testing](./docs/e2e-mobile.md) + - [Performance monitoring](./docs/perf-monitoring.md) +- [Troubleshooting](#troubleshooting) + - [Common issues](#common-issues) + - [Common fixes](#common-fixes) + - [Shell profile setup](#shell-profile-setup) + +## Setup + +This guide assumes that: + +- You are using a Mac (you will need a Mac computer in order to run the Xcode iOS Simulator) +- You are using an Apple Silicon Mac (if you’re not sure, go to  → About this Mac and check if the chip name starts with "Apple") + +Note: If you are indeed using an Apple Silicon Mac, we recommend setting up your environment _without_ using Rosetta. Some instructions on how to do that can be found [here](https://medium.com/@davidjasonharding/developing-a-react-native-app-on-an-m1-mac-without-rosetta-29fcc7314d70). + +- [React Native Requirements](#packages-and-software) +- [iOS Setup](#ios-setup) + - NOTE: Start downloading [Xcode](#xcode) first since it's a large file +- [Android Setup](#android-setup) + +### Packages and Software + +1. Install `homebrew`. We’ll be using Homebrew to install many of the other required tools through the command line. Open a terminal and Copy and paste the command from [brew.sh](https://brew.sh/) into your terminal and run it +2. Install `nvm` [Node Version Manager](https://github.com/nvm-sh/nvm) While not required, it makes it easy to install Node and switch between different versions. Use the version of `node` specified in `.nvmrc`. + + - Copy the curl command listed under _Install & Update Script_ on [this page](https://github.com/nvm-sh/nvm#install--update-script) and run it in your terminal. + - To make sure nvm installed correctly, try running `nvm -v` (you may need to re-source your shell with `source {base config}`). It should return a version number. If it returns something like `zsh: command not found: nvm`, it hasn’t been installed correctly. + +3. Install `node` + + Look at the `.nvmrc` file in your workspace to determine which version to install. Then run the following command in your terminal with that version: + + ```bash + nvm install 22.13.1 + nvm use 22.13.1 + ``` + + Quit and re-open the terminal, and then run to confirm that v22 is running + + ```bash + > node -v + v22.13.1 + ``` + + Alternatively, to automatically try to find and use an `.nvmrc` file in your workspace, per the [official nvm docs for zsh](https://github.com/nvm-sh/nvm?tab=readme-ov-file#zsh), add the following script to your shell (typically `~/.zshrc` on mac): + + ```zsh + # place this after nvm initialization! + autoload -U add-zsh-hook + + load-nvmrc() { + local nvmrc_path + nvmrc_path="$(nvm_find_nvmrc)" + + if [ -n "$nvmrc_path" ]; then + local nvmrc_node_version + nvmrc_node_version=$(nvm version "$(cat "${nvmrc_path}")") + + if [ "$nvmrc_node_version" = "N/A" ]; then + nvm install + elif [ "$nvmrc_node_version" != "$(nvm version)" ]; then + nvm use + # Optionally, add `>/dev/null 2>&1` after `nvm use` to suppress output + fi + elif [ -n "$(PWD=$OLDPWD nvm_find_nvmrc)" ] && [ "$(nvm version)" != "$(nvm version default)" ]; then + echo "Reverting to nvm default version" + nvm use default + fi + } + + add-zsh-hook chpwd load-nvmrc + load-nvmrc + ``` + +4. Install `bun`. We use bun as our package manager and to run scripts. + + Look at the `.bun-version` file in your workspace to determine which version to install. Run the following command to install it, being mindful of the version string here (npm comes with node, so it should work if the above step has been completed correctly) + + ```bash + curl -fsSL https://bun.sh/install | bash -s "bun-v1.3.11" + ``` + + Check version to verify installation + + ```bash + > bun -v + 1.3.11 + ``` + +5. Install `ruby` + + Use `rbenv` to install a specific version of `ruby`: + + ```bash + brew install rbenv ruby-build + ``` + + Run init and follow the instructions to complete the installation. + + ```bash + rbenv init + ``` + + After following the instructions, make sure you `source` your `.zshrc` or `.bash_profile`, or start a new terminal session. + + Install a version of `ruby` and set as the default. + + ```bash + rbenv install 3.2.2 + rbenv global 3.2.2 + ``` + +6. Install cocoapods and fastlane using bundler (make sure to run in `mobile`) + + ```bash + bundle install + ``` + + Note: In the case you run into permission issues when installing ruby, [you may need to add some permissions to make it work.](https://stackoverflow.com/a/50181250) + +### iOS Setup + +#### Xcode + +You should start with downloading Xcode if you don't already have it installed, since the file is so large. You can find it here: [developer.apple.com/xcode](https://developer.apple.com/xcode/) + +You must use the [Required Xcode Version](https://github.com/Uniswap/universe/blob/main/.xcode-version) to compile the app. [Older versions of xCode can be found here](https://developer.apple.com/download/all/?q=xcode). + +#### Add Xcode Command Line Tools + +Open Xcode and go to: + +`Preferences → Locations → Command Line Tools` + +Select the version that pops up. + +### Android Setup + +1. Install [Android Studio](https://developer.android.com/studio) +2. Install the JDK. Taken from [RN instructions](https://reactnative.dev/docs/set-up-your-environment?platform=android) + + ```bash + brew install --cask zulu@17 + + # Get path to where cask was installed to double-click installer + brew info --cask zulu@17 + ``` + + Add the following to your .rc file + `export JAVA_HOME=/Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home` + + [Also verify that in Android Studio it is using the correct JDK.](https://developer.android.com/build/jdks#jdk-config-in-studio) + +3. Add the following to your `.rc` file + + ```bash + export ANDROID_HOME=$HOME/Library/Android/sdk + export PATH=$PATH:$ANDROID_HOME/emulator + export PATH=$PATH:$ANDROID_HOME/platform-tools + ``` + +4. Install an emulator. Android Studio should have an emulator already, but if not: + Open the project at `universe/apps/mobile/android` + Tools -> Device Manager to create a new emulator + +#### Deploying to Physical Android Device + +1. Enable developer mode on Android + + - Open Settings + - Tap About phone or About device + - Tap Software information + - Tap Build number seven times in a row + - A message will appear when you're close to enabling Developer mode + - Enable USB Debugging: Go to Developer Options in settings and enable USB Debugging + +2. Connect device and Allow communication + + - Pop up message must appear and enable transfer. + - Run the following command to verify your device has been detected: `adb devices` + +3. In your terminal run + + ```bash + adb reverse tcp:8081 tcp:8081 + bun mobile android + ``` + +If it fails, quit the terminal and run it directly from Android Studio. Once you get the first build running, retry the previous step. + +## Development + +Once all the setup steps above are completed, you're ready to try running the app locally! + +### Environment variables + +Note: The app will likely have limited functionality when running it locally with the default environment variables. + +Use the environment variables defined in the `.env.defaults.local` file to run the app locally. + +You can use the command `bun mobile env:local:download` if you have the 1password CLI to copy that file to your root folder. + +### Compile contract ABI types + +This is done in bootstrap but good to know about. Before the code will compile you need to generate types for the smart contracts the wallet interacts with. Run `bun g:prepare` at the top level. Re-run this if the ABIs are ever changed. + +### Run the app + +In the root directory, run `bun install` to install all the necessary npm packages. + +Then run `bun mobile pod` to install all the necessary pods. (You may need to updated source repos with `pod repo update` if this fails.) + +Finally, run `bun mobile ios` to boot up the iOS Simulator and run the app inside it. The JS bundler (metro) should automatically open in a new terminal window. If it does not, start it manually with `bun start`. + +Or you can use one command to run them all one after the other: `bun install && bun pod && bun ios` + +You can also run the app from Xcode, which is necessary for any Swift related changes. Xcode will automatically start the metro bundler. + +Hopefully you now (after a few minutes) see the Uniswap Wallet running in the iOS Simulator! + +### Using Radon IDE (VSCode/Cursor Extension) + +[Radon IDE](https://marketplace.visualstudio.com/items?itemName=swmansion.react-native-ide&ssr=false#review-details) is a relatively new VSCode extension build by Software Mansion. TLDR; its tagline is + +> A better developer experience for React Native developers + +It's not perfect, but it's great to have in the toolbox. One noteworthy feature is the ability to click on any piece of UI and be able to inspect the component hierarchy + jump straight into the relevant code. There's also support for breakpoints in VSCode/Cursor, better logging, instant replay of your session, and the ability to adjust common device settings on the fly. + +To get started, you should already be able to build the iOS app (either in XCode or via the cli). Install the extension, open it, and follow the onboarding instructions. + +One you have a device configured, it will start to build. If/when successful, you'll see the device simulator/emulator in the sidebar. + +In `.vscode/launch.json`, you will see configurations for each platform. This is where you can specify the fingerprint command. The fingerprint is a hash of the build environment, and Radon uses it to determine if the build has changed so that it knows when to re-run the build process (i.e. only on native code changes). See `getFingerprintForRadonIDE.js` for more details. There are more complex implementations of this, but this is a simple first step. + +#### Running on a Physical iOS Device + +1. Follow all steps listed above. +2. Sign into your `@uniswap.org` Apple ID (`Cmd + ,` -> Accounts tab) + download provisioning profiles +3. Connect your iOS device + follow the on-screen prompts to trust your computer +4. Select the Uniswap target + your connect device, then `Cmd + R` or use the ▶️ button the start the build +5. You may get an error about your device not yet being added to the Uniswap Apple Developer account; if so, click `Register` and restart the build + +## Important Libraries and Tools + +These are some tools you might want to familiarize yourself with to understand the codebase better and how different aspects of it work. + +- [Redux](https://redux.js.org/) and [Redux Toolkit](https://redux-toolkit.js.org/): state management +- [redux-saga](https://redux-saga.js.org/) & [typed-redux-saga](https://github.com/agiledigital/typed-redux-saga): Redux side effect manager -- used for complex/stateful network calls +- [ethers](https://docs.ethers.io/v5/) +- [Tamagui](https://tamagui.dev): UI framework +- [React navigation](https://reactnavigation.org/): routing and navigation with animations and gestures +- [react-i18next](https://react.i18next.com/): i18n + +## 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. + +## Testing & Performance + +- [Build local app files](./docs/build-app-files.md) +- [E2E testing](./docs/e2e-mobile.md) +- [Performance monitoring](./docs/perf-monitoring.md) + + +## Troubleshooting + +### Common issues + +- `zsh: command not found: [package name]` + This means whichever package you're trying to run (`[package name]`) wasn’t correctly installed, or your Terminal can’t figure out how to run it. If you just installed it, try quitting terminal and re-opening it. Otherwise try reinstalling the package. + +- `Failed to load 'glog' podspec:` + Resolve this issue by checking the path of Xcode, make sure is inside Applications and with the name `Xcode` + Once confirm run the following commands: + +`sudo xcode-select --switch /Applications/Xcode.app` +`pod install` + +- `unable to open file (in target "OneSignalNotificationServiceExtension" in project "Uniswap")`. + Resolve this issue by navigating to the `ios/` directory and running `pod update`. + +- `Build target hermes-engine: Command PhaseScriptExecution failed with a nonzero exit code` + Node isn't being located correctly during the build phase. Run `which node` and copy the resulting path into `.xcode.env.local`. More context [here](https://github.com/facebook/react-native/issues/42221). + +- `CocoaPods could not find compatible versions for pod "hermes-engine"` + The following commands can help you fix these types of errors: + +`cd ios && pod install --repo-update` +`cd ios && pod repo update` +`cd ios && pod update hermes-engine --no-repo-update` + +Context: + +### Common fixes + +If something isn’t working the way it should or you’re getting a weird error when trying to run the app, try the following: + +1. Quit the terminal +2. Quit Metro terminal +3. Open Finder and navigate to the `mobile` directory +4. Delete the `node_modules` folder +5. Navigate into the `ios` folder +6. Delete the `Pods` folder +7. Open XCode +8. Go to Product → Clean Build Folder +9. Open your terminal again +10. Navigate to the `mobile` directory in the terminal +11. Run `bun install && bun pod` again +12. Run `bun ios` + +### Shell profile setup + +Your shell profile file is most likely one of: `.bash_profile`, `.zshrc`, or `.zprofile`, and will be located in `/Users/[username]/`. You can reveal hidden files in Finder by pressing `⌘` + `Shift` + `.`. + +If issues with your terminal or shell seem to be the cause of some of your problems, here is an example of what that file may look like in order for your terminal to be able to run the app locally: + +```zsh +eval "$(/opt/homebrew/bin/brew shellenv)" + +export NVM_DIR="$HOME/.nvm" +[ -s "/opt/homebrew/opt/nvm/nvm.sh" ] && \. "/opt/homebrew/opt/nvm/nvm.sh" # This loads nvm +[ -s "/opt/homebrew/opt/nvm/etc/bash_completion.d/nvm" ] && \. "/opt/homebrew/opt/nvm/etc/bash_completion.d/nvm" # This loads nvm bash_completion + diff --git a/apps/mobile/ReactotronConfig.ts b/apps/mobile/ReactotronConfig.ts new file mode 100644 index 00000000..2b7b1b0b --- /dev/null +++ b/apps/mobile/ReactotronConfig.ts @@ -0,0 +1,23 @@ +import AsyncStorage from '@react-native-async-storage/async-storage' +import { MMKV } from 'react-native-mmkv' +import type { ReactotronReactNative } from 'reactotron-react-native' +import Reactotron, { openInEditor } from 'reactotron-react-native' +import mmkvPlugin from 'reactotron-react-native-mmkv' +import { reactotronRedux } from 'reactotron-redux' + +const storage = new MMKV() + +const reactotron = Reactotron.setAsyncStorageHandler(AsyncStorage) + .configure({ + name: 'Uniswap Wallet', + onConnect: () => { + Reactotron.clear() + }, + }) + .use(mmkvPlugin({ storage, ignore: ['react-query-cache', 'apollo-cache-persist'] })) + .use(reactotronRedux()) + .use(openInEditor()) + .useReactNative() + .connect() + +export default reactotron diff --git a/apps/mobile/__mocks__/@react-native-firebase/app.ts b/apps/mobile/__mocks__/@react-native-firebase/app.ts new file mode 100644 index 00000000..d3a5a062 --- /dev/null +++ b/apps/mobile/__mocks__/@react-native-firebase/app.ts @@ -0,0 +1,9 @@ +/* oxlint-disable typescript/explicit-function-return-type */ + +export default { + app: () => ({ + auth: () => ({ + signInAnonymously: () => undefined, + }), + }), +} diff --git a/apps/mobile/__mocks__/@react-native-firebase/firestore.ts b/apps/mobile/__mocks__/@react-native-firebase/firestore.ts new file mode 100644 index 00000000..b1c6ea43 --- /dev/null +++ b/apps/mobile/__mocks__/@react-native-firebase/firestore.ts @@ -0,0 +1 @@ +export default {} diff --git a/apps/mobile/__mocks__/@react-native-firebase/remote-config.ts b/apps/mobile/__mocks__/@react-native-firebase/remote-config.ts new file mode 100644 index 00000000..b1c6ea43 --- /dev/null +++ b/apps/mobile/__mocks__/@react-native-firebase/remote-config.ts @@ -0,0 +1 @@ +export default {} diff --git a/apps/mobile/__mocks__/@react-native-masked-view/masked-view.ts b/apps/mobile/__mocks__/@react-native-masked-view/masked-view.ts new file mode 100644 index 00000000..66e67ac3 --- /dev/null +++ b/apps/mobile/__mocks__/@react-native-masked-view/masked-view.ts @@ -0,0 +1,13 @@ +import React, { PropsWithChildren, ReactNode } from 'react' +import { View, ViewProps } from 'react-native' + +// react-native-masked-view for Storybook web +// https://github.com/react-native-masked-view/masked-view/issues/70#issuecomment-1171801526 +function MaskedViewWeb({ + maskElement, + ...props +}: PropsWithChildren<{ maskElement: ReactNode }>): React.CElement { + return React.createElement(View, props, maskElement) +} + +export default MaskedViewWeb diff --git a/apps/mobile/__mocks__/@react-navigation/native.js b/apps/mobile/__mocks__/@react-navigation/native.js new file mode 100644 index 00000000..ce368193 --- /dev/null +++ b/apps/mobile/__mocks__/@react-navigation/native.js @@ -0,0 +1,30 @@ +// Copied from: +// https://gist.github.com/phcbarros/bd90825863c3573cc0a28e90db17d1a4 +const RNN = require('@react-navigation/native') +let listeners = {} +const setOptions = jest.fn() +const navigate = jest.fn() + +const navigation = { + setOptions, + navigate, + addListener: jest.fn((name, l) => (listeners[name] = l)), + getListener: (name) => listeners[name], + triggerListener: (name, ...params) => listeners[name](...params), + resetListeners: () => { + listeners = {} + }, +} + +const useNavigation = () => navigation +let params = {} +const useRoute = () => ({ + params, +}) + +module.exports = { + ...RNN, + useNavigation, + useRoute, + setParams: (p) => (params = { ...params, ...p }), +} diff --git a/apps/mobile/__mocks__/@shopify/react-native-skia.ts b/apps/mobile/__mocks__/@shopify/react-native-skia.ts new file mode 100644 index 00000000..766d3d19 --- /dev/null +++ b/apps/mobile/__mocks__/@shopify/react-native-skia.ts @@ -0,0 +1,19 @@ +import React, { PropsWithChildren } from 'react' +import { View, ViewProps } from 'react-native' + +// Source: https://github.com/Shopify/react-native-skia/issues/548#issuecomment-1157609472 + +const PlainView = ({ children, ...props }: PropsWithChildren): React.CElement => { + return React.createElement(View, props, children) +} +const noop = (): null => null + +export const BlurMask = PlainView +export const Canvas = PlainView +export const Circle = PlainView +export const Group = PlainView +export const LinearGradient = PlainView +export const Mask = PlainView +export const Path = PlainView +export const Rect = PlainView +export const vec = noop diff --git a/apps/mobile/__mocks__/react-native-context-menu-view.ts b/apps/mobile/__mocks__/react-native-context-menu-view.ts new file mode 100644 index 00000000..7987c3a4 --- /dev/null +++ b/apps/mobile/__mocks__/react-native-context-menu-view.ts @@ -0,0 +1,8 @@ +import React, { PropsWithChildren } from 'react' +import { View, ViewProps } from 'react-native' + +const PlainView = ({ children, ...props }: PropsWithChildren): React.CElement => { + return React.createElement(View, props, children) +} + +export default PlainView diff --git a/apps/mobile/__mocks__/react-native-fast-image.ts b/apps/mobile/__mocks__/react-native-fast-image.ts new file mode 100644 index 00000000..d89ee717 --- /dev/null +++ b/apps/mobile/__mocks__/react-native-fast-image.ts @@ -0,0 +1,10 @@ +import React, { PropsWithChildren } from 'react' +import { Image, ImageProps } from 'react-native' + +const PlainImage = ({ children, ...props }: PropsWithChildren): React.CElement => { + return React.createElement(Image, props, children) +} + +PlainImage.resizeMode = {} + +export default PlainImage diff --git a/apps/mobile/__mocks__/react-native-permissions.ts b/apps/mobile/__mocks__/react-native-permissions.ts new file mode 100644 index 00000000..b1c6ea43 --- /dev/null +++ b/apps/mobile/__mocks__/react-native-permissions.ts @@ -0,0 +1 @@ +export default {} diff --git a/apps/mobile/android/.env.template b/apps/mobile/android/.env.template new file mode 100644 index 00000000..3bd6ee6a --- /dev/null +++ b/apps/mobile/android/.env.template @@ -0,0 +1,4 @@ +KEYSTORE_FILE=op://Android/keystore-properties-$APP_ENV/storeFile +STORE_PASSWORD=op://Android/keystore-properties-$APP_ENV/storePassword +KEYSTORE_ALIAS=op://Android/keystore-properties-$APP_ENV/keyAlias +KEY_PASSWORD=op://Android/keystore-properties-$APP_ENV/keyPassword \ No newline at end of file diff --git a/apps/mobile/android/app/build.gradle b/apps/mobile/android/app/build.gradle new file mode 100644 index 00000000..f62cb496 --- /dev/null +++ b/apps/mobile/android/app/build.gradle @@ -0,0 +1,293 @@ +apply plugin: "com.android.application" +apply plugin: "org.jetbrains.kotlin.android" +apply plugin: "com.google.gms.google-services" +apply plugin: "maven-publish" +apply plugin: "kotlin-android" +apply plugin: "org.jetbrains.kotlin.plugin.compose" +apply plugin: "com.facebook.react" + +def projectRoot = rootDir.getAbsoluteFile().getParentFile().getAbsolutePath() + +def nodeModulesPath = "../../../../node_modules" + +react { + // From expo docs: https://docs.expo.dev/brownfield/get-started + entryFile = file(["node", "-e", "require('expo/scripts/resolveAppEntry')", rootDir.getAbsoluteFile().getParentFile().getAbsolutePath(), "android", "absolute"].execute(null, rootDir).text.trim()) + + reactNativeDir = file("$nodeModulesPath/react-native") + + hermesCommand = new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim()).getParentFile().getAbsolutePath() + "/sdks/hermesc/%OS-BIN%/hermesc" + + codegenDir = new File(["node", "--print", "require.resolve('@react-native/codegen/package.json', { paths: [require.resolve('react-native/package.json')] })"].execute(null, rootDir).text.trim()).getParentFile().getAbsoluteFile() + + enableBundleCompression = (findProperty('android.enableBundleCompression') ?: false).toBoolean() + + cliFile = new File(["node", "--print", "require.resolve('@expo/cli')"].execute(null, rootDir).text.trim()) + bundleCommand = "export:embed" + + debuggableVariants = ["devDebug", "betaDebug", "prodDebug"] + + /* Autolinking */ + autolinkLibrariesWithApp() +} + +/** + * Set this to true to Run Proguard on Release builds to minify the Java bytecode. + */ +def enableProguardInReleaseBuilds = false + +/** + * The preferred build flavor of JavaScriptCore (JSC) + * + * For example, to use the international variant, you can use: + * `def jscFlavor = io.github.react-native-community:jsc-android-intl:2026004.+` + * + * The international variant includes ICU i18n library and necessary data + * allowing to use e.g. `Date.toLocaleString` and `String.localeCompare` that + * give correct results when using with locales other than en-US. Note that + * this variant is about 6MiB larger per architecture than default. + */ +def jscFlavor = 'io.github.react-native-community:jsc-android:2026004.+' + +/** + * Private function to get the list of Native Architectures you want to build. + * This reads the value from reactNativeArchitectures in your gradle.properties + * file and works together with the --active-arch-only flag of react-native run-android. + */ +def reactNativeArchitectures() { + def value = project.getProperties().get("reactNativeArchitectures") + return value ? value.split(",") : ["armeabi-v7a", "x86", "x86_64", "arm64-v8a"] +} + +boolean isCI = System.getenv('CI') != null + +boolean datadogPropertiesAvailable = System.getenv('DATADOG_API_KEY') != null + +if (isCI && datadogPropertiesAvailable) { + apply from: "../../../../node_modules/@datadog/mobile-react-native/datadog-sourcemaps.gradle" +} + +<<<<<<< HEAD +def devVersionName = "1.69" +def betaVersionName = "1.69" +def prodVersionName = "1.69" +======= +def devVersionName = "1.70" +def betaVersionName = "1.70" +def prodVersionName = "1.70" +>>>>>>> upstream/main + +android { + ndkVersion rootProject.ext.ndkVersion + +<<<<<<< HEAD + namespace "com.lux.exchange" + defaultConfig { + applicationId "com.lux.exchange.mobile" +======= + namespace "com.uniswap" + defaultConfig { + applicationId "com.uniswap.mobile" +>>>>>>> upstream/main + compileSdk rootProject.ext.compileSdkVersion + minSdkVersion rootProject.ext.minSdkVersion + targetSdkVersion rootProject.ext.targetSdkVersion + versionCode 1 + versionName "1.0" + } + splits { + abi { + reset() + enable false + universalApk false // If true, also generate a universal APK + include (*reactNativeArchitectures()) + } + } + lintOptions { + abortOnError false + } + signingConfigs { + debug { + storeFile file('debug.keystore') + storePassword 'android' + keyAlias 'androiddebugkey' + keyPassword 'android' + } + release { + def useDebugKeystore = System.getenv("ANDROID_USE_DEBUG_KEYSTORE") == "true" + if (useDebugKeystore) { + storeFile file('debug.keystore') + storePassword 'android' + keyAlias 'androiddebugkey' + keyPassword 'android' + } else { + storeFile file(System.getenv("ANDROID_KEYSTORE_FILE") ?: 'keystore.jks') + storePassword System.getenv("ANDROID_STORE_PASSWORD") ?: "" + keyAlias System.getenv("ANDROID_KEYSTORE_ALIAS") ?: "" + keyPassword System.getenv("ANDROID_KEY_PASSWORD") ?: "" + } + } + } + + flavorDimensions += "variant" + + productFlavors { + dev { + isDefault(true) +<<<<<<< HEAD + applicationId "com.lux.exchange.mobile.dev" +======= + applicationId "com.uniswap.mobile.dev" +>>>>>>> upstream/main + versionName devVersionName + dimension "variant" + } + beta { +<<<<<<< HEAD + applicationId "com.lux.exchange.mobile.beta" +======= + applicationId "com.uniswap.mobile.beta" +>>>>>>> upstream/main + versionName betaVersionName + dimension "variant" + } + prod { + dimension "variant" + versionName prodVersionName + } + } + + buildTypes { + debug { + signingConfig signingConfigs.debug + } + release { + signingConfig signingConfigs.release + minifyEnabled enableProguardInReleaseBuilds + proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro" + } + } + + // applicationVariants are e.g. debug, release + applicationVariants.configureEach { variant -> + // Prevent using debug keystore for production builds + if (variant.flavorName == "prod" && variant.buildType.name == "release") { + def useDebugKeystore = System.getenv("ANDROID_USE_DEBUG_KEYSTORE") == "true" + if (useDebugKeystore) { + def blockTask = tasks.register("blockDebugKeystoreFor${variant.name.capitalize()}") { + doLast { + throw new GradleException( + "ANDROID_USE_DEBUG_KEYSTORE cannot be used for production builds.\n" + + "This prevents accidentally publishing an improperly signed APK." + ) + } + } + variant.assembleProvider.configure { dependsOn blockTask } + } + } + + variant.outputs.each { output -> + // For each separate APK per architecture, set a unique version code as described here: + // https://developer.android.com/studio/build/configure-apk-splits.html + // Example: versionCode 1 will generate 1001 for armeabi-v7a, 1002 for x86, etc. + def versionCodes = ["armeabi-v7a": 1, "x86": 2, "arm64-v8a": 3, "x86_64": 4] + def abi = output.getFilter(com.android.build.VariantOutput.FilterType.ABI) + if (abi != null) { // null for the universal-debug, universal-release variants + output.versionCodeOverride = + defaultConfig.versionCode * 1000 + versionCodes.get(abi) + } + } + } + + packagingOptions { + resources.excludes.add("META-INF/*") + pickFirst 'lib/x86/libc++_shared.so' + pickFirst 'lib/x86_64/libc++_shared.so' + pickFirst 'lib/armeabi-v7a/libc++_shared.so' + pickFirst 'lib/arm64-v8a/libc++_shared.so' + } + + androidResources { + ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:!CVS:!thumbs.db:!picasa.ini:!*~' + } + + buildFeatures { + compose true + } + + composeOptions { + kotlinCompilerExtensionVersion = "1.5.15" + } + + sourceSets { + main { + jniLibs { +<<<<<<< HEAD + srcDir '../../../../node_modules/@luxamm/ethers-rs-mobile/android/jniLibs' +======= + srcDir '../../../../node_modules/@uniswap/ethers-rs-mobile/android/jniLibs' +>>>>>>> upstream/main + } + } + } +} + +dependencies { + // The version of react-native is set by the React Native Gradle Plugin + implementation "com.facebook.react:react-android" + implementation("com.facebook.react:hermes-android") + implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.0.0" + + implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion" + implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:$kotlinSerialization" + implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle" + + implementation 'com.google.android.play:integrity:1.2.0' + + // Firebase App Check: Import the BoM for the Firebase platform + implementation(platform("com.google.firebase:firebase-bom:32.7.2")) + implementation("com.google.firebase:firebase-appcheck-playintegrity") + + // Guava + implementation "com.google.guava:guava:24.1-jre" + // Guava fix + implementation "com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava" + + //TODO: Revisit dependencies during security audit + //Drive + implementation('com.google.api-client:google-api-client-android:2.1.0') { + exclude group: 'org.apache.httpcomponents' + exclude module: 'guava-jdk5' + } + implementation('com.google.apis:google-api-services-drive:v3-rev20221023-2.0.0') { + exclude group: 'org.apache.httpcomponents' + exclude module: 'guava-jdk5' + } + implementation 'com.google.android.gms:play-services-auth:20.4.0' + + implementation 'com.google.api-client:google-api-client-jackson2:1.31.1' + implementation 'com.google.auth:google-auth-library-oauth2-http:1.11.0' + + implementation "androidx.compose.foundation:foundation:$compose" + implementation "androidx.compose.material:material:$compose" + implementation "androidx.compose.ui:ui:$compose" + debugImplementation("androidx.compose.ui:ui-tooling:$compose") + implementation("androidx.compose.ui:ui-tooling-preview:$compose") + + implementation "androidx.security:security-crypto:1.0.0" + implementation 'com.lambdapioneer.argon2kt:argon2kt:1.3.0' + + implementation "com.google.accompanist:accompanist-flowlayout:$flowlayout" + + // Used for device-reported performance class. + implementation("androidx.core:core-performance:$corePerf") + implementation("androidx.core:core-performance-play-services:$corePerf") + + implementation 'com.github.statsig-io:android-sdk:4.36.0' + + // For animated GIF support + implementation 'com.facebook.fresco:animated-gif:3.6.0' + implementation 'androidx.compose.ui:ui-tooling-preview-android:1.8.1' + + implementation project(':react-native-video') +} diff --git a/apps/mobile/android/app/debug.keystore b/apps/mobile/android/app/debug.keystore new file mode 100644 index 00000000..364e105e Binary files /dev/null and b/apps/mobile/android/app/debug.keystore differ diff --git a/apps/mobile/android/app/proguard-rules.pro b/apps/mobile/android/app/proguard-rules.pro new file mode 100644 index 00000000..11b02572 --- /dev/null +++ b/apps/mobile/android/app/proguard-rules.pro @@ -0,0 +1,10 @@ +# Add project specific ProGuard rules here. +# By default, the flags in this file are appended to flags specified +# in /usr/local/Cellar/android-sdk/24.3.3/tools/proguard/proguard-android.txt +# You can edit the include path and order by changing the proguardFiles +# directive in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# Add any project specific keep options here: diff --git a/apps/mobile/android/app/src/beta/AndroidManifest.xml b/apps/mobile/android/app/src/beta/AndroidManifest.xml new file mode 100644 index 00000000..ea7f0ab5 --- /dev/null +++ b/apps/mobile/android/app/src/beta/AndroidManifest.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + +<<<<<<< HEAD + + + + +======= + + + + +>>>>>>> upstream/main + + + + + diff --git a/apps/mobile/android/app/src/beta/res/mipmap-anydpi-v26/ic_launcher.xml b/apps/mobile/android/app/src/beta/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 00000000..ec59d6ec --- /dev/null +++ b/apps/mobile/android/app/src/beta/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/mobile/android/app/src/beta/res/mipmap-hdpi/ic_launcher.png b/apps/mobile/android/app/src/beta/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 00000000..fa62883a Binary files /dev/null and b/apps/mobile/android/app/src/beta/res/mipmap-hdpi/ic_launcher.png differ diff --git a/apps/mobile/android/app/src/beta/res/mipmap-hdpi/ic_launcher_background.png b/apps/mobile/android/app/src/beta/res/mipmap-hdpi/ic_launcher_background.png new file mode 100644 index 00000000..19669488 Binary files /dev/null and b/apps/mobile/android/app/src/beta/res/mipmap-hdpi/ic_launcher_background.png differ diff --git a/apps/mobile/android/app/src/beta/res/mipmap-hdpi/ic_launcher_foreground.png b/apps/mobile/android/app/src/beta/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..fa62883a Binary files /dev/null and b/apps/mobile/android/app/src/beta/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/apps/mobile/android/app/src/beta/res/mipmap-hdpi/ic_launcher_round.png b/apps/mobile/android/app/src/beta/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 00000000..42b71231 Binary files /dev/null and b/apps/mobile/android/app/src/beta/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/apps/mobile/android/app/src/beta/res/mipmap-mdpi/ic_launcher.png b/apps/mobile/android/app/src/beta/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 00000000..b02e6613 Binary files /dev/null and b/apps/mobile/android/app/src/beta/res/mipmap-mdpi/ic_launcher.png differ diff --git a/apps/mobile/android/app/src/beta/res/mipmap-mdpi/ic_launcher_background.png b/apps/mobile/android/app/src/beta/res/mipmap-mdpi/ic_launcher_background.png new file mode 100644 index 00000000..75025cfd Binary files /dev/null and b/apps/mobile/android/app/src/beta/res/mipmap-mdpi/ic_launcher_background.png differ diff --git a/apps/mobile/android/app/src/beta/res/mipmap-mdpi/ic_launcher_foreground.png b/apps/mobile/android/app/src/beta/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..b02e6613 Binary files /dev/null and b/apps/mobile/android/app/src/beta/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/apps/mobile/android/app/src/beta/res/mipmap-mdpi/ic_launcher_round.png b/apps/mobile/android/app/src/beta/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 00000000..2fb8e793 Binary files /dev/null and b/apps/mobile/android/app/src/beta/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/apps/mobile/android/app/src/beta/res/mipmap-xhdpi/ic_launcher.png b/apps/mobile/android/app/src/beta/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 00000000..14c36620 Binary files /dev/null and b/apps/mobile/android/app/src/beta/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/apps/mobile/android/app/src/beta/res/mipmap-xhdpi/ic_launcher_background.png b/apps/mobile/android/app/src/beta/res/mipmap-xhdpi/ic_launcher_background.png new file mode 100644 index 00000000..9784f16c Binary files /dev/null and b/apps/mobile/android/app/src/beta/res/mipmap-xhdpi/ic_launcher_background.png differ diff --git a/apps/mobile/android/app/src/beta/res/mipmap-xhdpi/ic_launcher_foreground.png b/apps/mobile/android/app/src/beta/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..14c36620 Binary files /dev/null and b/apps/mobile/android/app/src/beta/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/apps/mobile/android/app/src/beta/res/mipmap-xhdpi/ic_launcher_round.png b/apps/mobile/android/app/src/beta/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 00000000..0660b135 Binary files /dev/null and b/apps/mobile/android/app/src/beta/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/apps/mobile/android/app/src/beta/res/mipmap-xxhdpi/ic_launcher.png b/apps/mobile/android/app/src/beta/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 00000000..b1702563 Binary files /dev/null and b/apps/mobile/android/app/src/beta/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/apps/mobile/android/app/src/beta/res/mipmap-xxhdpi/ic_launcher_background.png b/apps/mobile/android/app/src/beta/res/mipmap-xxhdpi/ic_launcher_background.png new file mode 100644 index 00000000..04ef206c Binary files /dev/null and b/apps/mobile/android/app/src/beta/res/mipmap-xxhdpi/ic_launcher_background.png differ diff --git a/apps/mobile/android/app/src/beta/res/mipmap-xxhdpi/ic_launcher_foreground.png b/apps/mobile/android/app/src/beta/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..b1702563 Binary files /dev/null and b/apps/mobile/android/app/src/beta/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/apps/mobile/android/app/src/beta/res/mipmap-xxhdpi/ic_launcher_round.png b/apps/mobile/android/app/src/beta/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 00000000..6a71cfa5 Binary files /dev/null and b/apps/mobile/android/app/src/beta/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/apps/mobile/android/app/src/beta/res/mipmap-xxxhdpi/ic_launcher.png b/apps/mobile/android/app/src/beta/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 00000000..65a7df30 Binary files /dev/null and b/apps/mobile/android/app/src/beta/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/apps/mobile/android/app/src/beta/res/mipmap-xxxhdpi/ic_launcher_background.png b/apps/mobile/android/app/src/beta/res/mipmap-xxxhdpi/ic_launcher_background.png new file mode 100644 index 00000000..66a5487a Binary files /dev/null and b/apps/mobile/android/app/src/beta/res/mipmap-xxxhdpi/ic_launcher_background.png differ diff --git a/apps/mobile/android/app/src/beta/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/apps/mobile/android/app/src/beta/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..65a7df30 Binary files /dev/null and b/apps/mobile/android/app/src/beta/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/apps/mobile/android/app/src/beta/res/mipmap-xxxhdpi/ic_launcher_round.png b/apps/mobile/android/app/src/beta/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 00000000..8bf7f8d1 Binary files /dev/null and b/apps/mobile/android/app/src/beta/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/apps/mobile/android/app/src/beta/res/values/strings.xml b/apps/mobile/android/app/src/beta/res/values/strings.xml new file mode 100644 index 00000000..63c66d69 --- /dev/null +++ b/apps/mobile/android/app/src/beta/res/values/strings.xml @@ -0,0 +1,8 @@ + + +<<<<<<< HEAD + Lux Beta +======= + Uniswap Beta +>>>>>>> upstream/main + diff --git a/apps/mobile/android/app/src/debug/AndroidManifest.xml b/apps/mobile/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 00000000..fa3e4f9a --- /dev/null +++ b/apps/mobile/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/apps/mobile/android/app/src/dev/AndroidManifest.xml b/apps/mobile/android/app/src/dev/AndroidManifest.xml new file mode 100644 index 00000000..8ff176bb --- /dev/null +++ b/apps/mobile/android/app/src/dev/AndroidManifest.xml @@ -0,0 +1,10 @@ + + + + + + diff --git a/apps/mobile/android/app/src/dev/res/mipmap-anydpi-v26/ic_launcher.xml b/apps/mobile/android/app/src/dev/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 00000000..ec59d6ec --- /dev/null +++ b/apps/mobile/android/app/src/dev/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/mobile/android/app/src/dev/res/mipmap-hdpi/ic_launcher.png b/apps/mobile/android/app/src/dev/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 00000000..fa99c2ed Binary files /dev/null and b/apps/mobile/android/app/src/dev/res/mipmap-hdpi/ic_launcher.png differ diff --git a/apps/mobile/android/app/src/dev/res/mipmap-hdpi/ic_launcher_background.png b/apps/mobile/android/app/src/dev/res/mipmap-hdpi/ic_launcher_background.png new file mode 100644 index 00000000..5def7fe6 Binary files /dev/null and b/apps/mobile/android/app/src/dev/res/mipmap-hdpi/ic_launcher_background.png differ diff --git a/apps/mobile/android/app/src/dev/res/mipmap-hdpi/ic_launcher_foreground.png b/apps/mobile/android/app/src/dev/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..fa99c2ed Binary files /dev/null and b/apps/mobile/android/app/src/dev/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/apps/mobile/android/app/src/dev/res/mipmap-hdpi/ic_launcher_round.png b/apps/mobile/android/app/src/dev/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 00000000..1a0de427 Binary files /dev/null and b/apps/mobile/android/app/src/dev/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/apps/mobile/android/app/src/dev/res/mipmap-mdpi/ic_launcher.png b/apps/mobile/android/app/src/dev/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 00000000..63046dff Binary files /dev/null and b/apps/mobile/android/app/src/dev/res/mipmap-mdpi/ic_launcher.png differ diff --git a/apps/mobile/android/app/src/dev/res/mipmap-mdpi/ic_launcher_background.png b/apps/mobile/android/app/src/dev/res/mipmap-mdpi/ic_launcher_background.png new file mode 100644 index 00000000..b2b5d150 Binary files /dev/null and b/apps/mobile/android/app/src/dev/res/mipmap-mdpi/ic_launcher_background.png differ diff --git a/apps/mobile/android/app/src/dev/res/mipmap-mdpi/ic_launcher_foreground.png b/apps/mobile/android/app/src/dev/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..63046dff Binary files /dev/null and b/apps/mobile/android/app/src/dev/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/apps/mobile/android/app/src/dev/res/mipmap-mdpi/ic_launcher_round.png b/apps/mobile/android/app/src/dev/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 00000000..67713a16 Binary files /dev/null and b/apps/mobile/android/app/src/dev/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/apps/mobile/android/app/src/dev/res/mipmap-xhdpi/ic_launcher.png b/apps/mobile/android/app/src/dev/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 00000000..1744144b Binary files /dev/null and b/apps/mobile/android/app/src/dev/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/apps/mobile/android/app/src/dev/res/mipmap-xhdpi/ic_launcher_background.png b/apps/mobile/android/app/src/dev/res/mipmap-xhdpi/ic_launcher_background.png new file mode 100644 index 00000000..43dca89d Binary files /dev/null and b/apps/mobile/android/app/src/dev/res/mipmap-xhdpi/ic_launcher_background.png differ diff --git a/apps/mobile/android/app/src/dev/res/mipmap-xhdpi/ic_launcher_foreground.png b/apps/mobile/android/app/src/dev/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..1744144b Binary files /dev/null and b/apps/mobile/android/app/src/dev/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/apps/mobile/android/app/src/dev/res/mipmap-xhdpi/ic_launcher_round.png b/apps/mobile/android/app/src/dev/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 00000000..c225832f Binary files /dev/null and b/apps/mobile/android/app/src/dev/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/apps/mobile/android/app/src/dev/res/mipmap-xxhdpi/ic_launcher.png b/apps/mobile/android/app/src/dev/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 00000000..2d45be78 Binary files /dev/null and b/apps/mobile/android/app/src/dev/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/apps/mobile/android/app/src/dev/res/mipmap-xxhdpi/ic_launcher_background.png b/apps/mobile/android/app/src/dev/res/mipmap-xxhdpi/ic_launcher_background.png new file mode 100644 index 00000000..e843b878 Binary files /dev/null and b/apps/mobile/android/app/src/dev/res/mipmap-xxhdpi/ic_launcher_background.png differ diff --git a/apps/mobile/android/app/src/dev/res/mipmap-xxhdpi/ic_launcher_foreground.png b/apps/mobile/android/app/src/dev/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..2d45be78 Binary files /dev/null and b/apps/mobile/android/app/src/dev/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/apps/mobile/android/app/src/dev/res/mipmap-xxhdpi/ic_launcher_round.png b/apps/mobile/android/app/src/dev/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 00000000..990b30f5 Binary files /dev/null and b/apps/mobile/android/app/src/dev/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/apps/mobile/android/app/src/dev/res/mipmap-xxxhdpi/ic_launcher.png b/apps/mobile/android/app/src/dev/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 00000000..c37dc961 Binary files /dev/null and b/apps/mobile/android/app/src/dev/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/apps/mobile/android/app/src/dev/res/mipmap-xxxhdpi/ic_launcher_background.png b/apps/mobile/android/app/src/dev/res/mipmap-xxxhdpi/ic_launcher_background.png new file mode 100644 index 00000000..c44b3836 Binary files /dev/null and b/apps/mobile/android/app/src/dev/res/mipmap-xxxhdpi/ic_launcher_background.png differ diff --git a/apps/mobile/android/app/src/dev/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/apps/mobile/android/app/src/dev/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..c37dc961 Binary files /dev/null and b/apps/mobile/android/app/src/dev/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/apps/mobile/android/app/src/dev/res/mipmap-xxxhdpi/ic_launcher_round.png b/apps/mobile/android/app/src/dev/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 00000000..2b8eeaaf Binary files /dev/null and b/apps/mobile/android/app/src/dev/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/apps/mobile/android/app/src/dev/res/values/strings.xml b/apps/mobile/android/app/src/dev/res/values/strings.xml new file mode 100644 index 00000000..a6b1e28e --- /dev/null +++ b/apps/mobile/android/app/src/dev/res/values/strings.xml @@ -0,0 +1,8 @@ + + +<<<<<<< HEAD + Lux Dev +======= + Uniswap Dev +>>>>>>> upstream/main + diff --git a/apps/mobile/android/app/src/main/AndroidManifest.xml b/apps/mobile/android/app/src/main/AndroidManifest.xml new file mode 100644 index 00000000..7c5cf9b9 --- /dev/null +++ b/apps/mobile/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,184 @@ + +======= + package="com.uniswap"> +>>>>>>> upstream/main + + + + + + + + + + + + + + + +======= + android:value="com.uniswap.notifications.SilentPushNotificationServiceExtension" /> +>>>>>>> upstream/main + + + + + + + + + + + + + + + + + + +<<<<<<< HEAD + +======= + +>>>>>>> upstream/main + + + + + + + + + + + + + + + + >>>>>> upstream/main + android:pathPrefix="/nfts/asset/" /> + + >>>>>> upstream/main + android:pathPrefix="/nfts/collection/" /> + + >>>>>> upstream/main + android:pathPrefix="/tokens" /> + + >>>>>> upstream/main + android:pathPrefix="/portfolio/" /> + + >>>>>> upstream/main + android:pathPrefix="/explore/tokens" /> + + >>>>>> upstream/main + android:pathPrefix="/swap" /> + + >>>>>> upstream/main + android:pathPrefix="/buy" /> + + + + + + + + >>>>>> upstream/main + android:pathPrefix="/app" /> + + >>>>>> upstream/main + android:pathPrefix="/app/wc" /> + + + + diff --git a/apps/mobile/android/app/src/main/assets/OnboardingDark.riv b/apps/mobile/android/app/src/main/assets/OnboardingDark.riv new file mode 100644 index 00000000..64d5092d Binary files /dev/null and b/apps/mobile/android/app/src/main/assets/OnboardingDark.riv differ diff --git a/apps/mobile/android/app/src/main/assets/OnboardingLight.riv b/apps/mobile/android/app/src/main/assets/OnboardingLight.riv new file mode 100644 index 00000000..5a6da1da Binary files /dev/null and b/apps/mobile/android/app/src/main/assets/OnboardingLight.riv differ diff --git a/apps/mobile/android/app/src/main/assets/fonts/Basel-Grotesk-Book.otf b/apps/mobile/android/app/src/main/assets/fonts/Basel-Grotesk-Book.otf new file mode 100644 index 00000000..77dc33d7 Binary files /dev/null and b/apps/mobile/android/app/src/main/assets/fonts/Basel-Grotesk-Book.otf differ diff --git a/apps/mobile/android/app/src/main/assets/fonts/Basel-Grotesk-Medium.otf b/apps/mobile/android/app/src/main/assets/fonts/Basel-Grotesk-Medium.otf new file mode 100644 index 00000000..344235fe Binary files /dev/null and b/apps/mobile/android/app/src/main/assets/fonts/Basel-Grotesk-Medium.otf differ diff --git a/apps/mobile/android/app/src/main/assets/fonts/InputMono-Regular.ttf b/apps/mobile/android/app/src/main/assets/fonts/InputMono-Regular.ttf new file mode 100644 index 00000000..1da56040 Binary files /dev/null and b/apps/mobile/android/app/src/main/assets/fonts/InputMono-Regular.ttf differ diff --git a/apps/mobile/android/app/src/main/assets/fonts/monospace.ttf b/apps/mobile/android/app/src/main/assets/fonts/monospace.ttf new file mode 100644 index 00000000..9fcf9b45 Binary files /dev/null and b/apps/mobile/android/app/src/main/assets/fonts/monospace.ttf differ diff --git a/apps/mobile/android/app/src/main/ic_launcher-playstore.png b/apps/mobile/android/app/src/main/ic_launcher-playstore.png new file mode 100644 index 00000000..f48a4097 Binary files /dev/null and b/apps/mobile/android/app/src/main/ic_launcher-playstore.png differ diff --git a/apps/mobile/android/app/src/main/java/com/lux/AndroidDeviceModule.kt b/apps/mobile/android/app/src/main/java/com/lux/AndroidDeviceModule.kt new file mode 100644 index 00000000..08703a36 --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/lux/AndroidDeviceModule.kt @@ -0,0 +1,24 @@ +package com.lux + +import androidx.core.performance.play.services.PlayServicesDevicePerformance +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactContextBaseJavaModule +import com.facebook.react.bridge.ReactMethod + + +/** + * React module to provide device level information particular to Android + */ +class AndroidDeviceModule(reactContext: ReactApplicationContext): ReactContextBaseJavaModule(reactContext) { + override fun getName() = REACT_CLASS + + @ReactMethod + fun getPerformanceClass(promise: Promise) { + val devicePerformance = PlayServicesDevicePerformance(reactApplicationContext) + promise.resolve(devicePerformance.mediaPerformanceClass) + } + companion object { + private const val REACT_CLASS = "AndroidDeviceModule" + } +} diff --git a/apps/mobile/android/app/src/main/java/com/lux/EmbeddedWalletModule.kt b/apps/mobile/android/app/src/main/java/com/lux/EmbeddedWalletModule.kt new file mode 100644 index 00000000..d8fb7ae9 --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/lux/EmbeddedWalletModule.kt @@ -0,0 +1,58 @@ +package com.lux + +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactContextBaseJavaModule +import com.facebook.react.bridge.ReactMethod +import decryptMnemonic +import generateRsaKeyPair +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import java.security.KeyPair + +class EmbeddedWalletModule(reactContext: ReactApplicationContext) : + ReactContextBaseJavaModule(reactContext) { + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + private val publicKeyPairMap = mutableMapOf() + + override fun getName() = "EmbeddedWallet" + + @ReactMethod + fun decryptMnemonicForPublicKey(encryptedMnemonic: String, publicKeyBase64: String, promise: Promise) { + val keyPair = publicKeyPairMap[publicKeyBase64] ?: run { + promise.reject( + "KEY_PAIR_NOT_FOUND", + "Key pair not found for public key $publicKeyBase64", + IllegalStateException("Key pair not found for public key $publicKeyBase64") + ) + return + } + + scope.launch { + try { + val decryptedMnemonic = decryptMnemonic(encryptedMnemonic, keyPair) + publicKeyPairMap.remove(publicKeyBase64) + promise.resolve(decryptedMnemonic) + } catch (e: Exception) { + promise.reject("DECRYPT_ERROR", "Failed to decrypt mnemonic", e) + } + } + } + + @ReactMethod + fun generateKeyPair(promise: Promise) { + scope.launch { + try { + val pair = generateRsaKeyPair() + publicKeyPairMap[pair.first] = pair.second + promise.resolve(pair.first) + } catch (e: Exception) { + promise.reject("KEYPAIR_GENERATION_ERROR", "Failed to generate key pair", e) + } + } + } +} diff --git a/apps/mobile/android/app/src/main/java/com/lux/EncryptionHelper.kt b/apps/mobile/android/app/src/main/java/com/lux/EncryptionHelper.kt new file mode 100644 index 00000000..22af0423 --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/lux/EncryptionHelper.kt @@ -0,0 +1,189 @@ +import com.lambdapioneer.argon2kt.Argon2Kt +import com.lambdapioneer.argon2kt.Argon2KtResult +import com.lambdapioneer.argon2kt.Argon2Mode +import javax.crypto.spec.OAEPParameterSpec +import javax.crypto.spec.PSource +import javax.crypto.spec.SecretKeySpec +import java.security.SecureRandom +import android.util.Base64; +import javax.crypto.Cipher +import javax.crypto.spec.IvParameterSpec +import java.security.KeyPair +import java.security.KeyPairGenerator +import java.security.spec.MGF1ParameterSpec +import java.security.spec.RSAKeyGenParameterSpec +import java.math.BigInteger + +/** + * Encrypts a string using an AES/GCM cipher and a key derived from a password and salt. + * + * This function creates a key from a password and salt with the function [keyFromPassword]. + * It then creates a Cipher instance for AES/GCM/NoPadding with a given random nonce and + * encrypts the original string. The result of this operation is encoded as Base64 and + * then returned. + * + * @param secret The original plaintext string to be encrypted. + * @param password The user-supplied password used for generating the encryption key with the salt. + * @param salt The unique salt used in conjunction with the password for key generation. + * @param nonce The unique byte array nonce (IV) used for AES-GCM cipher + * + * @return A string representing the Base64 encoded encrypted string. + * + * @throws IllegalArgumentException If secret, password, salt, or nonce is blank. + * + * @see Cipher + * @see SecretKeySpec + * @see IvParameterSpec + * @see Base64 + */ +fun encrypt(secret: String, password: String, salt: String, nonce: ByteArray): String { + val key = keyFromPassword(password, salt) + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(key, "AES"), IvParameterSpec(nonce)) + val encrypted = cipher.doFinal(secret.toByteArray(Charsets.UTF_8)) + return Base64.encodeToString(encrypted, Base64.DEFAULT) +} + +/** + * Decrypts an AES/GCM encrypted string using a password, salt, and nonce to provide the decryption key. + * + * This function generates a key derived from a password and salt, along with the nonce used for AES-GCM cipher + * This key is then used in an AES/GCM cipher to decrypt the provided encrypted secret. Returns the decrypted + * original string. + * + * @param encryptedSecret The encrypted string (in Base64 format) to be decrypted. + * @param password User-supplied password used along with salt for deriving the decryption key. + * @param salt A unique string (in Base64 format) used to diversify derived encryption keys + * and to protect from rainbow table attacks. + * @param nonce A unique byte array used as an nonce/IV for the AES-GCM cipher. + * + * @return A UTF-8 encoded string that was decrypted from the encryptedSecret. + * + * @throws IllegalArgumentException If password or salt or nonce is blank or encryptedSecret is not a valid Base64 string. + * + * @see Cipher + * @see SecretKeySpec + * @see IvParameterSpec + * @see Base64 + * @see Charsets.UTF_8 + */ +fun decrypt(encryptedSecret: String, password: String, salt: String, nonce: ByteArray): String { + val key = keyFromPassword(password, salt) + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(key, "AES"), IvParameterSpec(nonce)) + val original = cipher.doFinal(Base64.decode(encryptedSecret, Base64.DEFAULT)) + return String(original, Charsets.UTF_8) +} + +/** + * Generates a cryptographic key from a user password and salt using Argon2Kt. + * + * This function uses Argon2Kt to generate a hash from a user password and a given salt. + * It then returns the raw hash as a byte array. + * This is useful for secure password storage or key derivation. + * Parameters where picked based on the security audit and recommendations + * + * @param password The plaintext password provided by the user. + * @param salt The unique salt generated for hashing. + * + * @return A byte array representing the raw hash of the password. + * + * @throws IllegalArgumentException If password or salt is blank. + * + * @see Argon2Kt + * @see Charsets + */ +fun keyFromPassword(password: String, salt: String): ByteArray { + val hash: Argon2KtResult = Argon2Kt().hash( + mode = Argon2Mode.ARGON2_ID, + password = password.toByteArray(Charsets.UTF_8), + salt = salt.toByteArray(Charsets.UTF_8), + tCostInIterations = 3, + mCostInKibibyte = 65536, + parallelism = 4 + ) + return hash.rawHashAsByteArray() +} + +/** + * Generates a Base64-encoded string to be used as a security salt. + * + * This function creates a ByteArray of a given length and populates it + * with securely random bytes. These bytes are then encoded to Base64 string. + * + * @param length The length of the byte array to be generated which further determines the length of the salt. + * + * @return A Base64 encoded string representing the generated salt. + * + * @see SecureRandom + * @see Base64 + */ +fun generateSalt(length: Int): String { + val bytes = ByteArray(length) + val secureRandom = SecureRandom() + secureRandom.nextBytes(bytes) + return Base64.encodeToString(bytes, Base64.DEFAULT) +} + +/** + * Generates a byte array to be used as a nonce/initialization vector. + * + * This function creates a ByteArray of a given length and populates it + * with securely random bytes. + * + * @param length The length of the byte array to be generated. + * + * @return A random array of bytes representing the generated nonce. + * + * @see SecureRandom + * @see Base64 + */ +fun generateNonce(length: Int): ByteArray { + val bytes = ByteArray(length) + val secureRandom = SecureRandom() + secureRandom.nextBytes(bytes) + return bytes +} + +/** + * Generates an RSA key pair for encrypting/decrypting seed phrases. + * Matches the web implementation using RSA-OAEP with SHA-256. + * + * @return A Pair containing the Base64 encoded public key in SPKI format (first) + * and the KeyPair object (second) for later decryption + */ +fun generateRsaKeyPair(): Pair { + val keyPairGenerator = KeyPairGenerator.getInstance("RSA") + val parameterSpec = RSAKeyGenParameterSpec( + 2048, // modulusLength + BigInteger.valueOf(65537) // publicExponent (same as [1, 0, 1] in web) + ) + + keyPairGenerator.initialize(parameterSpec) + val keyPair = keyPairGenerator.generateKeyPair() + + // Export public key in SPKI + val publicKeyEncoded = keyPair.public.encoded + val publicKeyBase64 = Base64.encodeToString(publicKeyEncoded, Base64.NO_WRAP) + + return Pair(publicKeyBase64, keyPair) +} + +val oaepParams = OAEPParameterSpec( + "SHA-256", // digest algorithm + "MGF1", // mask generation function + MGF1ParameterSpec.SHA256, // MGF digest + PSource.PSpecified.DEFAULT // source of encoding input +) + +/** + * Decrypts an encrypted seed phrase response using an RSA key pair. + */ +fun decryptMnemonic(encryptedMnemonic: String, keyPair: KeyPair): String { + val cipher = Cipher.getInstance("RSA/None/OAEPPadding") + cipher.init(Cipher.DECRYPT_MODE, keyPair.private, oaepParams) + + val encryptedBytes = Base64.decode(encryptedMnemonic, Base64.DEFAULT) + val decryptedBytes = cipher.doFinal(encryptedBytes) + return String(decryptedBytes, Charsets.UTF_8) +} diff --git a/apps/mobile/android/app/src/main/java/com/lux/EthersRs.kt b/apps/mobile/android/app/src/main/java/com/lux/EthersRs.kt new file mode 100644 index 00000000..c4ed5ed5 --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/lux/EthersRs.kt @@ -0,0 +1,106 @@ +package com.lux + +/** + * These functions are defined from an object to be used from a static context. + * The Rust implementation contains JNI bindings that are generated from the definition here. + */ +object EthersRs { + + /** + * Validates a mnemonic string to check that each word exists in the BIP 39 wordlist. + * @param mnemonic - the mnemonic string + * @return The first invalid word. If there are none, an invalid string. + */ + external fun findInvalidWord(mnemonic: String): String + + /** + * General validation for a mnemonic string, including entropy. + * @param mnemonic - the mnemonic string + * @return True if valid and false if not. + */ + external fun validateMnemonic(mnemonic: String): Boolean + + /** + * Generates a mnemonic and its associated address. + * @return A CMnemonicAndAddress object containing the generated mnemonic and its associated address. + */ + external fun generateMnemonic(): MnemonicAndAddress + + /** + * Generates a private key from a given mnemonic. + * @param mnemonic The mnemonic to generate the private key from. + * @param index The index of the private key to generate. + * @return A CPrivateKey object containing the generated private key. + */ + external fun privateKeyFromMnemonic(mnemonic: String?, index: Int): PrivateKeyAndAddress + + /** + * Creates a wallet from a given private key. + * @param privateKey The private key to create the wallet from. + * @return A long representing the pointer to the created wallet. + */ + external fun walletFromPrivateKey(privateKey: String?): Long + + /** + * Frees the memory allocated for the wallet. + * @param walletPtr The pointer to the wallet to be freed. + */ + external fun walletFree(walletPtr: Long) + + /** + * Signs a transaction with a wallet. + * @param localWallet The wallet to sign the transaction with. + * @param txHash The transaction hash to sign. + * @param chainId The id of the blockchain network. + * @return A signed transaction hash. + */ + external fun signTxWithWallet( + localWallet: Long, + txHash: String, + chainId: Long + ): String + + /** + * Signs a message with a wallet. + * @param localWallet The wallet to sign the message with. + * @param message The message to sign. + * @return The signed message. + */ + external fun signMessageWithWallet( + localWallet: Long, + message: String + ): String + + /** + * Signs a hash with a wallet. + * @param localWallet The wallet to sign the hash with. + * @param hash The hash to sign. + * @param chainId The id of the blockchain network. + * @return The signed hash. + */ + external fun signHashWithWallet( + localWallet: Long, + hash: String, + chainId: Long + ): String +} + +/** + * Represents a private key and its associated address. + * @property privateKey The private key. + * @property address The address associated with the private key. + */ +class PrivateKeyAndAddress( + var privateKey: String, + var address: String, +) + +/** + * Represents a mnemonic and its associated address. + * @property mnemonic The mnemonic phrase. + * @property address The address associated with the mnemonic. + */ +class MnemonicAndAddress( + var mnemonic: String, + var address: String, +) diff --git a/apps/mobile/android/app/src/main/java/com/lux/GoogleDriveApiHelper.kt b/apps/mobile/android/app/src/main/java/com/lux/GoogleDriveApiHelper.kt new file mode 100644 index 00000000..4dd2a4b0 --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/lux/GoogleDriveApiHelper.kt @@ -0,0 +1,235 @@ +package com.lux + +import android.app.Activity +import android.content.Intent +import android.util.Log +import com.facebook.react.bridge.ActivityEventListener +import com.facebook.react.bridge.ReactApplicationContext +import com.google.android.gms.auth.api.signin.GoogleSignIn +import com.google.android.gms.auth.api.signin.GoogleSignInAccount +import com.google.android.gms.auth.api.signin.GoogleSignInClient +import com.google.android.gms.auth.api.signin.GoogleSignInOptions +import com.google.android.gms.common.api.ApiException +import com.google.android.gms.common.api.Scope +import com.google.api.client.googleapis.extensions.android.gms.auth.GoogleAccountCredential +import com.google.api.client.http.ByteArrayContent +import com.google.api.client.http.javanet.NetHttpTransport +import com.google.api.client.json.gson.GsonFactory +import com.google.api.services.drive.Drive +import com.google.api.services.drive.DriveScopes +import com.google.api.services.drive.model.File +import com.google.api.services.drive.model.FileList +import com.google.gson.Gson +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import java.nio.charset.StandardCharsets + +object GDriveParams { + const val SPACES = "appDataFolder" + const val FIELDS = "nextPageToken, files(id, name)" + const val PAGE_SIZE_NORMAL = 30 + const val PAGE_SIZE_SINGLE = 1 +} + +/** + * Helper class for Google Drive operations such as fetching and storing backups. + */ +class GoogleDriveApiHelper { + companion object { + + private val gson = Gson() + + /** + * Returns a GoogleSignInClient object, which is needed to access Google Drive. + * + * @param reactContext The react application context. + * @return GoogleSignInClient object + * @throws IllegalStateException if the activity context is null. + */ + private fun getGoogleSignInClient(reactContext: ReactApplicationContext): GoogleSignInClient { + val activity = reactContext.currentActivity + if (activity != null) { + val signInOptions = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN) + .requestEmail() + .requestScopes(Scope(DriveScopes.DRIVE_APPDATA)) + .build() + return GoogleSignIn.getClient(activity, signInOptions) + } else { + throw IllegalStateException("Activity cannot be null") + } + } + + /** + * Determines if the user has permission to Google Drive. + * + * @param reactContext The react application context. + * @return A Boolean value indicating whether the user has permission. + */ + private fun hasPermissionToGoogleDrive(reactContext: ReactApplicationContext): Boolean { + val acc = GoogleSignIn.getLastSignedInAccount(reactContext) + val hasPermissions = + acc?.let { GoogleSignIn.hasPermissions(acc, Scope(DriveScopes.DRIVE_APPDATA)) } + return hasPermissions == true + } + + /** + * Fetches cloud backups from Google Drive and triggers corresponding events. + * + * @param drive The authenticated Drive object of Google Drive. + */ + suspend fun fetchCloudBackupFiles( + drive: Drive + ): FileList { + return withContext(Dispatchers.IO) { + val files: FileList = drive.files().list() + .setSpaces(GDriveParams.SPACES) + .setFields(GDriveParams.FIELDS) + .setPageSize(GDriveParams.PAGE_SIZE_NORMAL) + .execute() + + files + } + } + + /** + * Asynchronously retrieves an authenticated GoogleDrive object by gaining Google Drive permissions. + * + * @param reactContext The react application context. + * @return An authenticated GoogleSignInAccount object. + */ + private suspend fun getGoogleDrivePermissions(reactContext: ReactApplicationContext): GoogleSignInAccount? = + suspendCancellableCoroutine { continuation -> + try { + val googleSignInClient = getGoogleSignInClient(reactContext) + googleSignInClient.signOut() // Force a sign out so that we can reselect account + val signInIntent = googleSignInClient.signInIntent + reactContext.currentActivity?.startActivityForResult( + signInIntent, + Request.GOOGLE_SIGN_IN.value + ) + val listener = object : ActivityEventListener { + override fun onActivityResult( + activity: Activity?, + requestCode: Int, + resultCode: Int, + intent: Intent? + ) { + // Remove the listener after using it + reactContext.removeActivityEventListener(this) + if (requestCode == Request.GOOGLE_SIGN_IN.value && resultCode == Activity.RESULT_OK) { + + val signInTask = GoogleSignIn.getSignedInAccountFromIntent(intent) + val account: GoogleSignInAccount? = + signInTask.getResult(ApiException::class.java) + continuation.resumeWith(Result.success(account)) + + } else { + continuation.resumeWith(Result.failure(Exception("Oauth process has been interrupted"))) + Log.d("Activity intent", "Intent null") + } + } + + override fun onNewIntent(p0: Intent?) {} + } + reactContext.addActivityEventListener(listener) + } catch (e: Exception) { + Log.e("EXCEPTION", "${e.message}") + continuation.resumeWith( + Result.failure( + Exception("Failed to get google drive account") + ) + ) + } + } + + /** + * Asynchronously retrieves an authenticated Drive object. + * + * @param reactContext The react application context. + * @return Google Drive object if user has permissions, `null` otherwise. + */ + suspend fun getGoogleDrive( + reactContext: ReactApplicationContext, + useRecentAccount: Boolean = false + ): Pair { + return withContext(Dispatchers.IO) { + val canUseRecentAccount = useRecentAccount && hasPermissionToGoogleDrive(reactContext) + val account = + if (canUseRecentAccount) + GoogleSignIn.getLastSignedInAccount(reactContext) + else + getGoogleDrivePermissions(reactContext) + + val drive = account?.let { + val credential = + GoogleAccountCredential.usingOAuth2(reactContext, listOf(DriveScopes.DRIVE_APPDATA)) + credential.selectedAccount = account.account!! + + Drive.Builder( + NetHttpTransport(), + GsonFactory.getDefaultInstance(), + credential + ) + .setApplicationName(reactContext.getString(R.string.app_name)) + .build() + } + + Pair(drive, account) + } + } + + /** + * Fetches the fileId of a file in Google Drive by its file name. + * Assuming there is no bug in code, should always be only one file with the given name + * even though google drive allows to store multiple files with the same name + * + * @param drive The authenticated Drive object of Google Drive. + * @param name Name of the file. + * @return String fileId if file exists, `null` otherwise. + */ + fun getFileIdByFileName(drive: Drive, name: String): String? { + try { + val files: FileList = drive.files().list() + .setSpaces(GDriveParams.SPACES) + .setFields(GDriveParams.FIELDS) + .setPageSize(GDriveParams.PAGE_SIZE_SINGLE) + .setQ("name = '$name.json'") + .execute() + return files.files.firstOrNull()?.id + } catch (e: Exception) { + e.printStackTrace() + } + return null + } + + /** + * Uploads mnemonic backup to google drive in json formant + * + * @param drive The authenticated Drive object of Google Drive. + * @param mnemonicId Id of saved mnemonic. + * @param backup Instance of [CloudStorageMnemonicBackup] object representing mnemonic backup. + * @return String fileId if file exists, `null` otherwise. + */ + fun saveMnemonicToGoogleDrive( + drive: Drive, + mnemonicId: String, + backup: CloudStorageMnemonicBackup + ) { + val fileMetadata = File() + fileMetadata.name = "$mnemonicId.json" + fileMetadata.parents = listOf("appDataFolder") + + val jsonData = gson.toJson(backup) + + val jsonByteArray = jsonData.toByteArray(StandardCharsets.UTF_8) + val inputContent = ByteArrayContent("application/json", jsonByteArray) + val fileId = getFileIdByFileName(drive, mnemonicId) + if (fileId != null) { + drive.files().delete(fileId).execute() + } + drive.files().create(fileMetadata, inputContent) + .execute() + } + } +} diff --git a/apps/mobile/android/app/src/main/java/com/lux/LuxPackage.kt b/apps/mobile/android/app/src/main/java/com/lux/LuxPackage.kt new file mode 100644 index 00000000..267ba8cf --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/lux/LuxPackage.kt @@ -0,0 +1,34 @@ +package com.lux + +import android.view.View +import com.facebook.react.ReactPackage +import com.facebook.react.bridge.NativeModule +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.uimanager.ReactShadowNode +import com.facebook.react.uimanager.ViewManager +import com.lux.notifications.SilentPushEventEmitterModule +import com.lux.onboarding.backup.MnemonicConfirmationViewManager +import com.lux.onboarding.backup.MnemonicDisplayViewManager +import com.lux.onboarding.import.SeedPhraseInputViewManager +import com.lux.onboarding.privatekeys.PrivateKeyDisplayViewManager + +class LuxPackage : ReactPackage { + override fun createViewManagers( + reactContext: ReactApplicationContext + ): List>> = listOf( + MnemonicConfirmationViewManager(), + MnemonicDisplayViewManager(), + SeedPhraseInputViewManager(), + PrivateKeyDisplayViewManager() + ) + + override fun createNativeModules( + reactContext: ReactApplicationContext + ): List = listOf( + AndroidDeviceModule(reactContext), + RNEthersRSModule(reactContext), + EmbeddedWalletModule(reactContext), + ThemeModule(reactContext), + SilentPushEventEmitterModule(reactContext), + ) +} diff --git a/apps/mobile/android/app/src/main/java/com/lux/MainActivity.kt b/apps/mobile/android/app/src/main/java/com/lux/MainActivity.kt new file mode 100644 index 00000000..0d6d0457 --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/lux/MainActivity.kt @@ -0,0 +1,49 @@ +package com.lux + +import android.graphics.Color +import android.os.Build +import android.os.Bundle +import android.view.View +import com.facebook.react.ReactActivity +import com.facebook.react.ReactActivityDelegate +import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.concurrentReactEnabled +import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled +import com.facebook.react.defaults.DefaultReactActivityDelegate +import com.facebook.react.modules.i18nmanager.I18nUtil +import expo.modules.ReactActivityDelegateWrapper +import com.zoontek.rnbootsplash.RNBootSplash; + + +class MainActivity : ReactActivity() { + + /** + * Returns the name of the main component registered from JavaScript. This is used to schedule + * rendering of the component. + */ + override fun getMainComponentName(): String { + return "Lux" + } + + // Required for react-navigation to work on Android + override fun onCreate(savedInstanceState: Bundle?) { + RNBootSplash.init(this, R.style.AppTheme) + + super.onCreate(null); + + window.navigationBarColor = Color.TRANSPARENT + + if (Build.VERSION_CODES.Q <= Build.VERSION.SDK_INT) { + window.isNavigationBarContrastEnforced = false + } + val sharedI18nUtilInstance = I18nUtil.getInstance() + sharedI18nUtilInstance.allowRTL(applicationContext, false) + } + + /** + * Returns the instance of the [ReactActivityDelegate]. Here we use a util class [ ] which allows you to easily enable Fabric and Concurrent React + * (aka React 18) with two boolean flags. + */ + override fun createReactActivityDelegate(): ReactActivityDelegate? { + return ReactActivityDelegateWrapper(this, BuildConfig.IS_NEW_ARCHITECTURE_ENABLED, DefaultReactActivityDelegate(this, mainComponentName, fabricEnabled)) + } +} diff --git a/apps/mobile/android/app/src/main/java/com/lux/MainApplication.kt b/apps/mobile/android/app/src/main/java/com/lux/MainApplication.kt new file mode 100644 index 00000000..03b5a058 --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/lux/MainApplication.kt @@ -0,0 +1,64 @@ +package com.lux + +import android.app.Application +import android.content.res.Configuration +import com.facebook.react.PackageList +import com.facebook.react.ReactApplication +import com.facebook.react.ReactHost +import com.facebook.react.ReactNativeHost +import com.facebook.react.ReactPackage +import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.load +import com.facebook.react.defaults.DefaultReactNativeHost +import com.facebook.react.soloader.OpenSourceMergedSoMapping +import com.facebook.react.defaults.DefaultReactHost.getDefaultReactHost +import com.facebook.soloader.SoLoader +import com.shopify.reactnativeperformance.ReactNativePerformance +import com.lux.onboarding.scantastic.ScantasticEncryptionModule +import expo.modules.ApplicationLifecycleDispatcher +import expo.modules.ReactNativeHostWrapper + +class MainApplication : Application(), ReactApplication { + override val reactNativeHost: ReactNativeHost = + ReactNativeHostWrapper(this, object : DefaultReactNativeHost(this) { + override fun getPackages(): List = + PackageList(this).packages.apply { + // Packages that cannot be autolinked yet can be added manually here, for example: + // packages.add(new MyReactNativePackage()); + add(LuxPackage()) + add(RNCloudStorageBackupsManagerModule()) + add(ScantasticEncryptionModule()) + add(RedirectToSourceAppPackage()) + } + override fun getJSMainModuleName(): String = ".expo/.virtual-metro-entry" + + override fun getUseDeveloperSupport(): Boolean { + return BuildConfig.DEBUG + } + + override val isNewArchEnabled: Boolean + get() = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED + }) + + override val reactHost: ReactHost + get() = ReactNativeHostWrapper.createReactHost(applicationContext, reactNativeHost) + + override fun onCreate() { + ReactNativePerformance.onAppStarted() + super.onCreate() + + // Initialize SoLoader before any code that might load native libraries + SoLoader.init(this, OpenSourceMergedSoMapping) + if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) { + // If you opted-in for the New Architecture, we load the native entry point for this app. + load() + } + + // Initialize Expo modules after SoLoader + ApplicationLifecycleDispatcher.onApplicationCreate(this) + } + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + ApplicationLifecycleDispatcher.onConfigurationChanged(this, newConfig) + } +} diff --git a/apps/mobile/android/app/src/main/java/com/lux/RNCloudStorageBackupsManager.kt b/apps/mobile/android/app/src/main/java/com/lux/RNCloudStorageBackupsManager.kt new file mode 100644 index 00000000..471d0166 --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/lux/RNCloudStorageBackupsManager.kt @@ -0,0 +1,297 @@ +package com.lux + +import android.util.Log +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactContextBaseJavaModule +import com.facebook.react.bridge.ReactMethod +import com.facebook.react.bridge.WritableMap +import com.facebook.react.bridge.WritableArray +import com.facebook.react.modules.core.DeviceEventManagerModule +import com.google.android.gms.common.ConnectionResult +import com.google.android.gms.common.GoogleApiAvailability +import com.google.gson.Gson +import decrypt +import encrypt +import generateSalt +import generateNonce +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.ByteArrayOutputStream +import java.io.FileNotFoundException +import java.util.Date +import javax.crypto.BadPaddingException +import javax.crypto.IllegalBlockSizeException +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import java.util.Collections + +/** + * Data class representing a mnemonic backup in cloud storage. + * + * @property mnemonicId The ID of the mnemonic string. + * @property encryptedMnemonic The encrypted mnemonic string. + * @property encryptionSalt The salt used for generating the encryption key from password. + * @property encryptionNonce The nonce used for encryption. + * @property createdAt The time the backup was created, in seconds since the epoch. + */ +data class CloudStorageMnemonicBackup( + val mnemonicId: String, + val encryptedMnemonic: String, + val encryptionSalt: String, + val encryptionNonce: ByteArray, + val createdAt: Double, + val googleDriveEmail: String? = null +) + +/** + * Enum representing various types of cloud backup errors. + */ +enum class CloudBackupError(val value: String) { + BACKUP_NOT_FOUND_ERROR("backupNotFoundError"), + BACKUP_ENCRYPTION_ERROR("backupEncryptionError"), + BACKUP_DECRYPTION_ERROR("backupDecryptionError"), + BACKUP_INCORRECT_PASSWORD_ERROR("backupIncorrectPasswordError"), + DELETE_BACKUP_ERROR("deleteBackupError"), + CLOUD_ERROR("cloudError") +} + +/** + * Enum representing various types of requests. + */ +enum class Request(val value: Int) { + GOOGLE_SIGN_IN(122) +} + +/** + * Class for managing cloud storage backups on android for React Native. + * + * @property reactContext The react application context. + */ +class RNCloudStorageBackupsManager(private val reactContext: ReactApplicationContext) : + ReactContextBaseJavaModule(reactContext) { + + override fun getName() = "RNCloudStorageBackupsManager" + + private val rnEthersRS = RnEthersRs(reactContext) + + private val gson = Gson() + + /** + * Checks if cloud storage services (like Google Play Services) are available. + * There is no way to check if just google drive api is available + * + * @param promise A promise to return the result of the operation. + */ + @ReactMethod + fun isCloudStorageAvailable(promise: Promise) { + val googleApiAvailability = GoogleApiAvailability.getInstance() + val resultCode = googleApiAvailability.isGooglePlayServicesAvailable(reactContext) + promise.resolve(resultCode == ConnectionResult.SUCCESS) + } + + /** + * Fetches list of backups and returns it by resolving a promise promise. + * + * @param promise A promise to return the result of the operation. + */ + @ReactMethod + fun getCloudBackupList(promise: Promise) { + CoroutineScope(Dispatchers.Main).launch { + try { + GoogleDriveApiHelper.getGoogleDrive(reactContext).let { (drive) -> + if (drive == null) return@launch + GoogleDriveApiHelper.fetchCloudBackupFiles(drive).let { files -> + val backupDeferreds = files.files.map { file -> + async(Dispatchers.IO) { + val outputStream = ByteArrayOutputStream() + drive.files()[file.id] + .executeMediaAndDownloadTo(outputStream) + val mnemonicBackup: CloudStorageMnemonicBackup = gson.fromJson(outputStream.toString(), CloudStorageMnemonicBackup::class.java) + + val backup: WritableMap = Arguments.createMap() + backup.putString("mnemonicId", mnemonicBackup.mnemonicId) + backup.putString("createdAt", mnemonicBackup.createdAt.toString()) + backup.putString("googleDriveEmail", mnemonicBackup.googleDriveEmail) + + backup + } + } + + val backups = backupDeferreds.awaitAll() + + val resultArray: WritableArray = Arguments.createArray() + backups.forEach { resultArray.pushMap(it) } + promise.resolve(resultArray) + } + } + } catch (e: Exception) { + promise.reject(CloudBackupError.CLOUD_ERROR.value, "Failed to fetch cloud backups") + } + } + } + + /** + * Backs up a mnemonic string as a json file with mnemonicId as a name to google drive api. + * Backup is stored in app specific folder which is not accessible to other apps. + * + * @param mnemonicId The ID of the mnemonic string. + * @param password The password used for encryption. + * @param promise A promise to return the result of the operation. + */ + @ReactMethod + fun backupMnemonicToCloudStorage(mnemonicId: String, password: String, promise: Promise) { + CoroutineScope(Dispatchers.Default).launch { + try { + val mnemonic = rnEthersRS.retrieveMnemonic(mnemonicId) + ?: throw Exception("rnEthersRs module retrieve mnemonic null") + val encryptionSalt = generateSalt(16) + val encryptionNonce = generateNonce(12) + val encryptedMnemonic = + withContext(Dispatchers.IO) { encrypt(mnemonic, password, encryptionSalt, encryptionNonce) } + GoogleDriveApiHelper.getGoogleDrive(reactContext).let { (drive, acc) -> + if (drive == null) return@launch + val createdAt = Date().time / 1000.0 + val backup = CloudStorageMnemonicBackup( + mnemonicId, + encryptedMnemonic, + encryptionSalt, + encryptionNonce, + createdAt, + acc?.email + ) + withContext(Dispatchers.IO) { + GoogleDriveApiHelper.saveMnemonicToGoogleDrive(drive, mnemonicId, backup) + } + } + promise.resolve(true) + } catch (e: Exception) { + promise.reject( + CloudBackupError.BACKUP_ENCRYPTION_ERROR.value, + "Failed to encrypt mnemonics: ${e.message}", + e, + ) + } + } + } + + /** + * Restores a mnemonic string from a backup in google drive. + * + * @param mnemonicId The ID of the mnemonic string. + * @param password The password used for decryption. + * @param promise A promise to return the result of the operation. + */ + @ReactMethod + fun restoreMnemonicFromCloudStorage(mnemonicId: String, password: String, promise: Promise) { + CoroutineScope(Dispatchers.Main).launch { + try { + GoogleDriveApiHelper.getGoogleDrive(reactContext, true).let { (drive) -> + if (drive == null) return@launch + val fileId = withContext(Dispatchers.IO) { + GoogleDriveApiHelper.getFileIdByFileName( + drive, + mnemonicId + ) + } + if (fileId == null) { + promise.reject( + CloudBackupError.BACKUP_NOT_FOUND_ERROR.value, + "The file $mnemonicId is not found in Google Drive" + ) + } + val outputStream = ByteArrayOutputStream() + withContext(Dispatchers.IO) { + drive.files().get(fileId).executeMediaAndDownloadTo(outputStream) + } + var mnemonicsBackup: CloudStorageMnemonicBackup? + var decryptedMnemonics: String? = null + + withContext(Dispatchers.IO) { + mnemonicsBackup = + gson.fromJson(outputStream.toString(), CloudStorageMnemonicBackup::class.java) + } + + val encryptedMnemonic = mnemonicsBackup?.encryptedMnemonic + val encryptionSalt = mnemonicsBackup?.encryptionSalt + val encryptionNonce = mnemonicsBackup?.encryptionNonce + + if (encryptedMnemonic == null || encryptionSalt == null || encryptionNonce == null) throw Exception("Failed to read mnemonics backup") + + try { + decryptedMnemonics = withContext(Dispatchers.IO) { + decrypt(encryptedMnemonic, password, encryptionSalt, encryptionNonce) + } + } catch (e: BadPaddingException) { + Log.e("EXCEPTION", "${e.message}") + promise.reject( + CloudBackupError.BACKUP_INCORRECT_PASSWORD_ERROR.value, + "Incorrect decryption password" + ) + } catch (e: IllegalBlockSizeException) { + Log.e("EXCEPTION", "${e.message}") + promise.reject( + CloudBackupError.BACKUP_DECRYPTION_ERROR.value, + "Incorrect decryption password" + ) + } catch (e: Exception) { + Log.e("EXCEPTION", "${e.message}") + promise.reject( + CloudBackupError.BACKUP_DECRYPTION_ERROR.value, + "Failed to decrypt mnemonics" + ) + } + + rnEthersRS.storeNewMnemonic(mnemonic = decryptedMnemonics, address = mnemonicId) + promise.resolve(true) + } + } catch (e: Exception) { + Log.e("EXCEPTION", "${e.message}") + promise.reject( + CloudBackupError.BACKUP_NOT_FOUND_ERROR.value, + "Backup file not found in local storage" + ) + } + } + } + + /** + * Deletes a mnemonic backup from google drive. + * + * @param mnemonicId The ID of the mnemonic backup. + * @param promise A promise to return the result of the operation. + */ + @ReactMethod + fun deleteCloudStorageMnemonicBackup(mnemonicId: String, promise: Promise) { + CoroutineScope(Dispatchers.Main).launch { + try { + GoogleDriveApiHelper.getGoogleDrive(reactContext, true).let { (drive) -> + if (drive == null) return@launch + withContext(Dispatchers.IO) { + val fileId = GoogleDriveApiHelper.getFileIdByFileName(drive, mnemonicId) + if (fileId == null) { + GoogleDriveApiHelper.getGoogleDrive(reactContext).let { (drive) -> + if (drive == null) return@let + val fileId = GoogleDriveApiHelper.getFileIdByFileName(drive, mnemonicId) + ?: throw FileNotFoundException("Failed to locate backup") + drive.files().delete(fileId).execute() + } + } else { + drive.files().delete(fileId).execute() + } + } + } + promise.resolve(true) + } catch (e: FileNotFoundException) { + Log.e("EXCEPTION", "${e.message}") + promise.reject(CloudBackupError.DELETE_BACKUP_ERROR.value, "Failed to locate backup") + } catch (e: Exception) { + Log.e("EXCEPTION", "${e.message}") + promise.reject(CloudBackupError.DELETE_BACKUP_ERROR.value, "Failed to delete backup") + } + } + } +} diff --git a/apps/mobile/android/app/src/main/java/com/lux/RNCloudStorageBackupsManagerModule.kt b/apps/mobile/android/app/src/main/java/com/lux/RNCloudStorageBackupsManagerModule.kt new file mode 100644 index 00000000..42d090f0 --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/lux/RNCloudStorageBackupsManagerModule.kt @@ -0,0 +1,19 @@ +package com.lux + +import android.view.View +import com.facebook.react.ReactPackage +import com.facebook.react.bridge.NativeModule +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.uimanager.ReactShadowNode +import com.facebook.react.uimanager.ViewManager + +class RNCloudStorageBackupsManagerModule : ReactPackage { + + override fun createViewManagers( + reactContext: ReactApplicationContext + ): MutableList>> = mutableListOf() + + override fun createNativeModules( + reactContext: ReactApplicationContext + ): MutableList = listOf(RNCloudStorageBackupsManager(reactContext)).toMutableList() +} diff --git a/apps/mobile/android/app/src/main/java/com/lux/RNEthersRSModule.kt b/apps/mobile/android/app/src/main/java/com/lux/RNEthersRSModule.kt new file mode 100644 index 00000000..c98bb1ab --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/lux/RNEthersRSModule.kt @@ -0,0 +1,101 @@ +package com.lux; +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactContextBaseJavaModule +import com.facebook.react.bridge.ReactMethod +import com.facebook.react.bridge.WritableArray +import com.facebook.react.bridge.WritableNativeArray +import com.facebook.react.module.annotations.ReactModule +import com.facebook.soloader.SoLoader +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch + +/** + * Bridge between the React Native JavaScript code and the native Android code. + * It provides several methods that can be called from JavaScript, using the @ReactMethod annotation. + * The module uses the RnEthersRs class, which is initialized with the application context. + * The native library "ethers_ffi" is loaded when the module is initialized (`libethers_ffi.so`). + */ +@ReactModule(name = "RNEthersRS") +class RNEthersRSModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) { + + private val ethersRs: RnEthersRs = RnEthersRs(reactContext.applicationContext) + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + // Needs to be initialized form a static context + companion object { + init { + SoLoader.loadLibrary("ethers_ffi") + } + } + + override fun getName() = "RNEthersRS" + + @ReactMethod fun getMnemonicIds(promise: Promise) { + val array = WritableNativeArray() + ethersRs.mnemonicIds.forEach { + array.pushString(it) + } + promise.resolve(array) + } + + @ReactMethod fun importMnemonic(mnemonic: String, promise: Promise) { + promise.resolve(ethersRs.importMnemonic(mnemonic)) + } + + @ReactMethod fun removeMnemonic(mnemonicId: String, promise: Promise) { + scope.launch(Dispatchers.IO) { + val result = ethersRs.removeMnemonic(mnemonicId) + promise.resolve(result) + } + } + + @ReactMethod fun generateAndStoreMnemonic(promise: Promise) { + promise.resolve(ethersRs.generateAndStoreMnemonic()) + } + + @ReactMethod fun getAddressesForStoredPrivateKeys(promise: Promise) { + val addresses = ethersRs.addressesForStoredPrivateKeys + + // Convert the List to a WritableArray for passing over the bridge + val writableArray: WritableArray = WritableNativeArray() + for (address in addresses) { + writableArray.pushString(address) + } + + promise.resolve(writableArray) + } + + @ReactMethod fun generateAddressForMnemonic(mnemonic: String, derivationIndex: Int, promise: Promise) { + promise.resolve(ethersRs.generateAddressForMnemonic(mnemonic, derivationIndex)) + } + + @ReactMethod fun generateAndStorePrivateKey(mnemonicId: String, derivationIndex: Int, promise: Promise) { + try { + promise.resolve(ethersRs.generateAndStorePrivateKey(mnemonicId, derivationIndex)) + } catch (error: Exception) { + promise.reject(error) + } + } + + @ReactMethod fun removePrivateKey(address: String, promise: Promise) { + scope.launch(Dispatchers.IO) { + val result = ethersRs.removePrivateKey(address) + promise.resolve(result) + } + } + + @ReactMethod fun signTransactionHashForAddress(address: String, hash: String, chainId: Int, promise: Promise) { + promise.resolve(ethersRs.signTransactionHashForAddress(address, hash, chainId.toLong())) + } + + @ReactMethod fun signMessageForAddress(address: String, message: String, promise: Promise) { + promise.resolve(ethersRs.signMessageForAddress(address, message)) + } + + @ReactMethod fun signHashForAddress(address: String, hash: String, chainId: Int, promise: Promise) { + promise.resolve(ethersRs.signHashForAddress(address, hash, chainId.toLong())) + } +} diff --git a/apps/mobile/android/app/src/main/java/com/lux/RedirectToSourceAppModule.kt b/apps/mobile/android/app/src/main/java/com/lux/RedirectToSourceAppModule.kt new file mode 100644 index 00000000..f46a582f --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/lux/RedirectToSourceAppModule.kt @@ -0,0 +1,19 @@ +package com.lux + +import android.content.Intent +import android.net.Uri +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactContextBaseJavaModule +import com.facebook.react.bridge.ReactMethod + +class RedirectToSourceAppModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) { + + override fun getName(): String { + return "RedirectToSourceApp" + } + + @ReactMethod + fun moveAppToBackground() { + currentActivity?.moveTaskToBack(true) + } +} diff --git a/apps/mobile/android/app/src/main/java/com/lux/RedirectToSourceAppPackage.kt b/apps/mobile/android/app/src/main/java/com/lux/RedirectToSourceAppPackage.kt new file mode 100644 index 00000000..1288f0c9 --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/lux/RedirectToSourceAppPackage.kt @@ -0,0 +1,16 @@ +package com.lux + +import com.facebook.react.ReactPackage +import com.facebook.react.bridge.NativeModule +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.uimanager.ViewManager + +class RedirectToSourceAppPackage : ReactPackage { + override fun createNativeModules(reactContext: ReactApplicationContext): List { + return listOf(RedirectToSourceAppModule(reactContext)) + } + + override fun createViewManagers(reactContext: ReactApplicationContext): List> { + return emptyList() + } +} diff --git a/apps/mobile/android/app/src/main/java/com/lux/RnEthersRs.kt b/apps/mobile/android/app/src/main/java/com/lux/RnEthersRs.kt new file mode 100644 index 00000000..4d795686 --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/lux/RnEthersRs.kt @@ -0,0 +1,213 @@ +package com.lux + +import android.content.Context +import android.content.SharedPreferences +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKeys +import com.lux.EthersRs.generateMnemonic +import com.lux.EthersRs.privateKeyFromMnemonic +import com.lux.EthersRs.signHashWithWallet +import com.lux.EthersRs.signMessageWithWallet +import com.lux.EthersRs.signTxWithWallet +import com.lux.EthersRs.walletFromPrivateKey +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class RnEthersRs(applicationContext: Context) { + + // Long represents the opaque pointer to the Rust LocalWallet struct. + private val walletCache: MutableMap = mutableMapOf() + private val keychain: SharedPreferences + + init { + val masterKeyAlias = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC) + keychain = EncryptedSharedPreferences.create( + "preferences", + masterKeyAlias, + applicationContext, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ) + } + + val mnemonicIds: List + get() = keychain.all.keys.filter { + // MOB-3453 this will need to be updated after fixing prefixes + it.startsWith(MNEMONIC_PREFIX) + }.map { + key -> key.replace(MNEMONIC_PREFIX, "") + } + + /** + * Imports a mnemonic and returns the associated address. + * @param mnemonic The mnemonic to import. + * @return The address associated with the mnemonic. + */ + fun importMnemonic(mnemonic: String): String { + val privateKey = privateKeyFromMnemonic(mnemonic, 0) + val address = privateKey.address + return storeNewMnemonic(mnemonic, address) + } + + /** + * Generates a new mnemonic, stores it, and returns the associated address. + * @return The address associated with the new mnemonic. + */ + fun generateAndStoreMnemonic(): String { + val mnemonic = generateMnemonic() + val mnemonicStr = mnemonic.mnemonic + val addressStr = mnemonic.address + return storeNewMnemonic(mnemonicStr, addressStr) + } + + /** + * Stores a new mnemonic and its associated address. + * @param mnemonic The mnemonic to store. + * @param address The address associated with the mnemonic. + * @return The address. + */ + fun storeNewMnemonic(mnemonic: String?, address: String): String { + val checkStored = retrieveMnemonic(address) + if (checkStored == null) { + val newMnemonicKey = keychainKeyForMnemonicId(address) + keychain.edit().putString(newMnemonicKey, mnemonic).apply() + } + return address + } + + + private fun keychainKeyForMnemonicId(mnemonicId: String): String { + return MNEMONIC_PREFIX + mnemonicId + } + + fun retrieveMnemonic(mnemonicId: String): String? { + return keychain.getString(keychainKeyForMnemonicId(mnemonicId), null) + } + + suspend fun removeMnemonic(mnemonicId: String): Boolean { + keychain.edit().remove(keychainKeyForMnemonicId(mnemonicId)).apply() + return true + } + + val addressesForStoredPrivateKeys: List + get() = keychain.all.keys + .filter { key -> key.contains(PRIVATE_KEY_PREFIX) } + .map { key -> key.replace(PRIVATE_KEY_PREFIX, "") } + + private fun storeNewPrivateKey(address: String, privateKey: String?) { + val newKey = keychainKeyForPrivateKey(address) + keychain.edit().putString(newKey, privateKey).apply() + } + + /** + * Generates public address for a given mnemonic and returns the associated address. + * @param mnemonic Mmnemonic to generate the public address from. + * @param derivationIndex The index of the private key to generate. + * @return The address associated with the new private key. + */ + fun generateAddressForMnemonic(mnemonic: String, derivationIndex: Int): String { + val privateKey = privateKeyFromMnemonic(mnemonic, derivationIndex) + return privateKey.address + } + + /** + * Generates and stores a new private key for a given mnemonic and returns the associated address. + * @param mnemonicId The id of the mnemonic to generate the private key from. + * @param derivationIndex The index of the private key to generate. + * @return The address associated with the new private key. + */ + fun generateAndStorePrivateKey(mnemonicId: String, derivationIndex: Int): String { + val mnemonic = retrieveMnemonic(mnemonicId) + ?: throw IllegalArgumentException("Mnemonic not found") + + val privateKey = privateKeyFromMnemonic(mnemonic, derivationIndex) + val xprv = privateKey.privateKey + val address = privateKey.address + storeNewPrivateKey(address, xprv) + return address + } + + suspend fun removePrivateKey(address: String): Boolean { + keychain.edit().remove(keychainKeyForPrivateKey(address)).apply() + return true + } + + /** + * Signs a transaction for a given address. + * @param address The address to sign the transaction for. + * @param hash The transaction hash to sign. + * @param chainId The id of the blockchain network. + * @return The signed transaction hash. + */ + fun signTransactionHashForAddress(address: String, hash: String, chainId: Long): String { + val wallet = retrieveOrCreateWalletForAddress(address) + return signTxWithWallet(wallet, hash, chainId) + } + + /** + * Signs a message for a given address. + * @param address The address to sign the message for. + * @param message The message to sign. + * @return The signed message. + */ + fun signMessageForAddress(address: String, message: String): String { + val wallet = retrieveOrCreateWalletForAddress(address) + return signMessageWithWallet(wallet, message) + } + + /** + * Signs a hash for a given address. + * @param address The address to sign the hash for. + * @param hash The hash to sign. + * @param chainId The id of the blockchain network. + * @return The signed hash. + */ + fun signHashForAddress(address: String, hash: String, chainId: Long): String { + val wallet = retrieveOrCreateWalletForAddress(address) + return signHashWithWallet(wallet, hash, chainId) + } + + /** + * Retrieves an existing wallet for a given address or creates a new one if it doesn't exist. + * @param address The address of the wallet. + * @return A long representing the pointer to the wallet. + */ + private fun retrieveOrCreateWalletForAddress(address: String): Long { + val wallet = walletCache[address] + if (wallet != null) { + return wallet + } + val privateKey = retrievePrivateKey(address) + val newWallet = walletFromPrivateKey(privateKey) + walletCache[address] = newWallet + return newWallet + } + + /** + * Retrieves the private key for a given address. + * @param address The address to retrieve the private key for. + * @return The private key, or null if it doesn't exist. + */ + fun retrievePrivateKey(address: String): String? { + return keychain.getString(keychainKeyForPrivateKey(address), null) + } + /** + * Generates the keychain key for a given address. + * @param address The address to generate the key for. + * @return The keychain key. + */ + private fun keychainKeyForPrivateKey(address: String): String { + return PRIVATE_KEY_PREFIX + address + } + + companion object { + private const val PREFIX = "com.lux" + private const val MNEMONIC_PREFIX = ".mnemonic." + private const val PRIVATE_KEY_PREFIX = ".privateKey." + // MOB-3453 Android is currently not storing keys with PREFIX + private const val ENTIRE_MNEMONIC_PREFIX = PREFIX + MNEMONIC_PREFIX + } +} diff --git a/apps/mobile/android/app/src/main/java/com/lux/ThemeModule.kt b/apps/mobile/android/app/src/main/java/com/lux/ThemeModule.kt new file mode 100644 index 00000000..5fea02fc --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/lux/ThemeModule.kt @@ -0,0 +1,58 @@ +package com.lux +import android.app.Activity +import android.os.Build +import android.view.View +import android.view.WindowInsetsController +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactContextBaseJavaModule +import com.facebook.react.bridge.ReactMethod +import android.content.Context +import android.content.res.Configuration + +import androidx.appcompat.app.AppCompatDelegate + +class ThemeModule(private val reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) { + + override fun getName() = "ThemeModule" + + @ReactMethod fun setColorScheme(style: String) { + val activity = currentActivity + when (style) { + "dark" -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES); + "light" -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO); + "system" -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM); + } + val isLightTheme = style == "light" || (style == "system" && !isSystemInDarkTheme(reactContext)) + if (activity != null) setBottomNavigationTheme(activity, isLightTheme) + } + companion object { + fun setBottomNavigationTheme(activity: Activity, isLightTheme: Boolean) { + val window = activity.window + activity.runOnUiThread { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + window.setDecorFitsSystemWindows(false) + window.insetsController?.let { + it.setSystemBarsAppearance( + if (isLightTheme) WindowInsetsController.APPEARANCE_LIGHT_NAVIGATION_BARS else 0, + WindowInsetsController.APPEARANCE_LIGHT_NAVIGATION_BARS + ) + } + } else { + //TODO: Deprecated method won't allow for dynamic switch so it's safer to hardcoded dark buttons layout + @Suppress("DEPRECATION") + window.decorView.systemUiVisibility = ( + View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION + or View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR + ) + } + } + } + fun isSystemInDarkTheme(context: Context): Boolean { + return when (context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) { + Configuration.UI_MODE_NIGHT_YES -> true + Configuration.UI_MODE_NIGHT_NO -> false + else -> false + } + } + } +} diff --git a/apps/mobile/android/app/src/main/java/com/lux/extensions/ScrollFadeExtensions.kt b/apps/mobile/android/app/src/main/java/com/lux/extensions/ScrollFadeExtensions.kt new file mode 100644 index 00000000..2e96c104 --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/lux/extensions/ScrollFadeExtensions.kt @@ -0,0 +1,66 @@ +package com.lux.extensions + +import android.annotation.SuppressLint +import android.os.Build +import androidx.compose.foundation.ScrollState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.lux.theme.LuxTheme +import java.lang.Float.min + +@SuppressLint("ComposableModifierFactory") +@Composable +fun Modifier.fadingEdges( + scrollState: ScrollState, + topEdgeHeight: Dp = 0.dp, + bottomEdgeHeight: Dp = LuxTheme.spacing.spacing48, +): Modifier = this.then(Modifier + // adding layer fixes issue with blending gradient and content + .graphicsLayer { alpha = 0.99F } + .drawWithContent { + drawContent() + + val topColors = listOf(Color.Transparent, Color.Black) + val topStartY = scrollState.value.toFloat() + val topGradientHeight = min(topEdgeHeight.toPx(), topStartY) + val topGradientBrush = Brush.verticalGradient( + colors = topColors, startY = topStartY, endY = topStartY + topGradientHeight + ) + + val bottomColors = listOf(Color.Black, Color.Transparent) + val bottomEndY = size.height - scrollState.maxValue + scrollState.value + val bottomGradientHeight = + min(bottomEdgeHeight.toPx(), scrollState.maxValue.toFloat() - scrollState.value) + val bottomGradientBrush = Brush.verticalGradient( + colors = bottomColors, startY = bottomEndY - bottomGradientHeight, endY = bottomEndY + ) + + // Render gradient with blend mode on Android Q and above + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + drawRect( + brush = topGradientBrush, blendMode = BlendMode.DstIn + ) + if (bottomGradientHeight != 0f) { + drawRect( + brush = bottomGradientBrush, blendMode = BlendMode.DstIn + ) + } + // Otherwise, render gradient without blend mode if the blend mode is not supported + } else { + drawRect( + brush = topGradientBrush, + ) + if (bottomGradientHeight != 0f) { + drawRect( + brush = bottomGradientBrush + ) + } + } + }) diff --git a/apps/mobile/android/app/src/main/java/com/lux/notifications/SilentPushEventEmitterModule.kt b/apps/mobile/android/app/src/main/java/com/lux/notifications/SilentPushEventEmitterModule.kt new file mode 100644 index 00000000..fe57ec52 --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/lux/notifications/SilentPushEventEmitterModule.kt @@ -0,0 +1,129 @@ +package com.lux.notifications + +import android.util.Log +import androidx.annotation.Keep +import com.facebook.react.module.annotations.ReactModule +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactContextBaseJavaModule +import com.facebook.react.bridge.ReactMethod +import com.facebook.react.modules.core.DeviceEventManagerModule +import com.lux.utils.toWritableMap +import org.json.JSONObject + +@Keep +@ReactModule(name = SilentPushEventEmitterModule.MODULE_NAME) +class SilentPushEventEmitterModule( + reactContext: ReactApplicationContext +) : ReactContextBaseJavaModule(reactContext) { + + override fun getName() = MODULE_NAME + + override fun initialize() { + super.initialize() + instance = this + listenerCount = 0 + Log.d(TAG, "SilentPushEventEmitter initialized") + flushPendingEvents() + } + + override fun onCatalystInstanceDestroy() { + super.onCatalystInstanceDestroy() + if (instance === this) { + instance = null + } + listenerCount = 0 + } + + @ReactMethod + fun addListener(eventName: String) { + if (eventName != EVENT_NAME) { + return + } + listenerCount += 1 + flushPendingEvents() + } + + @ReactMethod + fun removeListeners(count: Int) { + if (count <= 0) { + return + } + listenerCount = (listenerCount - count).coerceAtLeast(0) + } + + private fun flushPendingEvents() { + if (!hasListeners()) { + return + } + + val events = synchronized(pendingPayloads) { + if (pendingPayloads.isEmpty()) { + null + } else { + val copy = ArrayList(pendingPayloads) + pendingPayloads.clear() + copy + } + } ?: return + + Log.d(TAG, "Flushing ${events.size} queued silent push events") + events.forEach { sendEvent(it) } + } + + private fun sendEvent(payload: JSONObject) { + if (!reactApplicationContext.hasActiveCatalystInstance()) { + synchronized(pendingPayloads) { + Log.d(TAG, "No active Catalyst instance; queueing payload: ${payload.toString()}") + pendingPayloads.add(JSONObject(payload.toString())) + } + return + } + + val map = payload.toWritableMap() + reactApplicationContext.runOnUiQueueThread { + if (!reactApplicationContext.hasActiveCatalystInstance()) { + synchronized(pendingPayloads) { + Log.d(TAG, "Catalyst inactive on UI thread; re-queueing payload: ${payload.toString()}") + pendingPayloads.add(JSONObject(payload.toString())) + } + return@runOnUiQueueThread + } + + Log.d(TAG, "Emitting silent push payload to JS: ${payload.toString()}") + reactApplicationContext + .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) + .emit(EVENT_NAME, map) + } + } + + private fun hasListeners(): Boolean = instance != null && listenerCount > 0 + + companion object { + const val MODULE_NAME = "SilentPushEventEmitter" + private const val EVENT_NAME = "SilentPushReceived" + private const val TAG = "SilentPushEmitter" + private val pendingPayloads = mutableListOf() + + @Volatile + private var instance: SilentPushEventEmitterModule? = null + + @Volatile + private var listenerCount: Int = 0 + + fun emitEvent(payload: JSONObject?) { + val eventPayload = payload?.let { JSONObject(it.toString()) } ?: JSONObject() + val currentInstance = instance + + if (currentInstance != null && currentInstance.hasListeners()) { + Log.d(TAG, "Sending silent push event to JS immediately: $eventPayload") + currentInstance.sendEvent(eventPayload) + return + } + + synchronized(pendingPayloads) { + Log.d(TAG, "Queueing silent push payload until listeners attach: $eventPayload") + pendingPayloads.add(eventPayload) + } + } + } +} diff --git a/apps/mobile/android/app/src/main/java/com/lux/notifications/SilentPushNotificationServiceExtension.kt b/apps/mobile/android/app/src/main/java/com/lux/notifications/SilentPushNotificationServiceExtension.kt new file mode 100644 index 00000000..3d1be185 --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/lux/notifications/SilentPushNotificationServiceExtension.kt @@ -0,0 +1,123 @@ +package com.lux.notifications + +import android.util.Log +import androidx.annotation.Keep +import com.onesignal.notifications.INotification +import com.onesignal.notifications.INotificationReceivedEvent +import com.onesignal.notifications.INotificationServiceExtension +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.json.JSONException +import org.json.JSONObject + +@Keep +class SilentPushNotificationServiceExtension : INotificationServiceExtension { + override fun onNotificationReceived(event: INotificationReceivedEvent) { + val notification = event.notification + val payload = buildPayload(notification) + + val hasContentAvailableFlag = hasContentAvailable(payload) + val isMissingVisibleContent = notification.isMissingVisibleContent() + + Log.d( + TAG, + "Notification received. hasContentAvailable=$hasContentAvailableFlag, " + + "missingVisibleContent=$isMissingVisibleContent, payload=$payload", + ) + + if (!hasContentAvailableFlag && !isMissingVisibleContent) { + return + } + + Log.d(TAG, "Emitting silent push event: $payload") + val payloadForEmission = try { + JSONObject(payload.toString()) + } catch (error: JSONException) { + Log.w(TAG, "Failed to clone payload for emission: ${error.message}") + payload + } + + CoroutineScope(Dispatchers.Default).launch { + withContext(Dispatchers.Main) { + SilentPushEventEmitterModule.emitEvent(payloadForEmission) + } + } + + if (isMissingVisibleContent) { + event.preventDefault() + } + } + + private fun INotification.isMissingVisibleContent(): Boolean { + val title: String? = this.title + val body: String? = this.body + return title.isNullOrBlank() && body.isNullOrBlank() + } + + private fun buildPayload(notification: INotification): JSONObject { + val rawPayload = notification.rawPayload + val payload = try { + if (rawPayload.isNullOrBlank()) JSONObject() else JSONObject(rawPayload) + } catch (error: JSONException) { + Log.w(TAG, "Failed parsing raw payload: ${error.message}") + JSONObject() + } + + notification.additionalData?.let { additionalData -> + try { + payload.put("additionalData", additionalData) + } catch (error: JSONException) { + Log.w(TAG, "Failed to append additional data: ${error.message}") + } + } + + return payload + } + + private fun hasContentAvailable(payload: JSONObject?): Boolean { + if (payload == null) { + return false + } + + if (payload.hasContentAvailableFlag()) { + return true + } + + val aps = payload.optJSONObject("aps") + if (aps != null && aps.hasContentAvailableFlag()) { + return true + } + + val additionalData = payload.optJSONObject("additionalData") + if (additionalData != null && additionalData.hasContentAvailableFlag()) { + return true + } + + return false + } + + private fun JSONObject.hasContentAvailableFlag(): Boolean { + return opt(CONTENT_AVAILABLE_UNDERSCORE).isTruthy() || opt(CONTENT_AVAILABLE_HYPHEN).isTruthy() + } + + private fun Any?.isTruthy(): Boolean { + return when (this) { + null, JSONObject.NULL -> false + is Boolean -> this + is Int -> this == 1 + is Long -> this == 1L + is Double -> this == 1.0 + is Float -> this == 1f + is String -> equals("1", ignoreCase = true) || equals("true", ignoreCase = true) + else -> false + } + } + + companion object { + private const val TAG = "SilentPushExt" + private const val CONTENT_AVAILABLE_UNDERSCORE = "content_available" + private const val CONTENT_AVAILABLE_HYPHEN = "content-available" + } +} diff --git a/apps/mobile/android/app/src/main/java/com/lux/onboarding/backup/MnemonicConfirmationViewManager.kt b/apps/mobile/android/app/src/main/java/com/lux/onboarding/backup/MnemonicConfirmationViewManager.kt new file mode 100644 index 00000000..1dde8460 --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/lux/onboarding/backup/MnemonicConfirmationViewManager.kt @@ -0,0 +1,98 @@ +package com.lux.onboarding.backup + +import android.view.View +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.ComposeView +import com.facebook.react.bridge.ReactContext +import com.facebook.react.uimanager.ThemedReactContext +import com.facebook.react.uimanager.ViewGroupManager +import com.facebook.react.uimanager.ViewManager +import com.facebook.react.uimanager.annotations.ReactProp +import com.facebook.react.uimanager.events.RCTEventEmitter +import com.lux.R +import com.lux.RnEthersRs +import com.lux.onboarding.backup.ui.MnemonicConfirmation +import com.lux.onboarding.backup.ui.MnemonicConfirmationViewModel +import com.lux.theme.LuxComponent +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update + +/** + * View manager used to import native component into React Native code + * for the MnemonicTest component used to test if user has saved their + * seed phrase + */ +class MnemonicConfirmationViewManager : ViewGroupManager() { + + override fun getName(): String = REACT_CLASS + + private val mnemonicIdFlow = MutableStateFlow("") + private val shouldShowSmallTextFlow = MutableStateFlow(false) + private val selectedWordPlaceholderFlow = MutableStateFlow("") + + override fun createViewInstance(reactContext: ThemedReactContext): ComposeView { + val ethersRs = RnEthersRs(reactContext) + val viewModel = MnemonicConfirmationViewModel(ethersRs) + + return ComposeView(reactContext).apply { + id = R.id.mnemonic_confirmation_compose_id // Needed for RN event emitter + + setContent { + val mnemonicId by mnemonicIdFlow.collectAsState() + val shouldShowSmallText by shouldShowSmallTextFlow.collectAsState() + val selectedWordPlaceholder by selectedWordPlaceholderFlow.collectAsState() + + viewModel.updatePlaceholder(selectedWordPlaceholder) + + LuxComponent { + MnemonicConfirmation( + mnemonicId = mnemonicId, + viewModel = viewModel, + shouldShowSmallText = shouldShowSmallText, + ) { + context as ReactContext + reactContext + .getJSModule(RCTEventEmitter::class.java) + .receiveEvent(id, EVENT_COMPLETED, null) // Sends event to RN bridge + } + } + } + } + } + + /** + * Maps local event name to expected RN prop. See RN [ViewManager] docs on github for schema. + * Using bubbling instead of direct events because overriding + * getExportedCustomDirectEventTypeConstants leads to a component not found error for some reason. + * Direct events will try find callback prop on native component, and bubbled events will bubble + * up until it finds component with the right callback prop. + */ + override fun getExportedCustomBubblingEventTypeConstants(): Map { + return mapOf( + EVENT_COMPLETED to mapOf( + "phasedRegistrationNames" to mapOf("bubbled" to EVENT_COMPLETED) + ) + ) + } + + @ReactProp(name = "mnemonicId") + fun setMnemonicId(view: View, mnemonicId: String) { + mnemonicIdFlow.update { mnemonicId } + } + + @ReactProp(name = "shouldShowSmallText") + fun setShouldShowSmallText(view: View, shouldShowSmallText: Boolean) { + shouldShowSmallTextFlow.update { shouldShowSmallText } + } + + @ReactProp(name = "selectedWordPlaceholder") + fun setSelectedWordPlaceholder(view: View, selectedWordPlaceholder: String) { + selectedWordPlaceholderFlow.update { selectedWordPlaceholder } + } + + companion object { + private const val REACT_CLASS = "MnemonicConfirmation" + private const val EVENT_COMPLETED = "onConfirmComplete" + } +} diff --git a/apps/mobile/android/app/src/main/java/com/lux/onboarding/backup/MnemonicDisplayViewManager.kt b/apps/mobile/android/app/src/main/java/com/lux/onboarding/backup/MnemonicDisplayViewManager.kt new file mode 100644 index 00000000..aebdb1c6 --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/lux/onboarding/backup/MnemonicDisplayViewManager.kt @@ -0,0 +1,121 @@ +package com.lux.onboarding.backup + +import android.view.View +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.ComposeView +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.WritableMap +import com.facebook.react.uimanager.ThemedReactContext +import com.facebook.react.uimanager.ViewGroupManager +import com.facebook.react.uimanager.ViewManager +import com.facebook.react.uimanager.annotations.ReactProp +import com.facebook.react.uimanager.events.RCTEventEmitter +import com.lux.RnEthersRs +import com.lux.onboarding.backup.ui.MnemonicDisplay +import com.lux.onboarding.backup.ui.MnemonicDisplayViewModel +import com.lux.theme.LuxComponent +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update + +/** + * View manager used to import native component into React Native code + * for the MnemonicDisplay component used to show the seed phrases + */ +class MnemonicDisplayViewManager : ViewGroupManager() { + + override fun getName(): String = REACT_CLASS + + private lateinit var context: ThemedReactContext + + private val mnemonicIdFlow = MutableStateFlow("") + private val copyTextFlow = MutableStateFlow("") + private val copiedTextFlow = MutableStateFlow("") + + override fun createViewInstance(reactContext: ThemedReactContext): ComposeView { + context = reactContext + val ethersRs = RnEthersRs(reactContext) + val viewModel = MnemonicDisplayViewModel(ethersRs) + + return ComposeView(reactContext).apply { + setContent { + val mnemonicId by mnemonicIdFlow.collectAsState() + val copyText by copyTextFlow.collectAsState() + val copiedText by copiedTextFlow.collectAsState() + + LuxComponent { + MnemonicDisplay( + mnemonicId = mnemonicId, + viewModel = viewModel, + copyText = copyText, + copiedText = copiedText, + onHeightMeasured = { + val bundle = Arguments.createMap().apply { + putDouble(FIELD_HEIGHT, it.toDouble()) + } + sendEvent(id, EVENT_HEIGHT_MEASURED, bundle) + }, + onEmptyMnemonic = { + val bundle = Arguments.createMap().apply { + putString("mnemonicId", it) + } + sendEvent(id, EVENT_EMPTY_MNEMONIC, bundle) + } + ) + } + } + } + } + + /** + * Maps local event name to expected RN prop. See RN [ViewManager] docs on github for schema. + * Using bubbling instead of direct events because overriding + * getExportedCustomDirectEventTypeConstants leads to a component not found error for some reason. + * Direct events will try find callback prop on native component, and bubbled events will bubble + * up until it finds component with the right callback prop. + */ + override fun getExportedCustomBubblingEventTypeConstants(): Map { + return mapOf( + EVENT_HEIGHT_MEASURED to mapOf( + "phasedRegistrationNames" to mapOf( + "bubbled" to EVENT_HEIGHT_MEASURED, + "captured" to EVENT_HEIGHT_MEASURED + ) + ), + EVENT_EMPTY_MNEMONIC to mapOf( + "phasedRegistrationNames" to mapOf( + "bubbled" to EVENT_EMPTY_MNEMONIC, + "captured" to EVENT_EMPTY_MNEMONIC + ) + ) + ) + } + + private fun sendEvent(id: Int, eventName: String, bundle: WritableMap? = null) { + context + .getJSModule(RCTEventEmitter::class.java) + .receiveEvent(id, eventName, bundle) + } + + @ReactProp(name = "mnemonicId") + fun setMnemonicId(view: View, mnemonicId: String) { + mnemonicIdFlow.update { mnemonicId } + } + + @ReactProp(name = "copyText") + fun setCopyText(view: View, copyText: String) { + copyTextFlow.update { copyText } + } + + @ReactProp(name = "copiedText") + fun setCopiedText(view: View, copiedText: String) { + copiedTextFlow.update { copiedText } + } + + companion object { + private const val REACT_CLASS = "MnemonicDisplay" + private const val EVENT_HEIGHT_MEASURED = "onHeightMeasured" + private const val EVENT_EMPTY_MNEMONIC = "onEmptyMnemonic" + private const val FIELD_HEIGHT = "height" + } +} diff --git a/apps/mobile/android/app/src/main/java/com/lux/onboarding/backup/ui/MnemonicConfirmation.kt b/apps/mobile/android/app/src/main/java/com/lux/onboarding/backup/ui/MnemonicConfirmation.kt new file mode 100644 index 00000000..bbc4ec7a --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/lux/onboarding/backup/ui/MnemonicConfirmation.kt @@ -0,0 +1,64 @@ +package com.lux.onboarding.backup.ui + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import com.lux.theme.LuxTheme + +/** + * Renders screen for confirming that a user wrote down their seed phrase + * by testing they can select it in order + */ +@Composable +fun MnemonicConfirmation( + viewModel: MnemonicConfirmationViewModel, + mnemonicId: String, + shouldShowSmallText: Boolean, + onCompleted: () -> Unit, +) { + + val displayedWords by viewModel.selectedWords.collectAsState() + val wordBankList by viewModel.wordBankList.collectAsState() + val completed by viewModel.completed.collectAsState() + + LaunchedEffect(mnemonicId) { + viewModel.setup(mnemonicId) + } + + LaunchedEffect(completed) { + if (completed) { + onCompleted() + } + } + + Column( + modifier = Modifier + .fillMaxSize() + ) { + Column( + modifier = Modifier + .weight(1f, fill = true) + .verticalScroll(rememberScrollState()) + ) { + MnemonicWordsGroup( + words = displayedWords, + shouldShowSmallText = shouldShowSmallText, + ) + } + + MnemonicWordBank(words = wordBankList, shouldShowSmallText = shouldShowSmallText) { + viewModel.handleWordBankClick(it.index) + } + } +} diff --git a/apps/mobile/android/app/src/main/java/com/lux/onboarding/backup/ui/MnemonicConfirmationViewModel.kt b/apps/mobile/android/app/src/main/java/com/lux/onboarding/backup/ui/MnemonicConfirmationViewModel.kt new file mode 100644 index 00000000..0c6ef969 --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/lux/onboarding/backup/ui/MnemonicConfirmationViewModel.kt @@ -0,0 +1,135 @@ +package com.lux.onboarding.backup.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.lux.RnEthersRs +import com.lux.onboarding.backup.ui.model.MnemonicInputStatus +import com.lux.onboarding.backup.ui.model.MnemonicWordBankCellUiState +import com.lux.onboarding.backup.ui.model.MnemonicWordUiState +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update + +class MnemonicConfirmationViewModel( + private val ethersRs: RnEthersRs, // Move to repository layer if app gets more complex +) : ViewModel() { + private val defaultMnemonicsCount = 12 + + private var sourceWords = List(defaultMnemonicsCount) { "" } + private var shuffledWords = emptyList() + private val focusedIndex = MutableStateFlow(0) + private val selectedWordsIndexes = + MutableStateFlow>(List(defaultMnemonicsCount) { null }) + private val selectedWordPlaceholderFlow = MutableStateFlow("") + + val selectedWords: StateFlow> = + combine(selectedWordsIndexes, selectedWordPlaceholderFlow, focusedIndex) { _, placeholder, focusedIndex -> + List(sourceWords.size) { index -> + getMnemonicWordUiState(index, placeholder, focusedIndex) + } + }.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) + + val wordBankList: StateFlow> = + selectedWordsIndexes.map { selectedWordsIndexes -> + shuffledWords.mapIndexed { index, word -> + MnemonicWordBankCellUiState( + index = index, + text = word, + used = selectedWordsIndexes.contains(index), + ) + } + }.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) + + private val _completed = MutableStateFlow(false) + val completed = _completed.asStateFlow() + + private var currentMnemonicId = "" + + fun setup(mnemonicId: String) { + if (mnemonicId.isNotEmpty() && mnemonicId != currentMnemonicId) { + currentMnemonicId = mnemonicId + reset() + + ethersRs.retrieveMnemonic(mnemonicId)?.let { mnemonic -> + val words = mnemonic.split(" ") + sourceWords = words + shuffledWords = words.shuffled() + selectedWordsIndexes.update { List(words.size) { null } } + } + } + } + + private fun reset() { + sourceWords = List(defaultMnemonicsCount) { "" } + shuffledWords = emptyList() + focusedIndex.update { 0 } + selectedWordsIndexes.update { emptyList() } + _completed.update { false } + } + + fun updatePlaceholder(newPlaceholder: String) { + selectedWordPlaceholderFlow.value = newPlaceholder + } + + fun handleWordBankClick(wordBankIndex: Int) { + + selectedWordsIndexes.update { indexes -> + val updatedIndexes = indexes.toMutableList() + updatedIndexes[focusedIndex.value] = wordBankIndex + updatedIndexes + } + + if (focusedIndex.value == sourceWords.size - 1) { + checkIfCompleted() + } else if (sourceWords[focusedIndex.value] == shuffledWords[wordBankIndex] && focusedIndex.value < sourceWords.size - 1) { + focusedIndex.update { it + 1 } + } + } + + private fun checkIfCompleted() { + if (selectedWordsIndexes.value.size != sourceWords.size) { + return + } + + for (i in selectedWordsIndexes.value.indices) { + val selectedWord = getSelectedWord(i) + if (sourceWords[i].isEmpty() || selectedWord != sourceWords[i]) { + return + } + } + + _completed.update { true } + } + + private fun getSelectedWord(displayIndex: Int): String { + return selectedWordsIndexes.value.getOrNull(displayIndex)?.let { shuffledWords.getOrNull(it) } + ?: "" + } + + private fun getMnemonicWordUiState( + displayIndex: Int, + placeholderText: String, + focusedIndex: Int, + ): MnemonicWordUiState { + val selectedWord = getSelectedWord(displayIndex) + var status = MnemonicInputStatus.CORRECT_INPUT + + if (selectedWord.isEmpty()) { + status = MnemonicInputStatus.NO_INPUT + } else if (selectedWord != sourceWords[displayIndex]) { + status = MnemonicInputStatus.WRONG_INPUT + } + + return MnemonicWordUiState( + num = displayIndex + 1, + text = selectedWord.ifEmpty { placeholderText }, + status = status, + isActive = displayIndex == focusedIndex, + ) + } +} diff --git a/apps/mobile/android/app/src/main/java/com/lux/onboarding/backup/ui/MnemonicDisplay.kt b/apps/mobile/android/app/src/main/java/com/lux/onboarding/backup/ui/MnemonicDisplay.kt new file mode 100644 index 00000000..49059e86 --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/lux/onboarding/backup/ui/MnemonicDisplay.kt @@ -0,0 +1,94 @@ +package com.lux.onboarding.backup.ui + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.unit.dp +import com.lux.onboarding.shared.CopyButton +import com.lux.theme.relativeOffset +import kotlinx.coroutines.withTimeoutOrNull +import kotlin.math.abs + +@Composable +fun MnemonicDisplay( + viewModel: MnemonicDisplayViewModel, + mnemonicId: String, + copyText: String, + copiedText: String, + onHeightMeasured: (height: Float) -> Unit, + onEmptyMnemonic: (mnemonicId: String) -> Unit +) { + val words by viewModel.words.collectAsState() + val textToCopy = AnnotatedString(words.joinToString(" ") { it.text }) + val density = LocalDensity.current.density + var buttonOffset by remember { mutableStateOf(20.dp) } + + LaunchedEffect(mnemonicId) { + viewModel.setup(mnemonicId) + + // Check and log if the mnemonic is empty after 1 second to avoid calling onEmptyMnemonic too early + withTimeoutOrNull(1000L) { + viewModel.words.collect { currentWords -> + if (currentWords.isEmpty() || currentWords.any { it.text.isBlank() }) { + onEmptyMnemonic(mnemonicId) + return@collect + } + } + } + } + + BoxWithConstraints { + Column( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight() + .verticalScroll(rememberScrollState()) + .onSizeChanged { size -> + onHeightMeasured(size.height / density) + } + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight() + .padding(top = buttonOffset) + .wrapContentSize(Alignment.Center) + ) { + MnemonicWordsGroup(words = words) + + Box( + modifier = Modifier + .align(Alignment.TopCenter) + .relativeOffset(y = -0.5f) { _, offsetY -> + buttonOffset = (abs(offsetY) / density).dp + } + ) { + CopyButton( + copyButtonText = copyText, + copiedButtonText = copiedText, + textToCopy = textToCopy, + isSensitive = true + ) + } + } + } + } +} diff --git a/apps/mobile/android/app/src/main/java/com/lux/onboarding/backup/ui/MnemonicDisplayViewModel.kt b/apps/mobile/android/app/src/main/java/com/lux/onboarding/backup/ui/MnemonicDisplayViewModel.kt new file mode 100644 index 00000000..f203e550 --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/lux/onboarding/backup/ui/MnemonicDisplayViewModel.kt @@ -0,0 +1,42 @@ +package com.lux.onboarding.backup.ui + +import androidx.lifecycle.ViewModel +import com.lux.RnEthersRs +import com.lux.onboarding.backup.ui.model.MnemonicWordUiState +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +class MnemonicDisplayViewModel( + private val ethersRs: RnEthersRs // Move to repository layer if app gets more complex +) : ViewModel() { + private val defaultMnemonicsCount = 12 + private val _words = + MutableStateFlow(List(defaultMnemonicsCount) { MnemonicWordUiState(num = it + 1, text = "") }) + val words = _words.asStateFlow() + + private var currentMnemonicId = "" + + fun setup(mnemonicId: String) { + if (mnemonicId.isNotEmpty() && mnemonicId != currentMnemonicId) { + currentMnemonicId = mnemonicId + reset() + + ethersRs.retrieveMnemonic(mnemonicId)?.let { mnemonic -> + val phraseList = mnemonic.split(" ") + _words.update { + phraseList.mapIndexed { index, phrase -> + MnemonicWordUiState( + num = index + 1, + text = phrase, + ) + } + } + } + } + } + + private fun reset() { + _words.update { emptyList() } + } +} diff --git a/apps/mobile/android/app/src/main/java/com/lux/onboarding/backup/ui/MnemonicWordBank.kt b/apps/mobile/android/app/src/main/java/com/lux/onboarding/backup/ui/MnemonicWordBank.kt new file mode 100644 index 00000000..74df2261 --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/lux/onboarding/backup/ui/MnemonicWordBank.kt @@ -0,0 +1,84 @@ +package com.lux.onboarding.backup.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.unit.dp +import com.google.accompanist.flowlayout.FlowRow +import com.google.accompanist.flowlayout.MainAxisAlignment +import com.lux.onboarding.backup.ui.model.MnemonicWordBankCellUiState +import com.lux.theme.LuxTheme + +/** + * Used to render a set of clickable mnemonic words + */ +@Composable +fun MnemonicWordBank( + words: List, + shouldShowSmallText: Boolean = false, + onClick: (word: MnemonicWordBankCellUiState) -> Unit +) { + FlowRow( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight() + .padding( + vertical = LuxTheme.spacing.spacing16, + horizontal = LuxTheme.spacing.spacing8 + ), + mainAxisSpacing = if (shouldShowSmallText) LuxTheme.spacing.spacing4 else LuxTheme.spacing.spacing8, + crossAxisSpacing = if (shouldShowSmallText) LuxTheme.spacing.spacing4 else LuxTheme.spacing.spacing8, + mainAxisAlignment = MainAxisAlignment.Center, + ) { + words.forEach { + MnemonicWordBankCell(word = it, shouldShowSmallText = shouldShowSmallText) { + onClick(it) + } + } + } +} + +@Composable +private fun MnemonicWordBankCell( + word: MnemonicWordBankCellUiState, + shouldShowSmallText: Boolean, + onClick: () -> Unit +) { + val textStyle = + if (shouldShowSmallText) LuxTheme.typography.body3 else LuxTheme.typography.body2 + val verticalPadding = + if (shouldShowSmallText) LuxTheme.spacing.spacing8 else 10.dp + val horizontalPadding = + if (shouldShowSmallText) 10.dp else LuxTheme.spacing.spacing12 + + Box( + modifier = Modifier + .shadow( + 10.dp, + spotColor = LuxTheme.colors.black.copy(alpha = 0.04f), + shape = LuxTheme.shapes.xlarge + ) + ) { + Box(modifier = Modifier + .clip(shape = LuxTheme.shapes.xlarge) + .then(Modifier.border(1.dp, LuxTheme.colors.surface3, LuxTheme.shapes.xlarge)) + .clickable { onClick() } + .background(color = LuxTheme.colors.surface1) + .padding(vertical = verticalPadding, horizontal = horizontalPadding)) { + Text( + text = word.text, + style = textStyle, + color = LuxTheme.colors.neutral1.copy(if (word.used) 0.5f else 1f), + ) + } + } +} diff --git a/apps/mobile/android/app/src/main/java/com/lux/onboarding/backup/ui/MnemonicWordCell.kt b/apps/mobile/android/app/src/main/java/com/lux/onboarding/backup/ui/MnemonicWordCell.kt new file mode 100644 index 00000000..040ae6ec --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/lux/onboarding/backup/ui/MnemonicWordCell.kt @@ -0,0 +1,64 @@ +package com.lux.onboarding.backup.ui + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.foundation.relocation.bringIntoViewRequester +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.text.style.TextOverflow +import com.lux.onboarding.backup.ui.model.MnemonicInputStatus +import com.lux.onboarding.backup.ui.model.MnemonicWordUiState +import com.lux.theme.LuxTheme + +/** + * Component used to display a single word as part of an overall seed phrase + */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun MnemonicWordCell( + word: MnemonicWordUiState, + shouldShowSmallText: Boolean = false, +) { + val bringIntoViewRequester = remember { BringIntoViewRequester() } + + LaunchedEffect(word.isActive) { + // When a cell status changes, request to bring it into view in the parent scroll container + if (word.isActive){ + bringIntoViewRequester.bringIntoView() + } + } + + val textStyle = + if (shouldShowSmallText) LuxTheme.typography.body3 else LuxTheme.typography.body2 + + val textColor = when (word.status) { + MnemonicInputStatus.NO_INPUT -> LuxTheme.colors.neutral3 + MnemonicInputStatus.CORRECT_INPUT -> LuxTheme.colors.neutral1 + MnemonicInputStatus.WRONG_INPUT -> LuxTheme.colors.statusCritical + } + + Row(modifier = Modifier.bringIntoViewRequester(bringIntoViewRequester)) { + Text( + text = "${word.num}", + color = LuxTheme.colors.neutral2, + modifier = Modifier.defaultMinSize(minWidth = if (shouldShowSmallText) 14.dp else 16.dp), + style = textStyle, + ) + Spacer(modifier = Modifier.width(LuxTheme.spacing.spacing16)) + Text( + text = word.text, + style = textStyle, + color = textColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } +} diff --git a/apps/mobile/android/app/src/main/java/com/lux/onboarding/backup/ui/MnemonicWordsColumn.kt b/apps/mobile/android/app/src/main/java/com/lux/onboarding/backup/ui/MnemonicWordsColumn.kt new file mode 100644 index 00000000..a0af3f7d --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/lux/onboarding/backup/ui/MnemonicWordsColumn.kt @@ -0,0 +1,30 @@ +package com.lux.onboarding.backup.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.lux.onboarding.backup.ui.model.MnemonicWordUiState +import com.lux.theme.LuxTheme + +/** + * Component used to display a numbered column of words as part of an overall seed phrase + */ +@Composable +fun MnemonicWordsColumn( + modifier: Modifier = Modifier, + words: List, + shouldShowSmallText: Boolean = false, +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(LuxTheme.spacing.spacing8), + ) { + words.forEach { word -> + MnemonicWordCell( + word = word, + shouldShowSmallText = shouldShowSmallText, + ) + } + } +} diff --git a/apps/mobile/android/app/src/main/java/com/lux/onboarding/backup/ui/MnemonicWordsGroup.kt b/apps/mobile/android/app/src/main/java/com/lux/onboarding/backup/ui/MnemonicWordsGroup.kt new file mode 100644 index 00000000..64577863 --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/lux/onboarding/backup/ui/MnemonicWordsGroup.kt @@ -0,0 +1,56 @@ +package com.lux.onboarding.backup.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.lux.onboarding.backup.ui.model.MnemonicWordUiState +import com.lux.theme.LuxTheme + +/** + * View used to display mnemonic words for wallet seed phrase + */ +@Composable +fun MnemonicWordsGroup( + modifier: Modifier = Modifier, + words: List, + columnCount: Int = DEFAULT_COLUMN_COUNT, + shouldShowSmallText: Boolean = false, +) { + Row( + modifier = modifier + .fillMaxWidth() + .wrapContentHeight() + .background(color = LuxTheme.colors.surface2, shape = RoundedCornerShape(20.dp)) + .border( + width = 1.dp, + color = LuxTheme.colors.surface3, + shape = RoundedCornerShape(20.dp), + ) + .padding( + vertical = LuxTheme.spacing.spacing24, + horizontal = LuxTheme.spacing.spacing32 + ), + horizontalArrangement = Arrangement.spacedBy(LuxTheme.spacing.spacing8) + ) { + val size = words.size / columnCount + for (i in 0 until columnCount) { + val starting = i * size + val ending = (i + 1) * size + MnemonicWordsColumn( + modifier = Modifier.weight(1f), + words = words.subList(starting, ending), + shouldShowSmallText = shouldShowSmallText, + ) + } + } +} + +private const val DEFAULT_COLUMN_COUNT = 2 diff --git a/apps/mobile/android/app/src/main/java/com/lux/onboarding/backup/ui/model/MnemonicWordBankCellUiState.kt b/apps/mobile/android/app/src/main/java/com/lux/onboarding/backup/ui/model/MnemonicWordBankCellUiState.kt new file mode 100644 index 00000000..626fcd8d --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/lux/onboarding/backup/ui/model/MnemonicWordBankCellUiState.kt @@ -0,0 +1,7 @@ +package com.lux.onboarding.backup.ui.model + +data class MnemonicWordBankCellUiState( + val index: Int, + val text: String, + val used: Boolean = false, +) diff --git a/apps/mobile/android/app/src/main/java/com/lux/onboarding/backup/ui/model/MnemonicWordUiState.kt b/apps/mobile/android/app/src/main/java/com/lux/onboarding/backup/ui/model/MnemonicWordUiState.kt new file mode 100644 index 00000000..83754998 --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/lux/onboarding/backup/ui/model/MnemonicWordUiState.kt @@ -0,0 +1,14 @@ +package com.lux.onboarding.backup.ui.model + +enum class MnemonicInputStatus { + NO_INPUT, + CORRECT_INPUT, + WRONG_INPUT, +} + +data class MnemonicWordUiState( + val num: Int, + val text: String, + val status: MnemonicInputStatus = MnemonicInputStatus.CORRECT_INPUT, + val isActive: Boolean = false +) diff --git a/apps/mobile/android/app/src/main/java/com/lux/onboarding/import/SeedPhraseInput.kt b/apps/mobile/android/app/src/main/java/com/lux/onboarding/import/SeedPhraseInput.kt new file mode 100644 index 00000000..cb09ea8e --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/lux/onboarding/import/SeedPhraseInput.kt @@ -0,0 +1,188 @@ +package com.lux.onboarding.import + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.ContentAlpha +import androidx.compose.material.Icon +import androidx.compose.material.LocalContentColor +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.focus.focusRequester +import com.lux.R +import com.lux.onboarding.import.SeedPhraseInputViewModel.MnemonicError.InvalidPhrase +import com.lux.onboarding.import.SeedPhraseInputViewModel.MnemonicError.InvalidWord +import com.lux.onboarding.import.SeedPhraseInputViewModel.MnemonicError.NotEnoughWords +import com.lux.onboarding.import.SeedPhraseInputViewModel.MnemonicError.TooManyWords +import com.lux.onboarding.import.SeedPhraseInputViewModel.MnemonicError.WrongRecoveryPhrase +import com.lux.onboarding.import.SeedPhraseInputViewModel.MnemonicError.WordIsAddress +import com.lux.onboarding.import.SeedPhraseInputViewModel.Status.Error +import com.lux.onboarding.import.SeedPhraseInputViewModel.Status.Valid +import com.lux.onboarding.shared.PasteButton +import com.lux.theme.LuxTheme +import com.lux.theme.relativeOffset +import kotlin.math.abs + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun SeedPhraseInput( + viewModel: SeedPhraseInputViewModel +) { + val focusRequester = remember { FocusRequester() } + val density = LocalDensity.current.density + var buttonOffset by remember { mutableStateOf(20.dp) } + val keyboardController = LocalSoftwareKeyboardController.current + + LaunchedEffect(viewModel.isFocused) { + if (viewModel.isFocused) { + focusRequester.requestFocus() + } else { + focusRequester.freeFocus() + keyboardController?.hide() + } + } + + Column( + modifier = Modifier.wrapContentHeight(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight() + .padding(bottom = buttonOffset) + ) { + BasicTextField( + modifier = Modifier + .fillMaxWidth() + .clip(LuxTheme.shapes.small) + .background(LuxTheme.colors.surface1) + .border( + width = 1.dp, + shape = LuxTheme.shapes.small, + color = mapStatusToBorderColor(viewModel.status), + ) + .focusRequester(focusRequester), + value = viewModel.input, + onValueChange = { viewModel.handleInputChange(it) }, + cursorBrush = SolidColor(LocalContentColor.current.copy(ContentAlpha.high)), + textStyle = LuxTheme.typography.subheading2.copy( + textAlign = TextAlign.Start, + color = LuxTheme.colors.neutral1 + ), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + capitalization = KeyboardCapitalization.None + ), + decorationBox = { innerTextField -> + Box( + modifier = Modifier + .wrapContentHeight() + .heightIn(min = 120.dp) + .padding(20.dp), + ) { + if (viewModel.input.text.isEmpty()) { + Text( + text = viewModel.rnStrings.inputPlaceholder, + style = LuxTheme.typography.subheading2.copy( + color = LuxTheme.colors.neutral3 + ) + ) + } + innerTextField() + } + } + ) + + if (viewModel.input.text.isEmpty()) { + PasteButton( + modifier = Modifier + .align(Alignment.BottomCenter) + .relativeOffset(y = .5f) { _, offsetY -> + buttonOffset = (abs(offsetY) / density).dp + }, + pasteButtonText = viewModel.rnStrings.pasteButton, + onPaste = { + viewModel.handleInputChange( + TextFieldValue(it, selection = TextRange(it.length)) + ) + focusRequester.requestFocus() + } + ) + } + } + + SeedPhraseError(viewModel) + } +} + +@Composable +private fun SeedPhraseError(viewModel: SeedPhraseInputViewModel) { + val status = viewModel.status + val rnStrings = viewModel.rnStrings + var text = "" + + if (status is Error) { + text = when (val error = status.error) { + is InvalidWord -> "${rnStrings.errorInvalidWord} ${error.word}" + is NotEnoughWords, TooManyWords -> rnStrings.errorPhraseLength + is WrongRecoveryPhrase -> rnStrings.errorWrongPhrase + is InvalidPhrase -> rnStrings.errorInvalidPhrase + is WordIsAddress -> rnStrings.errorWordIsAddress + } + } + + Row( + horizontalArrangement = Arrangement.spacedBy(LuxTheme.spacing.spacing4), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.alpha(if (text.isEmpty()) 0f else 1f) + ) { + Icon( + painter = painterResource(id = R.drawable.lux_icon_alert_triangle), + tint = LuxTheme.colors.statusCritical, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + Text(text, style = LuxTheme.typography.body3, color = LuxTheme.colors.statusCritical) + } +} + +@Composable +private fun mapStatusToBorderColor(status: SeedPhraseInputViewModel.Status): Color = + when (status) { + Valid -> LuxTheme.colors.statusSuccess + is Error -> LuxTheme.colors.statusCritical + else -> LuxTheme.colors.surface3 + } diff --git a/apps/mobile/android/app/src/main/java/com/lux/onboarding/import/SeedPhraseInputViewManager.kt b/apps/mobile/android/app/src/main/java/com/lux/onboarding/import/SeedPhraseInputViewManager.kt new file mode 100644 index 00000000..f73d2374 --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/lux/onboarding/import/SeedPhraseInputViewManager.kt @@ -0,0 +1,178 @@ +package com.lux.onboarding.import + +import android.view.View +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalDensity +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.ReadableArray +import com.facebook.react.bridge.ReadableMap +import com.facebook.react.bridge.WritableMap +import com.facebook.react.uimanager.ThemedReactContext +import com.facebook.react.uimanager.ViewGroupManager +import com.facebook.react.uimanager.ViewManager +import com.facebook.react.uimanager.annotations.ReactProp +import com.facebook.react.uimanager.events.RCTEventEmitter +import com.lux.R +import com.lux.RnEthersRs +import com.lux.theme.LuxComponent +import kotlinx.coroutines.flow.MutableStateFlow + + +/** + * View manager used to import native component into React Native code + * for the MnemonicDisplay component used to show the seed phrases + */ +class SeedPhraseInputViewManager : ViewGroupManager() { + + override fun getName(): String = REACT_CLASS + + private lateinit var viewModel: SeedPhraseInputViewModel + private lateinit var context: ThemedReactContext + + private var rnStrings = MutableStateFlow(emptyMap()) + + override fun createViewInstance(reactContext: ThemedReactContext): ComposeView { + context = reactContext + val ethersRs = RnEthersRs(reactContext) + + return ComposeView(reactContext).apply { + id = R.id.seed_phrase_input_compose_id + viewModel = SeedPhraseInputViewModel( + ethersRs, + onInputValidated = { + val bundle = Arguments.createMap().apply { + putBoolean(FIELD_CAN_SUBMIT, it) + } + sendEvent(id, EVENT_INPUT_VALIDATED, bundle) + }, + onMnemonicStored = { + val bundle = Arguments.createMap().apply { + putString(FIELD_MNEMONIC_ID, it) + } + sendEvent(id, EVENT_MNEMONIC_STORED, bundle) + }, + onSubmitError = { + sendEvent(id, EVENT_SUBMIT_ERROR) + } + ) + + setContent { + val density = LocalDensity.current.density + + BoxWithConstraints { + LuxComponent( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight(unbounded = true) + .align(Alignment.TopCenter) + .onSizeChanged { + val bundle = Arguments + .createMap() + .apply { + putDouble(FIELD_HEIGHT, it.height.toDouble() / density) + } + sendEvent(id, EVENT_HEIGHT_MEASURED, bundle) + }) { + SeedPhraseInput(viewModel) + } + } + } + } + } + + /** + * Maps local event name to expected RN prop. See RN [ViewManager] docs on github for schema. + * Using bubbling instead of direct events because overriding + * getExportedCustomDirectEventTypeConstants leads to a component not found error for some reason. + * Direct events will try find callback prop on native component, and bubbled events will bubble + * up until it finds component with the right callback prop. + */ + override fun getExportedCustomBubblingEventTypeConstants(): Map { + return mapOf( + EVENT_INPUT_VALIDATED to mapOf( + "phasedRegistrationNames" to mapOf( + "bubbled" to EVENT_INPUT_VALIDATED, + "captured" to EVENT_INPUT_VALIDATED + ) + ), + EVENT_MNEMONIC_STORED to mapOf( + "phasedRegistrationNames" to mapOf( + "bubbled" to EVENT_MNEMONIC_STORED, + "captured" to EVENT_MNEMONIC_STORED + ) + ), + EVENT_HEIGHT_MEASURED to mapOf( + "phasedRegistrationNames" to mapOf( + "bubbled" to EVENT_HEIGHT_MEASURED, + "captured" to EVENT_HEIGHT_MEASURED + ) + ), + EVENT_SUBMIT_ERROR to mapOf( + "phasedRegistrationNames" to mapOf( + "bubbled" to EVENT_SUBMIT_ERROR, + "captured" to EVENT_SUBMIT_ERROR + ) + ), + ) + } + + private fun sendEvent(id: Int, eventName: String, bundle: WritableMap? = null) { + context + .getJSModule(RCTEventEmitter::class.java) + .receiveEvent(id, eventName, bundle) + } + + override fun receiveCommand(root: ComposeView, commandId: String?, args: ReadableArray?) { + super.receiveCommand(root, commandId, args) + when (commandId) { + COMMAND_HANDLE_SUBMIT -> { + viewModel.handleSubmit() + } + COMMAND_FOCUS -> { + viewModel.focus() + } + COMMAND_BLUR -> { + viewModel.blur() + } + else -> Unit + } + } + + @ReactProp(name = "targetMnemonicId") + fun setTargetMnemonicId(view: View, mnemonicId: String?) { + viewModel.targetMnemonicId = mnemonicId + } + + @ReactProp(name = "strings") + fun setStrings(view: View, strings: ReadableMap) { + viewModel.rnStrings = SeedPhraseInputViewModel.ReactNativeStrings( + inputPlaceholder = strings.getString("inputPlaceholder") ?: "", + pasteButton = strings.getString("pasteButton") ?: "", + errorInvalidWord = strings.getString("errorInvalidWord") ?: "", + errorPhraseLength = strings.getString("errorPhraseLength") ?: "", + errorWrongPhrase = strings.getString("errorWrongPhrase") ?: "", + errorInvalidPhrase = strings.getString("errorInvalidPhrase") ?: "", + errorWordIsAddress = strings.getString("errorWordIsAddress") ?: "", + ) + } + + companion object { + private const val REACT_CLASS = "SeedPhraseInput" + private const val EVENT_INPUT_VALIDATED = "onInputValidated" + private const val EVENT_MNEMONIC_STORED = "onMnemonicStored" + private const val EVENT_HEIGHT_MEASURED = "onHeightMeasured" + private const val EVENT_SUBMIT_ERROR = "onSubmitError" + private const val COMMAND_HANDLE_SUBMIT = "handleSubmit" + private const val COMMAND_FOCUS = "focus" + private const val COMMAND_BLUR = "blur" + private const val FIELD_MNEMONIC_ID = "mnemonicId" + private const val FIELD_CAN_SUBMIT = "canSubmit" + private const val FIELD_HEIGHT = "height" + } +} diff --git a/apps/mobile/android/app/src/main/java/com/lux/onboarding/import/SeedPhraseInputViewModel.kt b/apps/mobile/android/app/src/main/java/com/lux/onboarding/import/SeedPhraseInputViewModel.kt new file mode 100644 index 00000000..7bf46028 --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/lux/onboarding/import/SeedPhraseInputViewModel.kt @@ -0,0 +1,187 @@ +package com.lux.onboarding.import + +import android.util.Log +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.text.input.TextFieldValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.lux.EthersRs +import com.lux.RnEthersRs +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +class SeedPhraseInputViewModel( + private val ethersRs: RnEthersRs, + private val onInputValidated: (canSubmit: Boolean) -> Unit, + private val onMnemonicStored: (mnemonicId: String) -> Unit, + private val onSubmitError: () -> Unit, +) : ViewModel() { + + sealed interface Status { + object None : Status + object Valid : Status + class Error(val error: MnemonicError) : Status + } + + sealed interface MnemonicError { + class InvalidWord(val word: String) : MnemonicError + object TooManyWords : MnemonicError + object NotEnoughWords : MnemonicError + object WrongRecoveryPhrase : MnemonicError + object InvalidPhrase : MnemonicError + object WordIsAddress : MnemonicError + } + + data class ReactNativeStrings( + val inputPlaceholder: String, + val pasteButton: String, + val errorInvalidWord: String, + val errorPhraseLength: String, + val errorWrongPhrase: String, + val errorInvalidPhrase: String, + val errorWordIsAddress: String, + ) + + // Sourced externally from RN + var targetMnemonicId by mutableStateOf(null) + var rnStrings by mutableStateOf( + ReactNativeStrings( + inputPlaceholder = "", + pasteButton = "", + errorInvalidWord = "", + errorPhraseLength = "", + errorWrongPhrase = "", + errorInvalidPhrase = "", + errorWordIsAddress = "", + ) + ) + + var input by mutableStateOf(TextFieldValue("")) + private set + var status by mutableStateOf(Status.None) + private set + private var validateLastWordTimeout: Long = 1000 + private var validateLastWordJob: Job? = null + + var isFocused by mutableStateOf(false) + private set + + fun focus() { + isFocused = true + } + + fun blur() { + isFocused = false + } + + fun handleInputChange(value: TextFieldValue) { + input = value + val normalized = normalizeInput(value) + + val skipLastWord = normalized.lastOrNull() != ' ' + val skipInvalidWord = skipLastWord && !isAddress(normalized) + validateInput(normalized, skipInvalidWord) + + validateLastWordJob?.cancel() + + validateLastWordJob = viewModelScope.launch(Dispatchers.Default) { + delay(validateLastWordTimeout) + validateInput(normalized, false) + } + } + + private fun validateInput(normalizedInput: String, skipInvalidWord: Boolean) { + val mnemonic = normalizedInput.trim() + val words = mnemonic.split(" ") + + val prevStatus = status + val isValidLength = words.size in MIN_LENGTH..MAX_LENGTH + val firstInvalidWord = EthersRs.findInvalidWord(mnemonic) + val isFirstWordInvalid = firstInvalidWord == words.last() && skipInvalidWord + val isInvalidLengthError = + prevStatus is Status.Error && (prevStatus.error == MnemonicError.NotEnoughWords || prevStatus.error == MnemonicError.TooManyWords) && !isValidLength + + if (isFirstWordInvalid) { + return + } + + status = + if (isAddress(mnemonic)) { + Status.Error(MnemonicError.WordIsAddress) + } else if (firstInvalidWord.isNotEmpty()) { + Status.Error(MnemonicError.InvalidWord(firstInvalidWord)) + } else if (isInvalidLengthError) { + prevStatus + } else if (firstInvalidWord.isEmpty() && isValidLength) { + Status.Valid + } else { + Status.None + } + + val canSubmit = status !is Status.Error && mnemonic != "" + onInputValidated(canSubmit) + } + + private fun normalizeInput(value: TextFieldValue) = + value.text.replace("\\s+".toRegex(), " ").lowercase() + + private fun isAddress(value: String) = value.startsWith("0x") && value.length == 42 + + fun handleSubmit() { + validateLastWordJob?.cancel() + + try { + val normalized = normalizeInput(input) + val mnemonic = normalized.trim() + val words = mnemonic.split(" ") + val valid = EthersRs.validateMnemonic(mnemonic) + + if (words.size < MIN_LENGTH || words.size in MIN_LENGTH + 1.. MAX_LENGTH) { + status = Status.Error(MnemonicError.TooManyWords) + } else if (!valid) { + status = Status.Error(MnemonicError.InvalidPhrase) + } else { + submitMnemonic(mnemonic) + } + } catch (e: Exception) { + // TODO gary add production logging and update rust code to convert to Java exceptions + Log.d("SeedPhraseInputViewModel", "Storing mnemonic caused error ${e.message}") + } + + if (status is Status.Error) { + onInputValidated(false) + onSubmitError() + } + } + + private fun submitMnemonic(mnemonic: String) { + if (targetMnemonicId != null) { + val generatedId = ethersRs.generateAddressForMnemonic(mnemonic, derivationIndex = 0) + if (generatedId != targetMnemonicId) { + status = Status.Error(MnemonicError.WrongRecoveryPhrase) + onSubmitError() + } else { + storeMnemonic(mnemonic) + } + } else { + storeMnemonic(mnemonic) + } + } + + private fun storeMnemonic(mnemonic: String) { + val mnemonicId = ethersRs.importMnemonic(mnemonic) + onMnemonicStored(mnemonicId) + } + + companion object { + private const val MIN_LENGTH = 12 + private const val MAX_LENGTH = 24 + } + +} diff --git a/apps/mobile/android/app/src/main/java/com/lux/onboarding/privatekeys/PrivateKeyDisplay.kt b/apps/mobile/android/app/src/main/java/com/lux/onboarding/privatekeys/PrivateKeyDisplay.kt new file mode 100644 index 00000000..f0a60cbf --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/lux/onboarding/privatekeys/PrivateKeyDisplay.kt @@ -0,0 +1,113 @@ +package com.lux.onboarding.privatekeys + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.mutableStateOf +import android.util.Log +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.tooling.preview.Preview +import com.lux.onboarding.shared.CopyButton +import com.lux.onboarding.shared.CopyButtonIcon +import com.lux.theme.CustomTypography +import com.lux.theme.LuxComponent +import com.lux.theme.LuxTheme + +@Composable +fun PrivateKeyDisplay( + viewModel: PrivateKeyDisplayViewModel, + address: String, + onHeightMeasured: (height: Float) -> Unit, +) { + val privateKey by viewModel.privateKey.collectAsState() + + + LaunchedEffect(address) { + viewModel.setup(address) + } + + PrivateKeyDisplayContent( + privateKey, + onHeightMeasured + ) +} + +@Composable +fun PrivateKeyDisplayContent( + privateKey: String, + onHeightMeasured: (height: Float) -> Unit, +) { + val density = LocalDensity.current.density + + Box( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight(unbounded = true) + .onSizeChanged { size -> + with(density) { + val heightInDp = size.height / density + onHeightMeasured(heightInDp) + } + } + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(LuxTheme.spacing.spacing12)) + .background(LuxTheme.colors.surface3) + .padding(LuxTheme.spacing.spacing12) + + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .fillMaxWidth() + ) { + Text( + text = privateKey, + style = LuxTheme.typography.monospace.copy( + color = LuxTheme.colors.neutral1 + ), + modifier = Modifier.weight(1f), + ) + Spacer(modifier = Modifier.width(LuxTheme.spacing.spacing8)) + CopyButtonIcon( + textToCopy = AnnotatedString(privateKey), + isSensitive = true + ) + } + } + } +} + +@Preview() +@Composable +fun PrivateKeyDisplayContentPreview() { + LuxTheme { + PrivateKeyDisplayContent( + privateKey = "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCprc7GUFxQpp27kTdoEhd0VpR1hwPxRsPCK8f4sEC8iVab+WOOFB4WAM0V7942KwLoFmZpi6G01KYmROklO1YhYvCkOYgBgegT8vJeuaYKlmzBuAcdu4AWWHbeSndxdvqfjeyr6thgNIYqsPLi+Djm1VgCWFLGBMN9DEEEgpiIlQIDAQAB", + onHeightMeasured = {} + ) + } +} diff --git a/apps/mobile/android/app/src/main/java/com/lux/onboarding/privatekeys/PrivateKeyDisplayViewManager.kt b/apps/mobile/android/app/src/main/java/com/lux/onboarding/privatekeys/PrivateKeyDisplayViewManager.kt new file mode 100644 index 00000000..03eeb0b0 --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/lux/onboarding/privatekeys/PrivateKeyDisplayViewManager.kt @@ -0,0 +1,91 @@ +package com.lux.onboarding.privatekeys + +import android.view.View +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.ComposeView +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.WritableMap +import com.facebook.react.uimanager.ThemedReactContext +import com.facebook.react.uimanager.ViewGroupManager +import com.facebook.react.uimanager.ViewManager +import com.facebook.react.uimanager.annotations.ReactProp +import com.facebook.react.uimanager.events.RCTEventEmitter +import com.lux.RnEthersRs +import com.lux.theme.LuxComponent +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update + +/** + * View manager used to import native component into React Native code + * for the PrivateKeyDisplay which shows the private key for the given + * address and enabled copying it to clipboard. + */ +class PrivateKeyDisplayViewManager : ViewGroupManager() { + + override fun getName(): String = REACT_CLASS + + private lateinit var context: ThemedReactContext + + private val addressFlow = MutableStateFlow("") + + override fun createViewInstance(reactContext: ThemedReactContext): ComposeView { + context = reactContext + val ethersRs = RnEthersRs(reactContext) + val viewModel = PrivateKeyDisplayViewModel(ethersRs) + + return ComposeView(reactContext).apply { + setContent { + val address by addressFlow.collectAsState() + + LuxComponent { + PrivateKeyDisplay( + address = address, + viewModel = viewModel, + onHeightMeasured = { + val bundle = Arguments.createMap().apply { + putDouble(FIELD_HEIGHT, it.toDouble()) + } + sendEvent(id, EVENT_HEIGHT_MEASURED, bundle) + }, + ) + } + } + } + } + + /** + * Maps local event name to expected RN prop. See RN [ViewManager] docs on github for schema. + * Using bubbling instead of direct events because overriding + * getExportedCustomDirectEventTypeConstants leads to a component not found error for some reason. + * Direct events will try find callback prop on native component, and bubbled events will bubble + * up until it finds component with the right callback prop. + */ + override fun getExportedCustomBubblingEventTypeConstants(): Map { + return mapOf( + EVENT_HEIGHT_MEASURED to mapOf( + "phasedRegistrationNames" to mapOf( + "bubbled" to EVENT_HEIGHT_MEASURED, + "captured" to EVENT_HEIGHT_MEASURED + ) + ), + ) + } + + private fun sendEvent(id: Int, eventName: String, bundle: WritableMap? = null) { + context + .getJSModule(RCTEventEmitter::class.java) + .receiveEvent(id, eventName, bundle) + } + + @ReactProp(name = "address") + fun setAddress(view: View, mnemonicId: String) { + addressFlow.update { mnemonicId } + } + + companion object { + private const val REACT_CLASS = "PrivateKeyDisplay" + private const val EVENT_HEIGHT_MEASURED = "onHeightMeasured" + private const val FIELD_HEIGHT = "height" + } +} diff --git a/apps/mobile/android/app/src/main/java/com/lux/onboarding/privatekeys/PrivateKeyDisplayViewModel.kt b/apps/mobile/android/app/src/main/java/com/lux/onboarding/privatekeys/PrivateKeyDisplayViewModel.kt new file mode 100644 index 00000000..b9b7a960 --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/lux/onboarding/privatekeys/PrivateKeyDisplayViewModel.kt @@ -0,0 +1,17 @@ +package com.lux.onboarding.privatekeys + +import androidx.lifecycle.ViewModel +import com.lux.RnEthersRs +import kotlinx.coroutines.flow.MutableStateFlow + +open class PrivateKeyDisplayViewModel( + private val ethersRs: RnEthersRs +) : ViewModel() { + + val privateKey = MutableStateFlow("") + fun setup(address: String) { + ethersRs.retrievePrivateKey(address)?.let { mnemonic -> + privateKey.value = mnemonic + } + } +} diff --git a/apps/mobile/android/app/src/main/java/com/lux/onboarding/scantastic/ScantasticEncryption.kt b/apps/mobile/android/app/src/main/java/com/lux/onboarding/scantastic/ScantasticEncryption.kt new file mode 100644 index 00000000..6db6392c --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/lux/onboarding/scantastic/ScantasticEncryption.kt @@ -0,0 +1,79 @@ +package com.lux.onboarding.scantastic + +import com.lux.RnEthersRs +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactContextBaseJavaModule +import com.facebook.react.bridge.ReactMethod +import javax.crypto.spec.OAEPParameterSpec +import javax.crypto.spec.PSource +import java.security.spec.MGF1ParameterSpec +import java.math.BigInteger +import java.util.Base64 +import java.security.KeyFactory +import java.security.spec.RSAPublicKeySpec +import javax.crypto.Cipher + +class ScantasticError(override val message: String) : Exception(message) + +class ScantasticEncryption(private val reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) { + override fun getName() = "ScantasticEncryption" + + private val rnEthersRS = RnEthersRs(reactContext) + + @ReactMethod + fun getEncryptedMnemonic(mnemonicId: String, n: String, e: String, promise: Promise) { + val mnemonic = rnEthersRS.retrieveMnemonic(mnemonicId) ?: run { + promise.reject(ScantasticError("Failed to retrieve mnemonic")) + return + } + + val publicKey = try { + generatePublicRSAKey(n, e) + } catch (ex: Exception) { + promise.reject(ScantasticError("Failed to generate public Key: ${ex.message}")) + return + } + + val encodedCiphertext = try { + encryptForStorage(mnemonic, publicKey) + } catch (ex: Exception) { + promise.reject(ScantasticError("Failed to encrypt the mnemonic: ${ex.message}")) + return + } + // Normal B64 not URL encoded, use getUrlDecoder() if you need URL encoded format + val b64encodedCiphertext = Base64.getEncoder().encodeToString(encodedCiphertext) + promise.resolve(b64encodedCiphertext) + } + + @Throws(Exception::class) + private fun generatePublicRSAKey(modulusStr: String, exponentStr: String): java.security.PublicKey { + val modulus = BigInteger(1, base64UrlToStandardBase64(modulusStr).let { Base64.getDecoder().decode(it) }) + val exponent = BigInteger(1, base64UrlToStandardBase64(exponentStr).let { Base64.getDecoder().decode(it) }) + val keySpec = RSAPublicKeySpec(modulus, exponent) + return KeyFactory.getInstance("RSA").generatePublic(keySpec) + } + + // It is unclear why URLDecoder doesn't do this by default and we have to do it here instead. + private fun base64UrlToStandardBase64(input: String): String { + var base64 = input.replace("-", "+").replace("_", "/") + while (base64.length % 4 != 0) { + base64 += "=" + } + return base64 + } + + @Throws(Exception::class) + private fun encryptForStorage(plaintext: String, publicKey: java.security.PublicKey): ByteArray { + val oaepParams = OAEPParameterSpec( + "SHA-256", + "MGF1", + MGF1ParameterSpec.SHA256, + PSource.PSpecified.DEFAULT + ) + + val cipher = Cipher.getInstance("RSA/ECB/OAEPPadding") + cipher.init(Cipher.ENCRYPT_MODE, publicKey, oaepParams) + return cipher.doFinal(plaintext.toByteArray()) + } +} diff --git a/apps/mobile/android/app/src/main/java/com/lux/onboarding/scantastic/ScantasticEncryptionModule.kt b/apps/mobile/android/app/src/main/java/com/lux/onboarding/scantastic/ScantasticEncryptionModule.kt new file mode 100644 index 00000000..08b4de01 --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/lux/onboarding/scantastic/ScantasticEncryptionModule.kt @@ -0,0 +1,20 @@ +package com.lux.onboarding.scantastic + +import android.view.View +import com.facebook.react.ReactPackage +import com.facebook.react.bridge.NativeModule +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.uimanager.ReactShadowNode +import com.facebook.react.uimanager.ViewManager + +class ScantasticEncryptionModule : ReactPackage { + + override fun createViewManagers( + reactContext: ReactApplicationContext + ): MutableList>> = mutableListOf() + + override fun createNativeModules( + reactContext: ReactApplicationContext + ): MutableList = listOf(ScantasticEncryption(reactContext)).toMutableList() +} + diff --git a/apps/mobile/android/app/src/main/java/com/lux/onboarding/shared/ActionButtons.kt b/apps/mobile/android/app/src/main/java/com/lux/onboarding/shared/ActionButtons.kt new file mode 100644 index 00000000..c97f48c2 --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/lux/onboarding/shared/ActionButtons.kt @@ -0,0 +1,105 @@ +package com.lux.onboarding.shared + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.Icon +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import com.lux.R +import com.lux.theme.LuxTheme + +enum class ActionButtonStatus { + SUCCESS, NEUTRAL +} + +/** + * Button that calls the action when clicked + */ +@Composable +fun ActionButton( + modifier: Modifier = Modifier, + action: () -> Unit, + text: String, + status: ActionButtonStatus = ActionButtonStatus.NEUTRAL, + @DrawableRes iconDrawable: Int? = null, +) { + val iconColor = when (status) { + ActionButtonStatus.SUCCESS -> LuxTheme.colors.statusSuccess + ActionButtonStatus.NEUTRAL -> LuxTheme.colors.neutral2 + } + val textColor = when (status) { + ActionButtonStatus.SUCCESS -> LuxTheme.colors.statusSuccess + ActionButtonStatus.NEUTRAL -> LuxTheme.colors.neutral1 + } + + Box( + modifier = modifier.shadow( + 10.dp, + spotColor = LuxTheme.colors.black.copy(alpha = 0.04f), + shape = LuxTheme.shapes.small + ) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(LuxTheme.spacing.spacing4), + modifier = Modifier + .clip(shape = LuxTheme.shapes.small) + .border(1.dp, LuxTheme.colors.surface3, LuxTheme.shapes.small) + .clickable { action() } + .background(color = LuxTheme.colors.surface1) + .padding( + top = LuxTheme.spacing.spacing8, + end = LuxTheme.spacing.spacing16, + bottom = LuxTheme.spacing.spacing8, + start = if (iconDrawable != null) LuxTheme.spacing.spacing8 else LuxTheme.spacing.spacing16 + )) { + iconDrawable?.let { + Icon( + painter = painterResource(id = it), + contentDescription = null, + tint = iconColor, + modifier = Modifier.size(20.dp) + ) + } + Text( + text = text, color = textColor, style = LuxTheme.typography.buttonLabel4 + ) + } + } +} + +@Composable +fun PasteButton( + modifier: Modifier = Modifier, + pasteButtonText: String, + onPaste: (text: String) -> Unit +) { + val clipboardManager = LocalClipboardManager.current + + fun onClick() { + clipboardManager.getText()?.toString()?.let { + onPaste(it) + } + } + + ActionButton( + action = { onClick() }, + text = pasteButtonText, + iconDrawable = R.drawable.lux_icon_paste, + modifier = modifier + ) +} diff --git a/apps/mobile/android/app/src/main/java/com/lux/onboarding/shared/CopyButtons.kt b/apps/mobile/android/app/src/main/java/com/lux/onboarding/shared/CopyButtons.kt new file mode 100644 index 00000000..0397a5fb --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/lux/onboarding/shared/CopyButtons.kt @@ -0,0 +1,177 @@ +package com.lux.onboarding.shared + +import android.content.ClipData +import android.content.ClipDescription +import android.content.ClipboardManager +import android.content.Context +import android.os.Build +import android.os.PersistableBundle +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.size +import androidx.compose.material.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.lux.R +import com.lux.theme.LuxTheme +import kotlinx.coroutines.delay +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit + + +/** + * A composable that displays a copy icon button. + * The icon changes to success color when content is copied to the clipboard. + * + * @param modifier The modifier to be applied to the component + * @param textToCopy The text to be copied to clipboard as an AnnotatedString + * @param isSensitive Whether the text contains sensitive information that should be cleared from clipboard after a delay + */ +@Composable +fun CopyButtonIcon( + modifier: Modifier = Modifier, + textToCopy: AnnotatedString, + isSensitive: Boolean = false, +) { + val (isCopied, onClick) = rememberCopyState(textToCopy, isSensitive) + + Box( + modifier = modifier + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { onClick() } + ) { + Icon( + painter = painterResource(id = R.drawable.lux_icon_copy_outline), + contentDescription = null, + tint = if (isCopied) LuxTheme.colors.statusSuccess else LuxTheme.colors.neutral1, + modifier = Modifier.size(20.dp) + ) + } +} + +/** + * A composable that displays a copy button with text. + * The button text changes when content is copied to the clipboard to provide visual feedback. + * + * @param modifier The modifier to be applied to the component + * @param copyButtonText The text to display on the button before copying + * @param copiedButtonText The text to display on the button after copying + * @param textToCopy The text to be copied to clipboard as an AnnotatedString + * @param isSensitive Whether the text contains sensitive information that should be cleared from clipboard after a delay + */ +@Composable +fun CopyButton( + modifier: Modifier = Modifier, + copyButtonText: String, + copiedButtonText: String, + textToCopy: AnnotatedString, + isSensitive: Boolean = false, +) { + val (isCopied, onClick) = rememberCopyState(textToCopy, isSensitive) + + ActionButton( + action = { onClick() }, + text = if (isCopied) copiedButtonText else copyButtonText, + iconDrawable = R.drawable.lux_icon_copy, + status = if (isCopied) ActionButtonStatus.SUCCESS else ActionButtonStatus.NEUTRAL, + modifier = modifier + ) +} + +/** + * A composable state function that manages clipboard operations. + * Handles copying text to clipboard and maintaining copied state with visual feedback. + * + * @param textToCopy The text to be copied to clipboard as an AnnotatedString + * @param isSensitive Whether the text contains sensitive information that should be handled securely. + * When true, sensitive data will be marked as such and will be cleared from clipboard after 2 minutes. + * @return A Pair containing: + * - Boolean: Whether the text has been copied (true) or not (false) + * - Function: Lambda function to trigger the copy operation + */ +@Composable +fun rememberCopyState( + textToCopy: AnnotatedString, + isSensitive: Boolean = false +): Pair Unit> { + val context = LocalContext.current + val backgroundExecutor = remember { + Executors.newSingleThreadScheduledExecutor() + } + DisposableEffect(Unit) { + onDispose { + backgroundExecutor.shutdown() + } + } + + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + + // Time after which the button reverts to the copy state + val copyTimeout: Long = 2000 + var isCopied by remember { mutableStateOf(false) } + var copyTimeoutId by remember { mutableStateOf(0) } + + val onClick = { + val label = if (isSensitive) "sensitive data" else "copied text" + val clipData = ClipData.newPlainText(label, textToCopy) + if (isSensitive) { + clipData.description.extras = PersistableBundle().apply { + val extraKey = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + ClipDescription.EXTRA_IS_SENSITIVE + } else { + "android.content.extra.IS_SENSITIVE" + } + putBoolean(extraKey, true) + } + backgroundExecutor.schedule({ + clipboard.clearPrimaryClip() + }, 2, TimeUnit.MINUTES) + } + + clipboard.setPrimaryClip(clipData) + copyTimeoutId++ + isCopied = true + } + + LaunchedEffect(copyTimeoutId) { + delay(copyTimeout) + isCopied = false + } + + return Pair(isCopied, onClick) +} + +@Preview(backgroundColor = 0xFFE0E0E0, showBackground = true) +@Composable +fun CopyButtonPreview() { + LuxTheme { + Column { + CopyButtonIcon( + textToCopy = AnnotatedString("whatevs"), + isSensitive = true + ) + CopyButton( + copyButtonText = "Copy", + copiedButtonText = "Copied", + textToCopy = AnnotatedString("whatevs"), + isSensitive = true + ) + } + + } +} diff --git a/apps/mobile/android/app/src/main/java/com/lux/theme/Color.kt b/apps/mobile/android/app/src/main/java/com/lux/theme/Color.kt new file mode 100644 index 00000000..b3182134 --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/lux/theme/Color.kt @@ -0,0 +1,249 @@ +package com.lux.theme + +import androidx.compose.material.darkColors +import androidx.compose.material.lightColors +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color + +object LuxColors { + val White = Color(0xFFFFFFFF) + val Black = Color(0xFF000000) + val Gray50 = Color(0xFFF5F6FC) + val Gray100 = Color(0xFFE8ECFB) + val Gray150 = Color(0xFFD2D9EE) + val Gray200 = Color(0xFFB8C0DC) + val Gray250 = Color(0xFFA6AFCA) + val Gray300 = Color(0xFF98A1C0) + val Gray350 = Color(0xFF888FAB) + val Gray400 = Color(0xFF7780A0) + val Gray450 = Color(0xFF6B7594) + val Gray500 = Color(0xFF5D6785) + val Gray550 = Color(0xFF505A78) + val Gray600 = Color(0xFF404A67) + val Gray650 = Color(0xFF333D59) + val Gray700 = Color(0xFF293249) + val Gray750 = Color(0xFF1B2236) + val Gray800 = Color(0xFF131A2A) + val Gray850 = Color(0xFF0E1524) + val Gray900 = Color(0xFF0D111C) + val Pink50 = Color(0xFFFFF2F7) + val Pink100 = Color(0xFFFFD9E4) + val Pink200 = Color(0xFFFBA4C0) + val Pink300 = Color(0xFFFF6FA3) + val Pink400 = Color(0xFFFB118E) + val Pink500 = Color(0xFFC41A69) + val Pink600 = Color(0xFF8C0F49) + val Pink700 = Color(0xFF55072A) + val Pink800 = Color(0xFF39061B) + val Pink900 = Color(0xFF2B000B) + val PinkVibrant = Color(0xFFF51A70) + val Red50 = Color(0xFFFEF0EE) + val Red100 = Color(0xFFFED5CF) + val Red200 = Color(0xFFFEA79B) + val Red300 = Color(0xFFFD766B) + val Red400 = Color(0xFFFA2B39) + val Red500 = Color(0xFFC4292F) + val Red600 = Color(0xFF891E20) + val Red700 = Color(0xFF530F0F) + val Red800 = Color(0xFF380A03) + val Red900 = Color(0xFF240800) + val RedVibrant = Color(0xFFF14544) + val Yellow50 = Color(0xFFFEF8C4) + val Yellow100 = Color(0xFFF0E49A) + val Yellow200 = Color(0xFFDBBC19) + val Yellow300 = Color(0xFFBB9F13) + val Yellow400 = Color(0xFFA08116) + val Yellow500 = Color(0xFF866311) + val Yellow600 = Color(0xFF5D4204) + val Yellow700 = Color(0xFF3E2B04) + val Yellow800 = Color(0xFF231902) + val Yellow900 = Color(0xFF180F02) + val YellowVibrant = Color(0xFFFAF40A) + val Gold50 = Color(0xFFFFF5E8) + val Gold100 = Color(0xFFF8DEB6) + val Gold200 = Color(0xFFEEB317) + val Gold300 = Color(0xFFDB900B) + val Gold400 = Color(0xFFB17900) + val Gold500 = Color(0xFF905C10) + val Gold600 = Color(0xFF643F07) + val Gold700 = Color(0xFF3F2208) + val Gold800 = Color(0xFF29160F) + val Gold900 = Color(0xFF161007) + val GoldVibrant = Color(0xFFFEB239) + val Green50 = Color(0xFFEDFDF0) + val Green100 = Color(0xFFBFEECA) + val Green200 = Color(0xFF76D191) + val Green300 = Color(0xFF40B66B) + val Green400 = Color(0xFF209853) + val Green500 = Color(0xFF0B783E) + val Green600 = Color(0xFF0C522A) + val Green700 = Color(0xFF053117) + val Green800 = Color(0xFF091F10) + val Green900 = Color(0xFF09130B) + val GreenVibrant = Color(0xFF5CFE9D) + val Blue50 = Color(0xFFF3F5FE) + val Blue100 = Color(0xFFDEE1FF) + val Blue200 = Color(0xFFADBCFF) + val Blue300 = Color(0xFF869EFF) + val Blue400 = Color(0xFF4C82FB) + val Blue500 = Color(0xFF1267D6) + val Blue600 = Color(0xFF1D4294) + val Blue700 = Color(0xFF09265E) + val Blue800 = Color(0xFF0B193F) + val Blue900 = Color(0xFF040E34) + val BlueVibrant = Color(0xFF587BFF) + val Lime50 = Color(0xFFF2FEDB) + val Lime100 = Color(0xFFD3EBA3) + val Lime200 = Color(0xFF9BCD46) + val Lime300 = Color(0xFF7BB10C) + val Lime400 = Color(0xFF649205) + val Lime500 = Color(0xFF527318) + val Lime600 = Color(0xFF344F00) + val Lime700 = Color(0xFF233401) + val Lime800 = Color(0xFF171D00) + val Lime900 = Color(0xFF0E1300) + val LimeVibrant = Color(0xFFB1F13C) + val Orange50 = Color(0xFFFEEDE5) + val Orange100 = Color(0xFFFCD9C8) + val Orange200 = Color(0xFFFBAA7F) + val Orange300 = Color(0xFFF67E3E) + val Orange400 = Color(0xFFDC5B14) + val Orange500 = Color(0xFFAF460A) + val Orange600 = Color(0xFF76330F) + val Orange700 = Color(0xFF4D220B) + val Orange800 = Color(0xFF2A1505) + val Orange900 = Color(0xFF1C0E03) + val OrangeVibrant = Color(0xFFFF6F1E) + val Magenta50 = Color(0xFFFFF1FE) + val Magenta100 = Color(0xFFFAD8F8) + val Magenta200 = Color(0xFFF5A1F5) + val Magenta300 = Color(0xFFF06DF3) + val Magenta400 = Color(0xFFDC39E3) + val Magenta500 = Color(0xFFAF2EB4) + val Magenta600 = Color(0xFF7A1C7D) + val Magenta700 = Color(0xFF550D56) + val Magenta800 = Color(0xFF330733) + val Magenta900 = Color(0xFF250225) + val MagentaVibrant = Color(0xFFFC72FF) + val Violet50 = Color(0xFFF1EFFE) + val Violet100 = Color(0xFFE2DEFD) + val Violet200 = Color(0xFFBDB8FA) + val Violet300 = Color(0xFF9D99F5) + val Violet400 = Color(0xFF7A7BEB) + val Violet500 = Color(0xFF515EDC) + val Violet600 = Color(0xFF343F9E) + val Violet700 = Color(0xFF232969) + val Violet800 = Color(0xFF121643) + val Violet900 = Color(0xFF0E0D30) + val VioletVibrant = Color(0xFF5065FD) + val Cyan50 = Color(0xFFD6F5FE) + val Cyan100 = Color(0xFFB0EDFE) + val Cyan200 = Color(0xFF63CDE8) + val Cyan300 = Color(0xFF2FB0CC) + val Cyan400 = Color(0xFF2092AB) + val Cyan500 = Color(0xFF117489) + val Cyan600 = Color(0xFF014F5F) + val Cyan700 = Color(0xFF003540) + val Cyan800 = Color(0xFF011E26) + val Cyan900 = Color(0xFF011418) + val CyanVibrant = Color(0xFF36DBFF) + val Slate50 = Color(0xFFF1FCEF) + val Slate100 = Color(0xFFDAE6D8) + val Slate200 = Color(0xFFB8C3B7) + val Slate300 = Color(0xFF9AA498) + val Slate400 = Color(0xFF7E887D) + val Slate500 = Color(0xFF646B62) + val Slate600 = Color(0xFF434942) + val Slate700 = Color(0xFF2C302C) + val Slate800 = Color(0xFF181B18) + val Slate900 = Color(0xFF0F120E) + val SlateVibrant = Color(0xFF7E887D) + val Transparent = Color.Transparent +} + +data class CustomColors( + val white: Color = LuxColors.White, + val black: Color = LuxColors.Black, + val surface1: Color, + val surface2: Color, + val surface3: Color, + val surface4: Color, + val surface5: Color, + val scrim: Color, + val neutral1: Color, + val neutral2: Color, + val neutral3: Color, + val accent1: Color, + val accent2: Color, + val statusActive: Color, + val statusSuccess: Color, + val statusCritical: Color, +) + +val lightCustomColors = CustomColors( + surface1 = Color(0xFFFFFFFF), + surface2 = Color(0xFFF9F9F9), + surface3 = Color(0x0D222222), + surface4 = Color(0xA3FFFFFF), + surface5 = Color(0x0A000000), + + scrim = Color(0x99000000), + + neutral1 = Color(0xFF222222), + neutral2 = Color(0xFF7D7D7D), + neutral3 = Color(0xFFCECECE), + + accent1 = Color(0xFFFC72FF), + accent2 = Color(0xFFFFEFFF), + + statusActive = Color(0xFF236EFF), + statusSuccess = Color(0xFF40B66B), + statusCritical = Color(0xFFFF5F52), +) + +val darkCustomColors = CustomColors( + surface1 = Color(0xFF131313), + surface2 = Color(0xFF1B1B1B), + surface3 = Color(0x1FFFFFFF), + surface4 = Color(0x33FFFFFF), + surface5 = Color(0x0A000000), + + scrim = Color(0x99000000), + + neutral1 = Color(0xFFFFFFFF), + neutral2 = Color(0xFF9B9B9B), + neutral3 = Color(0xFF5E5E5E), + + accent1 = Color(0xFFFC72FF), + accent2 = Color(0xFF311C31), + + statusActive = Color(0xFF236EFF), + statusSuccess = Color(0xFF40B66B), + statusCritical = Color(0xFFFF5F52), +) + +val LightColors = lightColors( + primary = lightCustomColors.accent1, + background = lightCustomColors.surface1, + surface = lightCustomColors.surface1, + error = lightCustomColors.statusCritical, + onPrimary = lightCustomColors.white, + onBackground = lightCustomColors.neutral1, + onSurface = lightCustomColors.neutral1, + onError = lightCustomColors.white, +) + +val DarkColors = darkColors( + primary = darkCustomColors.accent1, + background = darkCustomColors.surface1, + surface = darkCustomColors.surface1, + error = darkCustomColors.statusCritical, + onPrimary = darkCustomColors.white, + onBackground = darkCustomColors.neutral1, + onSurface = darkCustomColors.neutral1, + onError = darkCustomColors.white, +) + +val LocalCustomColors = staticCompositionLocalOf { + lightCustomColors +} diff --git a/apps/mobile/android/app/src/main/java/com/lux/theme/LuxComponent.kt b/apps/mobile/android/app/src/main/java/com/lux/theme/LuxComponent.kt new file mode 100644 index 00000000..59f1a0ef --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/lux/theme/LuxComponent.kt @@ -0,0 +1,13 @@ +package com.lux.theme + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +@Composable +fun LuxComponent(modifier: Modifier = Modifier, content: @Composable () -> Unit) { + LuxTheme { + Surface(modifier = modifier, content = content) + } +} diff --git a/apps/mobile/android/app/src/main/java/com/lux/theme/LuxTheme.kt b/apps/mobile/android/app/src/main/java/com/lux/theme/LuxTheme.kt new file mode 100644 index 00000000..9fad792d --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/lux/theme/LuxTheme.kt @@ -0,0 +1,56 @@ +package com.lux.theme + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material.MaterialTheme +import androidx.compose.material.ProvideTextStyle +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember + +@Composable +fun LuxTheme( + darkTheme: Boolean? = null, + content: @Composable () -> Unit +) { + val isDarkTheme = darkTheme ?: isSystemInDarkTheme() + + val customSpacing = CustomSpacing() + val customTypography = CustomTypography() + val customShapes = CustomShapes() + val customColors = remember(isDarkTheme) { + if (isDarkTheme) darkCustomColors else lightCustomColors + } + + CompositionLocalProvider( + LocalCustomSpacing provides customSpacing, + LocalCustomTypography provides customTypography, + LocalCustomShapes provides customShapes, + LocalCustomColors provides customColors, + ) { + MaterialTheme( + colors = if (isDarkTheme) DarkColors else LightColors + ) { + ProvideTextStyle(value = customTypography.body1) { + content() + } + } + } +} + +object LuxTheme { + val spacing: CustomSpacing + @Composable + get() = LocalCustomSpacing.current + + val typography: CustomTypography + @Composable + get() = LocalCustomTypography.current + + val shapes: CustomShapes + @Composable + get() = LocalCustomShapes.current + + val colors: CustomColors + @Composable + get() = LocalCustomColors.current +} diff --git a/apps/mobile/android/app/src/main/java/com/lux/theme/Modifiers.kt b/apps/mobile/android/app/src/main/java/com/lux/theme/Modifiers.kt new file mode 100644 index 00000000..3a2beb79 --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/lux/theme/Modifiers.kt @@ -0,0 +1,23 @@ +package com.lux.theme + +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.layout + +fun Modifier.relativeOffset( + x: Float = 0f, + y: Float = 0f, + onOffsetCalculated: (Int, Int) -> Unit = { _, _ -> } +): Modifier = this.then( + layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + + val offsetX = (placeable.width * x).toInt() + val offsetY = (placeable.height * y).toInt() + + onOffsetCalculated(offsetX, offsetY) + + layout(placeable.width, placeable.height) { + placeable.place(offsetX, offsetY) + } + } +) diff --git a/apps/mobile/android/app/src/main/java/com/lux/theme/Shape.kt b/apps/mobile/android/app/src/main/java/com/lux/theme/Shape.kt new file mode 100644 index 00000000..6d844468 --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/lux/theme/Shape.kt @@ -0,0 +1,18 @@ +package com.lux.theme + +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.unit.dp + +data class CustomShapes( + val small: RoundedCornerShape = RoundedCornerShape(16.dp), + val medium: RoundedCornerShape = RoundedCornerShape(20.dp), + val large: RoundedCornerShape = RoundedCornerShape(24.dp), + val xlarge: RoundedCornerShape = RoundedCornerShape(100.dp), + val buttonSmall: RoundedCornerShape = RoundedCornerShape(12.dp), + val buttonMedium: RoundedCornerShape = RoundedCornerShape(16.dp), +) + +val LocalCustomShapes = staticCompositionLocalOf { + CustomShapes() +} diff --git a/apps/mobile/android/app/src/main/java/com/lux/theme/Spacing.kt b/apps/mobile/android/app/src/main/java/com/lux/theme/Spacing.kt new file mode 100644 index 00000000..f57b47be --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/lux/theme/Spacing.kt @@ -0,0 +1,26 @@ +package com.lux.theme + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +@Immutable +data class CustomSpacing( + val spacing1: Dp = 1.dp, + val spacing2: Dp = 2.dp, + val spacing4: Dp = 4.dp, + val spacing8: Dp = 8.dp, + val spacing12: Dp = 12.dp, + val spacing16: Dp = 16.dp, + val spacing24: Dp = 24.dp, + val spacing28: Dp = 28.dp, + val spacing32: Dp = 32.dp, + val spacing36: Dp = 36.dp, + val spacing48: Dp = 48.dp, + val spacing60: Dp = 60.dp, +) + +val LocalCustomSpacing = staticCompositionLocalOf { + CustomSpacing() +} diff --git a/apps/mobile/android/app/src/main/java/com/lux/theme/Typography.kt b/apps/mobile/android/app/src/main/java/com/lux/theme/Typography.kt new file mode 100644 index 00000000..2d032bec --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/lux/theme/Typography.kt @@ -0,0 +1,119 @@ +package com.lux.theme + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.sp +import com.lux.R + +// TODO gary update for Spore changes +@Immutable +data class CustomTypography( + val defaultFontFamily: FontFamily = FontFamily( + Font(R.font.basel_grotesk_book), + Font(R.font.basel_grotesk_medium, FontWeight.Medium), + Font(R.font.basel_grotesk_medium, FontWeight.SemiBold), + Font(R.font.basel_grotesk_medium, FontWeight.Bold), + ), + val defaultLetterSpacing: TextUnit = 0.sp, + val heading1: TextStyle = TextStyle( + fontFamily = defaultFontFamily, + fontSize = 52.sp, + lineHeight = 60.sp, + fontWeight = FontWeight.Normal, + letterSpacing = defaultLetterSpacing, + ), + val heading2: TextStyle = TextStyle( + fontFamily = defaultFontFamily, + fontSize = 36.sp, + lineHeight = 44.sp, + fontWeight = FontWeight.Normal, + letterSpacing = defaultLetterSpacing, + ), + val heading3: TextStyle = TextStyle( + fontFamily = defaultFontFamily, + fontSize = 24.sp, + lineHeight = 32.sp, + fontWeight = FontWeight.Normal, + letterSpacing = defaultLetterSpacing, + ), + val subheading1: TextStyle = TextStyle( + fontFamily = defaultFontFamily, + fontSize = 18.sp, + lineHeight = 24.sp, + fontWeight = FontWeight.Normal, + letterSpacing = defaultLetterSpacing, + ), + val subheading2: TextStyle = TextStyle( + fontFamily = defaultFontFamily, + fontSize = 16.sp, + lineHeight = 24.sp, + fontWeight = FontWeight.Normal, + letterSpacing = defaultLetterSpacing, + ), + val body1: TextStyle = TextStyle( + fontFamily = defaultFontFamily, + fontSize = 18.sp, + lineHeight = 24.sp, + fontWeight = FontWeight.Normal, + letterSpacing = defaultLetterSpacing, + ), + val body2: TextStyle = TextStyle( + fontFamily = defaultFontFamily, + fontSize = 16.sp, + lineHeight = 24.sp, + fontWeight = FontWeight.Normal, + letterSpacing = defaultLetterSpacing, + ), + val body3: TextStyle = TextStyle( + fontFamily = defaultFontFamily, + fontSize = 14.sp, + lineHeight = 16.sp, + fontWeight = FontWeight.Normal, + letterSpacing = defaultLetterSpacing, + ), + val buttonLabel1: TextStyle = TextStyle( + fontFamily = defaultFontFamily, + fontSize = 18.sp, + lineHeight = 24.sp, + fontWeight = FontWeight.Normal, + letterSpacing = defaultLetterSpacing, + ), + val buttonLabel2: TextStyle = TextStyle( + fontFamily = defaultFontFamily, + fontSize = 18.sp, + lineHeight = 24.sp, + fontWeight = FontWeight.Medium, + letterSpacing = defaultLetterSpacing, + ), + val buttonLabel3: TextStyle = TextStyle( + fontFamily = defaultFontFamily, + fontSize = 18.sp, + lineHeight = 24.sp, + fontWeight = FontWeight.Normal, + letterSpacing = defaultLetterSpacing, + ), + val buttonLabel4: TextStyle = TextStyle( + fontFamily = defaultFontFamily, + fontSize = 14.sp, + lineHeight = 16.sp, + fontWeight = FontWeight.Medium, + letterSpacing = defaultLetterSpacing, + ), + val monospace: TextStyle = TextStyle( + fontFamily = FontFamily( + Font(R.font.inputmono_regular), + ), + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = defaultLetterSpacing, + ), +) + +val LocalCustomTypography = staticCompositionLocalOf { + CustomTypography() +} diff --git a/apps/mobile/android/app/src/main/java/com/lux/utils/JsonWritableExtensions.kt b/apps/mobile/android/app/src/main/java/com/lux/utils/JsonWritableExtensions.kt new file mode 100644 index 00000000..7967460d --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/lux/utils/JsonWritableExtensions.kt @@ -0,0 +1,61 @@ +package com.lux.utils + +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.WritableArray +import com.facebook.react.bridge.WritableMap +import org.json.JSONArray +import org.json.JSONObject + +fun JSONObject.toWritableMap(): WritableMap { + val map = Arguments.createMap() + val iterator = keys() + while (iterator.hasNext()) { + val key = iterator.next() + when (val value = opt(key)) { + null, JSONObject.NULL -> map.putNull(key) + is JSONObject -> map.putMap(key, value.toWritableMap()) + is JSONArray -> map.putArray(key, value.toWritableArray()) + is Boolean -> map.putBoolean(key, value) + is Int -> map.putInt(key, value) + is Long -> { + if (value in Int.MIN_VALUE..Int.MAX_VALUE) { + map.putInt(key, value.toInt()) + } else { + map.putDouble(key, value.toDouble()) + } + } + is Double -> map.putDouble(key, value) + is Float -> map.putDouble(key, value.toDouble()) + is Number -> map.putDouble(key, value.toDouble()) + is String -> map.putString(key, value) + else -> map.putString(key, value.toString()) + } + } + return map +} + +fun JSONArray.toWritableArray(): WritableArray { + val array = Arguments.createArray() + for (index in 0 until length()) { + when (val value = opt(index)) { + null, JSONObject.NULL -> array.pushNull() + is JSONObject -> array.pushMap(value.toWritableMap()) + is JSONArray -> array.pushArray(value.toWritableArray()) + is Boolean -> array.pushBoolean(value) + is Int -> array.pushInt(value) + is Long -> { + if (value in Int.MIN_VALUE..Int.MAX_VALUE) { + array.pushInt(value.toInt()) + } else { + array.pushDouble(value.toDouble()) + } + } + is Double -> array.pushDouble(value) + is Float -> array.pushDouble(value.toDouble()) + is Number -> array.pushDouble(value.toDouble()) + is String -> array.pushString(value) + else -> array.pushString(value.toString()) + } + } + return array +} diff --git a/apps/mobile/android/app/src/main/java/com/uniswap/AndroidDeviceModule.kt b/apps/mobile/android/app/src/main/java/com/uniswap/AndroidDeviceModule.kt new file mode 100644 index 00000000..4a722b85 --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/uniswap/AndroidDeviceModule.kt @@ -0,0 +1,24 @@ +package com.uniswap + +import androidx.core.performance.play.services.PlayServicesDevicePerformance +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactContextBaseJavaModule +import com.facebook.react.bridge.ReactMethod + + +/** + * React module to provide device level information particular to Android + */ +class AndroidDeviceModule(reactContext: ReactApplicationContext): ReactContextBaseJavaModule(reactContext) { + override fun getName() = REACT_CLASS + + @ReactMethod + fun getPerformanceClass(promise: Promise) { + val devicePerformance = PlayServicesDevicePerformance(reactApplicationContext) + promise.resolve(devicePerformance.mediaPerformanceClass) + } + companion object { + private const val REACT_CLASS = "AndroidDeviceModule" + } +} diff --git a/apps/mobile/android/app/src/main/java/com/uniswap/EmbeddedWalletModule.kt b/apps/mobile/android/app/src/main/java/com/uniswap/EmbeddedWalletModule.kt new file mode 100644 index 00000000..9fa06418 --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/uniswap/EmbeddedWalletModule.kt @@ -0,0 +1,58 @@ +package com.uniswap + +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactContextBaseJavaModule +import com.facebook.react.bridge.ReactMethod +import decryptMnemonic +import generateRsaKeyPair +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import java.security.KeyPair + +class EmbeddedWalletModule(reactContext: ReactApplicationContext) : + ReactContextBaseJavaModule(reactContext) { + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + private val publicKeyPairMap = mutableMapOf() + + override fun getName() = "EmbeddedWallet" + + @ReactMethod + fun decryptMnemonicForPublicKey(encryptedMnemonic: String, publicKeyBase64: String, promise: Promise) { + val keyPair = publicKeyPairMap[publicKeyBase64] ?: run { + promise.reject( + "KEY_PAIR_NOT_FOUND", + "Key pair not found for public key $publicKeyBase64", + IllegalStateException("Key pair not found for public key $publicKeyBase64") + ) + return + } + + scope.launch { + try { + val decryptedMnemonic = decryptMnemonic(encryptedMnemonic, keyPair) + publicKeyPairMap.remove(publicKeyBase64) + promise.resolve(decryptedMnemonic) + } catch (e: Exception) { + promise.reject("DECRYPT_ERROR", "Failed to decrypt mnemonic", e) + } + } + } + + @ReactMethod + fun generateKeyPair(promise: Promise) { + scope.launch { + try { + val pair = generateRsaKeyPair() + publicKeyPairMap[pair.first] = pair.second + promise.resolve(pair.first) + } catch (e: Exception) { + promise.reject("KEYPAIR_GENERATION_ERROR", "Failed to generate key pair", e) + } + } + } +} diff --git a/apps/mobile/android/app/src/main/java/com/uniswap/EncryptionHelper.kt b/apps/mobile/android/app/src/main/java/com/uniswap/EncryptionHelper.kt new file mode 100644 index 00000000..22af0423 --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/uniswap/EncryptionHelper.kt @@ -0,0 +1,189 @@ +import com.lambdapioneer.argon2kt.Argon2Kt +import com.lambdapioneer.argon2kt.Argon2KtResult +import com.lambdapioneer.argon2kt.Argon2Mode +import javax.crypto.spec.OAEPParameterSpec +import javax.crypto.spec.PSource +import javax.crypto.spec.SecretKeySpec +import java.security.SecureRandom +import android.util.Base64; +import javax.crypto.Cipher +import javax.crypto.spec.IvParameterSpec +import java.security.KeyPair +import java.security.KeyPairGenerator +import java.security.spec.MGF1ParameterSpec +import java.security.spec.RSAKeyGenParameterSpec +import java.math.BigInteger + +/** + * Encrypts a string using an AES/GCM cipher and a key derived from a password and salt. + * + * This function creates a key from a password and salt with the function [keyFromPassword]. + * It then creates a Cipher instance for AES/GCM/NoPadding with a given random nonce and + * encrypts the original string. The result of this operation is encoded as Base64 and + * then returned. + * + * @param secret The original plaintext string to be encrypted. + * @param password The user-supplied password used for generating the encryption key with the salt. + * @param salt The unique salt used in conjunction with the password for key generation. + * @param nonce The unique byte array nonce (IV) used for AES-GCM cipher + * + * @return A string representing the Base64 encoded encrypted string. + * + * @throws IllegalArgumentException If secret, password, salt, or nonce is blank. + * + * @see Cipher + * @see SecretKeySpec + * @see IvParameterSpec + * @see Base64 + */ +fun encrypt(secret: String, password: String, salt: String, nonce: ByteArray): String { + val key = keyFromPassword(password, salt) + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(key, "AES"), IvParameterSpec(nonce)) + val encrypted = cipher.doFinal(secret.toByteArray(Charsets.UTF_8)) + return Base64.encodeToString(encrypted, Base64.DEFAULT) +} + +/** + * Decrypts an AES/GCM encrypted string using a password, salt, and nonce to provide the decryption key. + * + * This function generates a key derived from a password and salt, along with the nonce used for AES-GCM cipher + * This key is then used in an AES/GCM cipher to decrypt the provided encrypted secret. Returns the decrypted + * original string. + * + * @param encryptedSecret The encrypted string (in Base64 format) to be decrypted. + * @param password User-supplied password used along with salt for deriving the decryption key. + * @param salt A unique string (in Base64 format) used to diversify derived encryption keys + * and to protect from rainbow table attacks. + * @param nonce A unique byte array used as an nonce/IV for the AES-GCM cipher. + * + * @return A UTF-8 encoded string that was decrypted from the encryptedSecret. + * + * @throws IllegalArgumentException If password or salt or nonce is blank or encryptedSecret is not a valid Base64 string. + * + * @see Cipher + * @see SecretKeySpec + * @see IvParameterSpec + * @see Base64 + * @see Charsets.UTF_8 + */ +fun decrypt(encryptedSecret: String, password: String, salt: String, nonce: ByteArray): String { + val key = keyFromPassword(password, salt) + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(key, "AES"), IvParameterSpec(nonce)) + val original = cipher.doFinal(Base64.decode(encryptedSecret, Base64.DEFAULT)) + return String(original, Charsets.UTF_8) +} + +/** + * Generates a cryptographic key from a user password and salt using Argon2Kt. + * + * This function uses Argon2Kt to generate a hash from a user password and a given salt. + * It then returns the raw hash as a byte array. + * This is useful for secure password storage or key derivation. + * Parameters where picked based on the security audit and recommendations + * + * @param password The plaintext password provided by the user. + * @param salt The unique salt generated for hashing. + * + * @return A byte array representing the raw hash of the password. + * + * @throws IllegalArgumentException If password or salt is blank. + * + * @see Argon2Kt + * @see Charsets + */ +fun keyFromPassword(password: String, salt: String): ByteArray { + val hash: Argon2KtResult = Argon2Kt().hash( + mode = Argon2Mode.ARGON2_ID, + password = password.toByteArray(Charsets.UTF_8), + salt = salt.toByteArray(Charsets.UTF_8), + tCostInIterations = 3, + mCostInKibibyte = 65536, + parallelism = 4 + ) + return hash.rawHashAsByteArray() +} + +/** + * Generates a Base64-encoded string to be used as a security salt. + * + * This function creates a ByteArray of a given length and populates it + * with securely random bytes. These bytes are then encoded to Base64 string. + * + * @param length The length of the byte array to be generated which further determines the length of the salt. + * + * @return A Base64 encoded string representing the generated salt. + * + * @see SecureRandom + * @see Base64 + */ +fun generateSalt(length: Int): String { + val bytes = ByteArray(length) + val secureRandom = SecureRandom() + secureRandom.nextBytes(bytes) + return Base64.encodeToString(bytes, Base64.DEFAULT) +} + +/** + * Generates a byte array to be used as a nonce/initialization vector. + * + * This function creates a ByteArray of a given length and populates it + * with securely random bytes. + * + * @param length The length of the byte array to be generated. + * + * @return A random array of bytes representing the generated nonce. + * + * @see SecureRandom + * @see Base64 + */ +fun generateNonce(length: Int): ByteArray { + val bytes = ByteArray(length) + val secureRandom = SecureRandom() + secureRandom.nextBytes(bytes) + return bytes +} + +/** + * Generates an RSA key pair for encrypting/decrypting seed phrases. + * Matches the web implementation using RSA-OAEP with SHA-256. + * + * @return A Pair containing the Base64 encoded public key in SPKI format (first) + * and the KeyPair object (second) for later decryption + */ +fun generateRsaKeyPair(): Pair { + val keyPairGenerator = KeyPairGenerator.getInstance("RSA") + val parameterSpec = RSAKeyGenParameterSpec( + 2048, // modulusLength + BigInteger.valueOf(65537) // publicExponent (same as [1, 0, 1] in web) + ) + + keyPairGenerator.initialize(parameterSpec) + val keyPair = keyPairGenerator.generateKeyPair() + + // Export public key in SPKI + val publicKeyEncoded = keyPair.public.encoded + val publicKeyBase64 = Base64.encodeToString(publicKeyEncoded, Base64.NO_WRAP) + + return Pair(publicKeyBase64, keyPair) +} + +val oaepParams = OAEPParameterSpec( + "SHA-256", // digest algorithm + "MGF1", // mask generation function + MGF1ParameterSpec.SHA256, // MGF digest + PSource.PSpecified.DEFAULT // source of encoding input +) + +/** + * Decrypts an encrypted seed phrase response using an RSA key pair. + */ +fun decryptMnemonic(encryptedMnemonic: String, keyPair: KeyPair): String { + val cipher = Cipher.getInstance("RSA/None/OAEPPadding") + cipher.init(Cipher.DECRYPT_MODE, keyPair.private, oaepParams) + + val encryptedBytes = Base64.decode(encryptedMnemonic, Base64.DEFAULT) + val decryptedBytes = cipher.doFinal(encryptedBytes) + return String(decryptedBytes, Charsets.UTF_8) +} diff --git a/apps/mobile/android/app/src/main/java/com/uniswap/EthersRs.kt b/apps/mobile/android/app/src/main/java/com/uniswap/EthersRs.kt new file mode 100644 index 00000000..233c1917 --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/uniswap/EthersRs.kt @@ -0,0 +1,106 @@ +package com.uniswap + +/** + * These functions are defined from an object to be used from a static context. + * The Rust implementation contains JNI bindings that are generated from the definition here. + */ +object EthersRs { + + /** + * Validates a mnemonic string to check that each word exists in the BIP 39 wordlist. + * @param mnemonic - the mnemonic string + * @return The first invalid word. If there are none, an invalid string. + */ + external fun findInvalidWord(mnemonic: String): String + + /** + * General validation for a mnemonic string, including entropy. + * @param mnemonic - the mnemonic string + * @return True if valid and false if not. + */ + external fun validateMnemonic(mnemonic: String): Boolean + + /** + * Generates a mnemonic and its associated address. + * @return A CMnemonicAndAddress object containing the generated mnemonic and its associated address. + */ + external fun generateMnemonic(): MnemonicAndAddress + + /** + * Generates a private key from a given mnemonic. + * @param mnemonic The mnemonic to generate the private key from. + * @param index The index of the private key to generate. + * @return A CPrivateKey object containing the generated private key. + */ + external fun privateKeyFromMnemonic(mnemonic: String?, index: Int): PrivateKeyAndAddress + + /** + * Creates a wallet from a given private key. + * @param privateKey The private key to create the wallet from. + * @return A long representing the pointer to the created wallet. + */ + external fun walletFromPrivateKey(privateKey: String?): Long + + /** + * Frees the memory allocated for the wallet. + * @param walletPtr The pointer to the wallet to be freed. + */ + external fun walletFree(walletPtr: Long) + + /** + * Signs a transaction with a wallet. + * @param localWallet The wallet to sign the transaction with. + * @param txHash The transaction hash to sign. + * @param chainId The id of the blockchain network. + * @return A signed transaction hash. + */ + external fun signTxWithWallet( + localWallet: Long, + txHash: String, + chainId: Long + ): String + + /** + * Signs a message with a wallet. + * @param localWallet The wallet to sign the message with. + * @param message The message to sign. + * @return The signed message. + */ + external fun signMessageWithWallet( + localWallet: Long, + message: String + ): String + + /** + * Signs a hash with a wallet. + * @param localWallet The wallet to sign the hash with. + * @param hash The hash to sign. + * @param chainId The id of the blockchain network. + * @return The signed hash. + */ + external fun signHashWithWallet( + localWallet: Long, + hash: String, + chainId: Long + ): String +} + +/** + * Represents a private key and its associated address. + * @property privateKey The private key. + * @property address The address associated with the private key. + */ +class PrivateKeyAndAddress( + var privateKey: String, + var address: String, +) + +/** + * Represents a mnemonic and its associated address. + * @property mnemonic The mnemonic phrase. + * @property address The address associated with the mnemonic. + */ +class MnemonicAndAddress( + var mnemonic: String, + var address: String, +) diff --git a/apps/mobile/android/app/src/main/java/com/uniswap/GoogleDriveApiHelper.kt b/apps/mobile/android/app/src/main/java/com/uniswap/GoogleDriveApiHelper.kt new file mode 100644 index 00000000..72ee96be --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/uniswap/GoogleDriveApiHelper.kt @@ -0,0 +1,235 @@ +package com.uniswap + +import android.app.Activity +import android.content.Intent +import android.util.Log +import com.facebook.react.bridge.ActivityEventListener +import com.facebook.react.bridge.ReactApplicationContext +import com.google.android.gms.auth.api.signin.GoogleSignIn +import com.google.android.gms.auth.api.signin.GoogleSignInAccount +import com.google.android.gms.auth.api.signin.GoogleSignInClient +import com.google.android.gms.auth.api.signin.GoogleSignInOptions +import com.google.android.gms.common.api.ApiException +import com.google.android.gms.common.api.Scope +import com.google.api.client.googleapis.extensions.android.gms.auth.GoogleAccountCredential +import com.google.api.client.http.ByteArrayContent +import com.google.api.client.http.javanet.NetHttpTransport +import com.google.api.client.json.gson.GsonFactory +import com.google.api.services.drive.Drive +import com.google.api.services.drive.DriveScopes +import com.google.api.services.drive.model.File +import com.google.api.services.drive.model.FileList +import com.google.gson.Gson +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import java.nio.charset.StandardCharsets + +object GDriveParams { + const val SPACES = "appDataFolder" + const val FIELDS = "nextPageToken, files(id, name)" + const val PAGE_SIZE_NORMAL = 30 + const val PAGE_SIZE_SINGLE = 1 +} + +/** + * Helper class for Google Drive operations such as fetching and storing backups. + */ +class GoogleDriveApiHelper { + companion object { + + private val gson = Gson() + + /** + * Returns a GoogleSignInClient object, which is needed to access Google Drive. + * + * @param reactContext The react application context. + * @return GoogleSignInClient object + * @throws IllegalStateException if the activity context is null. + */ + private fun getGoogleSignInClient(reactContext: ReactApplicationContext): GoogleSignInClient { + val activity = reactContext.currentActivity + if (activity != null) { + val signInOptions = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN) + .requestEmail() + .requestScopes(Scope(DriveScopes.DRIVE_APPDATA)) + .build() + return GoogleSignIn.getClient(activity, signInOptions) + } else { + throw IllegalStateException("Activity cannot be null") + } + } + + /** + * Determines if the user has permission to Google Drive. + * + * @param reactContext The react application context. + * @return A Boolean value indicating whether the user has permission. + */ + private fun hasPermissionToGoogleDrive(reactContext: ReactApplicationContext): Boolean { + val acc = GoogleSignIn.getLastSignedInAccount(reactContext) + val hasPermissions = + acc?.let { GoogleSignIn.hasPermissions(acc, Scope(DriveScopes.DRIVE_APPDATA)) } + return hasPermissions == true + } + + /** + * Fetches cloud backups from Google Drive and triggers corresponding events. + * + * @param drive The authenticated Drive object of Google Drive. + */ + suspend fun fetchCloudBackupFiles( + drive: Drive + ): FileList { + return withContext(Dispatchers.IO) { + val files: FileList = drive.files().list() + .setSpaces(GDriveParams.SPACES) + .setFields(GDriveParams.FIELDS) + .setPageSize(GDriveParams.PAGE_SIZE_NORMAL) + .execute() + + files + } + } + + /** + * Asynchronously retrieves an authenticated GoogleDrive object by gaining Google Drive permissions. + * + * @param reactContext The react application context. + * @return An authenticated GoogleSignInAccount object. + */ + private suspend fun getGoogleDrivePermissions(reactContext: ReactApplicationContext): GoogleSignInAccount? = + suspendCancellableCoroutine { continuation -> + try { + val googleSignInClient = getGoogleSignInClient(reactContext) + googleSignInClient.signOut() // Force a sign out so that we can reselect account + val signInIntent = googleSignInClient.signInIntent + reactContext.currentActivity?.startActivityForResult( + signInIntent, + Request.GOOGLE_SIGN_IN.value + ) + val listener = object : ActivityEventListener { + override fun onActivityResult( + activity: Activity?, + requestCode: Int, + resultCode: Int, + intent: Intent? + ) { + // Remove the listener after using it + reactContext.removeActivityEventListener(this) + if (requestCode == Request.GOOGLE_SIGN_IN.value && resultCode == Activity.RESULT_OK) { + + val signInTask = GoogleSignIn.getSignedInAccountFromIntent(intent) + val account: GoogleSignInAccount? = + signInTask.getResult(ApiException::class.java) + continuation.resumeWith(Result.success(account)) + + } else { + continuation.resumeWith(Result.failure(Exception("Oauth process has been interrupted"))) + Log.d("Activity intent", "Intent null") + } + } + + override fun onNewIntent(p0: Intent?) {} + } + reactContext.addActivityEventListener(listener) + } catch (e: Exception) { + Log.e("EXCEPTION", "${e.message}") + continuation.resumeWith( + Result.failure( + Exception("Failed to get google drive account") + ) + ) + } + } + + /** + * Asynchronously retrieves an authenticated Drive object. + * + * @param reactContext The react application context. + * @return Google Drive object if user has permissions, `null` otherwise. + */ + suspend fun getGoogleDrive( + reactContext: ReactApplicationContext, + useRecentAccount: Boolean = false + ): Pair { + return withContext(Dispatchers.IO) { + val canUseRecentAccount = useRecentAccount && hasPermissionToGoogleDrive(reactContext) + val account = + if (canUseRecentAccount) + GoogleSignIn.getLastSignedInAccount(reactContext) + else + getGoogleDrivePermissions(reactContext) + + val drive = account?.let { + val credential = + GoogleAccountCredential.usingOAuth2(reactContext, listOf(DriveScopes.DRIVE_APPDATA)) + credential.selectedAccount = account.account!! + + Drive.Builder( + NetHttpTransport(), + GsonFactory.getDefaultInstance(), + credential + ) + .setApplicationName(reactContext.getString(R.string.app_name)) + .build() + } + + Pair(drive, account) + } + } + + /** + * Fetches the fileId of a file in Google Drive by its file name. + * Assuming there is no bug in code, should always be only one file with the given name + * even though google drive allows to store multiple files with the same name + * + * @param drive The authenticated Drive object of Google Drive. + * @param name Name of the file. + * @return String fileId if file exists, `null` otherwise. + */ + fun getFileIdByFileName(drive: Drive, name: String): String? { + try { + val files: FileList = drive.files().list() + .setSpaces(GDriveParams.SPACES) + .setFields(GDriveParams.FIELDS) + .setPageSize(GDriveParams.PAGE_SIZE_SINGLE) + .setQ("name = '$name.json'") + .execute() + return files.files.firstOrNull()?.id + } catch (e: Exception) { + e.printStackTrace() + } + return null + } + + /** + * Uploads mnemonic backup to google drive in json formant + * + * @param drive The authenticated Drive object of Google Drive. + * @param mnemonicId Id of saved mnemonic. + * @param backup Instance of [CloudStorageMnemonicBackup] object representing mnemonic backup. + * @return String fileId if file exists, `null` otherwise. + */ + fun saveMnemonicToGoogleDrive( + drive: Drive, + mnemonicId: String, + backup: CloudStorageMnemonicBackup + ) { + val fileMetadata = File() + fileMetadata.name = "$mnemonicId.json" + fileMetadata.parents = listOf("appDataFolder") + + val jsonData = gson.toJson(backup) + + val jsonByteArray = jsonData.toByteArray(StandardCharsets.UTF_8) + val inputContent = ByteArrayContent("application/json", jsonByteArray) + val fileId = getFileIdByFileName(drive, mnemonicId) + if (fileId != null) { + drive.files().delete(fileId).execute() + } + drive.files().create(fileMetadata, inputContent) + .execute() + } + } +} diff --git a/apps/mobile/android/app/src/main/java/com/uniswap/MainActivity.kt b/apps/mobile/android/app/src/main/java/com/uniswap/MainActivity.kt new file mode 100644 index 00000000..ee40024b --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/uniswap/MainActivity.kt @@ -0,0 +1,49 @@ +package com.uniswap + +import android.graphics.Color +import android.os.Build +import android.os.Bundle +import android.view.View +import com.facebook.react.ReactActivity +import com.facebook.react.ReactActivityDelegate +import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.concurrentReactEnabled +import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled +import com.facebook.react.defaults.DefaultReactActivityDelegate +import com.facebook.react.modules.i18nmanager.I18nUtil +import expo.modules.ReactActivityDelegateWrapper +import com.zoontek.rnbootsplash.RNBootSplash; + + +class MainActivity : ReactActivity() { + + /** + * Returns the name of the main component registered from JavaScript. This is used to schedule + * rendering of the component. + */ + override fun getMainComponentName(): String { + return "Uniswap" + } + + // Required for react-navigation to work on Android + override fun onCreate(savedInstanceState: Bundle?) { + RNBootSplash.init(this, R.style.AppTheme) + + super.onCreate(null); + + window.navigationBarColor = Color.TRANSPARENT + + if (Build.VERSION_CODES.Q <= Build.VERSION.SDK_INT) { + window.isNavigationBarContrastEnforced = false + } + val sharedI18nUtilInstance = I18nUtil.getInstance() + sharedI18nUtilInstance.allowRTL(applicationContext, false) + } + + /** + * Returns the instance of the [ReactActivityDelegate]. Here we use a util class [ ] which allows you to easily enable Fabric and Concurrent React + * (aka React 18) with two boolean flags. + */ + override fun createReactActivityDelegate(): ReactActivityDelegate? { + return ReactActivityDelegateWrapper(this, BuildConfig.IS_NEW_ARCHITECTURE_ENABLED, DefaultReactActivityDelegate(this, mainComponentName, fabricEnabled)) + } +} diff --git a/apps/mobile/android/app/src/main/java/com/uniswap/MainApplication.kt b/apps/mobile/android/app/src/main/java/com/uniswap/MainApplication.kt new file mode 100644 index 00000000..21c5c327 --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/uniswap/MainApplication.kt @@ -0,0 +1,64 @@ +package com.uniswap + +import android.app.Application +import android.content.res.Configuration +import com.facebook.react.PackageList +import com.facebook.react.ReactApplication +import com.facebook.react.ReactHost +import com.facebook.react.ReactNativeHost +import com.facebook.react.ReactPackage +import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.load +import com.facebook.react.defaults.DefaultReactNativeHost +import com.facebook.react.soloader.OpenSourceMergedSoMapping +import com.facebook.react.defaults.DefaultReactHost.getDefaultReactHost +import com.facebook.soloader.SoLoader +import com.shopify.reactnativeperformance.ReactNativePerformance +import com.uniswap.onboarding.scantastic.ScantasticEncryptionModule +import expo.modules.ApplicationLifecycleDispatcher +import expo.modules.ReactNativeHostWrapper + +class MainApplication : Application(), ReactApplication { + override val reactNativeHost: ReactNativeHost = + ReactNativeHostWrapper(this, object : DefaultReactNativeHost(this) { + override fun getPackages(): List = + PackageList(this).packages.apply { + // Packages that cannot be autolinked yet can be added manually here, for example: + // packages.add(new MyReactNativePackage()); + add(UniswapPackage()) + add(RNCloudStorageBackupsManagerModule()) + add(ScantasticEncryptionModule()) + add(RedirectToSourceAppPackage()) + } + override fun getJSMainModuleName(): String = ".expo/.virtual-metro-entry" + + override fun getUseDeveloperSupport(): Boolean { + return BuildConfig.DEBUG + } + + override val isNewArchEnabled: Boolean + get() = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED + }) + + override val reactHost: ReactHost + get() = ReactNativeHostWrapper.createReactHost(applicationContext, reactNativeHost) + + override fun onCreate() { + ReactNativePerformance.onAppStarted() + super.onCreate() + + // Initialize SoLoader before any code that might load native libraries + SoLoader.init(this, OpenSourceMergedSoMapping) + if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) { + // If you opted-in for the New Architecture, we load the native entry point for this app. + load() + } + + // Initialize Expo modules after SoLoader + ApplicationLifecycleDispatcher.onApplicationCreate(this) + } + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + ApplicationLifecycleDispatcher.onConfigurationChanged(this, newConfig) + } +} diff --git a/apps/mobile/android/app/src/main/java/com/uniswap/RNCloudStorageBackupsManager.kt b/apps/mobile/android/app/src/main/java/com/uniswap/RNCloudStorageBackupsManager.kt new file mode 100644 index 00000000..c67d59ee --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/uniswap/RNCloudStorageBackupsManager.kt @@ -0,0 +1,297 @@ +package com.uniswap + +import android.util.Log +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactContextBaseJavaModule +import com.facebook.react.bridge.ReactMethod +import com.facebook.react.bridge.WritableMap +import com.facebook.react.bridge.WritableArray +import com.facebook.react.modules.core.DeviceEventManagerModule +import com.google.android.gms.common.ConnectionResult +import com.google.android.gms.common.GoogleApiAvailability +import com.google.gson.Gson +import decrypt +import encrypt +import generateSalt +import generateNonce +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.ByteArrayOutputStream +import java.io.FileNotFoundException +import java.util.Date +import javax.crypto.BadPaddingException +import javax.crypto.IllegalBlockSizeException +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import java.util.Collections + +/** + * Data class representing a mnemonic backup in cloud storage. + * + * @property mnemonicId The ID of the mnemonic string. + * @property encryptedMnemonic The encrypted mnemonic string. + * @property encryptionSalt The salt used for generating the encryption key from password. + * @property encryptionNonce The nonce used for encryption. + * @property createdAt The time the backup was created, in seconds since the epoch. + */ +data class CloudStorageMnemonicBackup( + val mnemonicId: String, + val encryptedMnemonic: String, + val encryptionSalt: String, + val encryptionNonce: ByteArray, + val createdAt: Double, + val googleDriveEmail: String? = null +) + +/** + * Enum representing various types of cloud backup errors. + */ +enum class CloudBackupError(val value: String) { + BACKUP_NOT_FOUND_ERROR("backupNotFoundError"), + BACKUP_ENCRYPTION_ERROR("backupEncryptionError"), + BACKUP_DECRYPTION_ERROR("backupDecryptionError"), + BACKUP_INCORRECT_PASSWORD_ERROR("backupIncorrectPasswordError"), + DELETE_BACKUP_ERROR("deleteBackupError"), + CLOUD_ERROR("cloudError") +} + +/** + * Enum representing various types of requests. + */ +enum class Request(val value: Int) { + GOOGLE_SIGN_IN(122) +} + +/** + * Class for managing cloud storage backups on android for React Native. + * + * @property reactContext The react application context. + */ +class RNCloudStorageBackupsManager(private val reactContext: ReactApplicationContext) : + ReactContextBaseJavaModule(reactContext) { + + override fun getName() = "RNCloudStorageBackupsManager" + + private val rnEthersRS = RnEthersRs(reactContext) + + private val gson = Gson() + + /** + * Checks if cloud storage services (like Google Play Services) are available. + * There is no way to check if just google drive api is available + * + * @param promise A promise to return the result of the operation. + */ + @ReactMethod + fun isCloudStorageAvailable(promise: Promise) { + val googleApiAvailability = GoogleApiAvailability.getInstance() + val resultCode = googleApiAvailability.isGooglePlayServicesAvailable(reactContext) + promise.resolve(resultCode == ConnectionResult.SUCCESS) + } + + /** + * Fetches list of backups and returns it by resolving a promise promise. + * + * @param promise A promise to return the result of the operation. + */ + @ReactMethod + fun getCloudBackupList(promise: Promise) { + CoroutineScope(Dispatchers.Main).launch { + try { + GoogleDriveApiHelper.getGoogleDrive(reactContext).let { (drive) -> + if (drive == null) return@launch + GoogleDriveApiHelper.fetchCloudBackupFiles(drive).let { files -> + val backupDeferreds = files.files.map { file -> + async(Dispatchers.IO) { + val outputStream = ByteArrayOutputStream() + drive.files()[file.id] + .executeMediaAndDownloadTo(outputStream) + val mnemonicBackup: CloudStorageMnemonicBackup = gson.fromJson(outputStream.toString(), CloudStorageMnemonicBackup::class.java) + + val backup: WritableMap = Arguments.createMap() + backup.putString("mnemonicId", mnemonicBackup.mnemonicId) + backup.putString("createdAt", mnemonicBackup.createdAt.toString()) + backup.putString("googleDriveEmail", mnemonicBackup.googleDriveEmail) + + backup + } + } + + val backups = backupDeferreds.awaitAll() + + val resultArray: WritableArray = Arguments.createArray() + backups.forEach { resultArray.pushMap(it) } + promise.resolve(resultArray) + } + } + } catch (e: Exception) { + promise.reject(CloudBackupError.CLOUD_ERROR.value, "Failed to fetch cloud backups") + } + } + } + + /** + * Backs up a mnemonic string as a json file with mnemonicId as a name to google drive api. + * Backup is stored in app specific folder which is not accessible to other apps. + * + * @param mnemonicId The ID of the mnemonic string. + * @param password The password used for encryption. + * @param promise A promise to return the result of the operation. + */ + @ReactMethod + fun backupMnemonicToCloudStorage(mnemonicId: String, password: String, promise: Promise) { + CoroutineScope(Dispatchers.Default).launch { + try { + val mnemonic = rnEthersRS.retrieveMnemonic(mnemonicId) + ?: throw Exception("rnEthersRs module retrieve mnemonic null") + val encryptionSalt = generateSalt(16) + val encryptionNonce = generateNonce(12) + val encryptedMnemonic = + withContext(Dispatchers.IO) { encrypt(mnemonic, password, encryptionSalt, encryptionNonce) } + GoogleDriveApiHelper.getGoogleDrive(reactContext).let { (drive, acc) -> + if (drive == null) return@launch + val createdAt = Date().time / 1000.0 + val backup = CloudStorageMnemonicBackup( + mnemonicId, + encryptedMnemonic, + encryptionSalt, + encryptionNonce, + createdAt, + acc?.email + ) + withContext(Dispatchers.IO) { + GoogleDriveApiHelper.saveMnemonicToGoogleDrive(drive, mnemonicId, backup) + } + } + promise.resolve(true) + } catch (e: Exception) { + promise.reject( + CloudBackupError.BACKUP_ENCRYPTION_ERROR.value, + "Failed to encrypt mnemonics: ${e.message}", + e, + ) + } + } + } + + /** + * Restores a mnemonic string from a backup in google drive. + * + * @param mnemonicId The ID of the mnemonic string. + * @param password The password used for decryption. + * @param promise A promise to return the result of the operation. + */ + @ReactMethod + fun restoreMnemonicFromCloudStorage(mnemonicId: String, password: String, promise: Promise) { + CoroutineScope(Dispatchers.Main).launch { + try { + GoogleDriveApiHelper.getGoogleDrive(reactContext, true).let { (drive) -> + if (drive == null) return@launch + val fileId = withContext(Dispatchers.IO) { + GoogleDriveApiHelper.getFileIdByFileName( + drive, + mnemonicId + ) + } + if (fileId == null) { + promise.reject( + CloudBackupError.BACKUP_NOT_FOUND_ERROR.value, + "The file $mnemonicId is not found in Google Drive" + ) + } + val outputStream = ByteArrayOutputStream() + withContext(Dispatchers.IO) { + drive.files().get(fileId).executeMediaAndDownloadTo(outputStream) + } + var mnemonicsBackup: CloudStorageMnemonicBackup? + var decryptedMnemonics: String? = null + + withContext(Dispatchers.IO) { + mnemonicsBackup = + gson.fromJson(outputStream.toString(), CloudStorageMnemonicBackup::class.java) + } + + val encryptedMnemonic = mnemonicsBackup?.encryptedMnemonic + val encryptionSalt = mnemonicsBackup?.encryptionSalt + val encryptionNonce = mnemonicsBackup?.encryptionNonce + + if (encryptedMnemonic == null || encryptionSalt == null || encryptionNonce == null) throw Exception("Failed to read mnemonics backup") + + try { + decryptedMnemonics = withContext(Dispatchers.IO) { + decrypt(encryptedMnemonic, password, encryptionSalt, encryptionNonce) + } + } catch (e: BadPaddingException) { + Log.e("EXCEPTION", "${e.message}") + promise.reject( + CloudBackupError.BACKUP_INCORRECT_PASSWORD_ERROR.value, + "Incorrect decryption password" + ) + } catch (e: IllegalBlockSizeException) { + Log.e("EXCEPTION", "${e.message}") + promise.reject( + CloudBackupError.BACKUP_DECRYPTION_ERROR.value, + "Incorrect decryption password" + ) + } catch (e: Exception) { + Log.e("EXCEPTION", "${e.message}") + promise.reject( + CloudBackupError.BACKUP_DECRYPTION_ERROR.value, + "Failed to decrypt mnemonics" + ) + } + + rnEthersRS.storeNewMnemonic(mnemonic = decryptedMnemonics, address = mnemonicId) + promise.resolve(true) + } + } catch (e: Exception) { + Log.e("EXCEPTION", "${e.message}") + promise.reject( + CloudBackupError.BACKUP_NOT_FOUND_ERROR.value, + "Backup file not found in local storage" + ) + } + } + } + + /** + * Deletes a mnemonic backup from google drive. + * + * @param mnemonicId The ID of the mnemonic backup. + * @param promise A promise to return the result of the operation. + */ + @ReactMethod + fun deleteCloudStorageMnemonicBackup(mnemonicId: String, promise: Promise) { + CoroutineScope(Dispatchers.Main).launch { + try { + GoogleDriveApiHelper.getGoogleDrive(reactContext, true).let { (drive) -> + if (drive == null) return@launch + withContext(Dispatchers.IO) { + val fileId = GoogleDriveApiHelper.getFileIdByFileName(drive, mnemonicId) + if (fileId == null) { + GoogleDriveApiHelper.getGoogleDrive(reactContext).let { (drive) -> + if (drive == null) return@let + val fileId = GoogleDriveApiHelper.getFileIdByFileName(drive, mnemonicId) + ?: throw FileNotFoundException("Failed to locate backup") + drive.files().delete(fileId).execute() + } + } else { + drive.files().delete(fileId).execute() + } + } + } + promise.resolve(true) + } catch (e: FileNotFoundException) { + Log.e("EXCEPTION", "${e.message}") + promise.reject(CloudBackupError.DELETE_BACKUP_ERROR.value, "Failed to locate backup") + } catch (e: Exception) { + Log.e("EXCEPTION", "${e.message}") + promise.reject(CloudBackupError.DELETE_BACKUP_ERROR.value, "Failed to delete backup") + } + } + } +} diff --git a/apps/mobile/android/app/src/main/java/com/uniswap/RNCloudStorageBackupsManagerModule.kt b/apps/mobile/android/app/src/main/java/com/uniswap/RNCloudStorageBackupsManagerModule.kt new file mode 100644 index 00000000..0c1f140e --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/uniswap/RNCloudStorageBackupsManagerModule.kt @@ -0,0 +1,19 @@ +package com.uniswap + +import android.view.View +import com.facebook.react.ReactPackage +import com.facebook.react.bridge.NativeModule +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.uimanager.ReactShadowNode +import com.facebook.react.uimanager.ViewManager + +class RNCloudStorageBackupsManagerModule : ReactPackage { + + override fun createViewManagers( + reactContext: ReactApplicationContext + ): MutableList>> = mutableListOf() + + override fun createNativeModules( + reactContext: ReactApplicationContext + ): MutableList = listOf(RNCloudStorageBackupsManager(reactContext)).toMutableList() +} diff --git a/apps/mobile/android/app/src/main/java/com/uniswap/RNEthersRSModule.kt b/apps/mobile/android/app/src/main/java/com/uniswap/RNEthersRSModule.kt new file mode 100644 index 00000000..8d09ed1f --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/uniswap/RNEthersRSModule.kt @@ -0,0 +1,101 @@ +package com.uniswap; +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactContextBaseJavaModule +import com.facebook.react.bridge.ReactMethod +import com.facebook.react.bridge.WritableArray +import com.facebook.react.bridge.WritableNativeArray +import com.facebook.react.module.annotations.ReactModule +import com.facebook.soloader.SoLoader +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch + +/** + * Bridge between the React Native JavaScript code and the native Android code. + * It provides several methods that can be called from JavaScript, using the @ReactMethod annotation. + * The module uses the RnEthersRs class, which is initialized with the application context. + * The native library "ethers_ffi" is loaded when the module is initialized (`libethers_ffi.so`). + */ +@ReactModule(name = "RNEthersRS") +class RNEthersRSModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) { + + private val ethersRs: RnEthersRs = RnEthersRs(reactContext.applicationContext) + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + // Needs to be initialized form a static context + companion object { + init { + SoLoader.loadLibrary("ethers_ffi") + } + } + + override fun getName() = "RNEthersRS" + + @ReactMethod fun getMnemonicIds(promise: Promise) { + val array = WritableNativeArray() + ethersRs.mnemonicIds.forEach { + array.pushString(it) + } + promise.resolve(array) + } + + @ReactMethod fun importMnemonic(mnemonic: String, promise: Promise) { + promise.resolve(ethersRs.importMnemonic(mnemonic)) + } + + @ReactMethod fun removeMnemonic(mnemonicId: String, promise: Promise) { + scope.launch(Dispatchers.IO) { + val result = ethersRs.removeMnemonic(mnemonicId) + promise.resolve(result) + } + } + + @ReactMethod fun generateAndStoreMnemonic(promise: Promise) { + promise.resolve(ethersRs.generateAndStoreMnemonic()) + } + + @ReactMethod fun getAddressesForStoredPrivateKeys(promise: Promise) { + val addresses = ethersRs.addressesForStoredPrivateKeys + + // Convert the List to a WritableArray for passing over the bridge + val writableArray: WritableArray = WritableNativeArray() + for (address in addresses) { + writableArray.pushString(address) + } + + promise.resolve(writableArray) + } + + @ReactMethod fun generateAddressForMnemonic(mnemonic: String, derivationIndex: Int, promise: Promise) { + promise.resolve(ethersRs.generateAddressForMnemonic(mnemonic, derivationIndex)) + } + + @ReactMethod fun generateAndStorePrivateKey(mnemonicId: String, derivationIndex: Int, promise: Promise) { + try { + promise.resolve(ethersRs.generateAndStorePrivateKey(mnemonicId, derivationIndex)) + } catch (error: Exception) { + promise.reject(error) + } + } + + @ReactMethod fun removePrivateKey(address: String, promise: Promise) { + scope.launch(Dispatchers.IO) { + val result = ethersRs.removePrivateKey(address) + promise.resolve(result) + } + } + + @ReactMethod fun signTransactionHashForAddress(address: String, hash: String, chainId: Int, promise: Promise) { + promise.resolve(ethersRs.signTransactionHashForAddress(address, hash, chainId.toLong())) + } + + @ReactMethod fun signMessageForAddress(address: String, message: String, promise: Promise) { + promise.resolve(ethersRs.signMessageForAddress(address, message)) + } + + @ReactMethod fun signHashForAddress(address: String, hash: String, chainId: Int, promise: Promise) { + promise.resolve(ethersRs.signHashForAddress(address, hash, chainId.toLong())) + } +} diff --git a/apps/mobile/android/app/src/main/java/com/uniswap/RedirectToSourceAppModule.kt b/apps/mobile/android/app/src/main/java/com/uniswap/RedirectToSourceAppModule.kt new file mode 100644 index 00000000..7740e84a --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/uniswap/RedirectToSourceAppModule.kt @@ -0,0 +1,19 @@ +package com.uniswap + +import android.content.Intent +import android.net.Uri +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactContextBaseJavaModule +import com.facebook.react.bridge.ReactMethod + +class RedirectToSourceAppModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) { + + override fun getName(): String { + return "RedirectToSourceApp" + } + + @ReactMethod + fun moveAppToBackground() { + currentActivity?.moveTaskToBack(true) + } +} diff --git a/apps/mobile/android/app/src/main/java/com/uniswap/RedirectToSourceAppPackage.kt b/apps/mobile/android/app/src/main/java/com/uniswap/RedirectToSourceAppPackage.kt new file mode 100644 index 00000000..a3916b67 --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/uniswap/RedirectToSourceAppPackage.kt @@ -0,0 +1,16 @@ +package com.uniswap + +import com.facebook.react.ReactPackage +import com.facebook.react.bridge.NativeModule +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.uimanager.ViewManager + +class RedirectToSourceAppPackage : ReactPackage { + override fun createNativeModules(reactContext: ReactApplicationContext): List { + return listOf(RedirectToSourceAppModule(reactContext)) + } + + override fun createViewManagers(reactContext: ReactApplicationContext): List> { + return emptyList() + } +} diff --git a/apps/mobile/android/app/src/main/java/com/uniswap/RnEthersRs.kt b/apps/mobile/android/app/src/main/java/com/uniswap/RnEthersRs.kt new file mode 100644 index 00000000..0b2ab227 --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/uniswap/RnEthersRs.kt @@ -0,0 +1,213 @@ +package com.uniswap + +import android.content.Context +import android.content.SharedPreferences +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKeys +import com.uniswap.EthersRs.generateMnemonic +import com.uniswap.EthersRs.privateKeyFromMnemonic +import com.uniswap.EthersRs.signHashWithWallet +import com.uniswap.EthersRs.signMessageWithWallet +import com.uniswap.EthersRs.signTxWithWallet +import com.uniswap.EthersRs.walletFromPrivateKey +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class RnEthersRs(applicationContext: Context) { + + // Long represents the opaque pointer to the Rust LocalWallet struct. + private val walletCache: MutableMap = mutableMapOf() + private val keychain: SharedPreferences + + init { + val masterKeyAlias = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC) + keychain = EncryptedSharedPreferences.create( + "preferences", + masterKeyAlias, + applicationContext, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ) + } + + val mnemonicIds: List + get() = keychain.all.keys.filter { + // MOB-3453 this will need to be updated after fixing prefixes + it.startsWith(MNEMONIC_PREFIX) + }.map { + key -> key.replace(MNEMONIC_PREFIX, "") + } + + /** + * Imports a mnemonic and returns the associated address. + * @param mnemonic The mnemonic to import. + * @return The address associated with the mnemonic. + */ + fun importMnemonic(mnemonic: String): String { + val privateKey = privateKeyFromMnemonic(mnemonic, 0) + val address = privateKey.address + return storeNewMnemonic(mnemonic, address) + } + + /** + * Generates a new mnemonic, stores it, and returns the associated address. + * @return The address associated with the new mnemonic. + */ + fun generateAndStoreMnemonic(): String { + val mnemonic = generateMnemonic() + val mnemonicStr = mnemonic.mnemonic + val addressStr = mnemonic.address + return storeNewMnemonic(mnemonicStr, addressStr) + } + + /** + * Stores a new mnemonic and its associated address. + * @param mnemonic The mnemonic to store. + * @param address The address associated with the mnemonic. + * @return The address. + */ + fun storeNewMnemonic(mnemonic: String?, address: String): String { + val checkStored = retrieveMnemonic(address) + if (checkStored == null) { + val newMnemonicKey = keychainKeyForMnemonicId(address) + keychain.edit().putString(newMnemonicKey, mnemonic).apply() + } + return address + } + + + private fun keychainKeyForMnemonicId(mnemonicId: String): String { + return MNEMONIC_PREFIX + mnemonicId + } + + fun retrieveMnemonic(mnemonicId: String): String? { + return keychain.getString(keychainKeyForMnemonicId(mnemonicId), null) + } + + suspend fun removeMnemonic(mnemonicId: String): Boolean { + keychain.edit().remove(keychainKeyForMnemonicId(mnemonicId)).apply() + return true + } + + val addressesForStoredPrivateKeys: List + get() = keychain.all.keys + .filter { key -> key.contains(PRIVATE_KEY_PREFIX) } + .map { key -> key.replace(PRIVATE_KEY_PREFIX, "") } + + private fun storeNewPrivateKey(address: String, privateKey: String?) { + val newKey = keychainKeyForPrivateKey(address) + keychain.edit().putString(newKey, privateKey).apply() + } + + /** + * Generates public address for a given mnemonic and returns the associated address. + * @param mnemonic Mmnemonic to generate the public address from. + * @param derivationIndex The index of the private key to generate. + * @return The address associated with the new private key. + */ + fun generateAddressForMnemonic(mnemonic: String, derivationIndex: Int): String { + val privateKey = privateKeyFromMnemonic(mnemonic, derivationIndex) + return privateKey.address + } + + /** + * Generates and stores a new private key for a given mnemonic and returns the associated address. + * @param mnemonicId The id of the mnemonic to generate the private key from. + * @param derivationIndex The index of the private key to generate. + * @return The address associated with the new private key. + */ + fun generateAndStorePrivateKey(mnemonicId: String, derivationIndex: Int): String { + val mnemonic = retrieveMnemonic(mnemonicId) + ?: throw IllegalArgumentException("Mnemonic not found") + + val privateKey = privateKeyFromMnemonic(mnemonic, derivationIndex) + val xprv = privateKey.privateKey + val address = privateKey.address + storeNewPrivateKey(address, xprv) + return address + } + + suspend fun removePrivateKey(address: String): Boolean { + keychain.edit().remove(keychainKeyForPrivateKey(address)).apply() + return true + } + + /** + * Signs a transaction for a given address. + * @param address The address to sign the transaction for. + * @param hash The transaction hash to sign. + * @param chainId The id of the blockchain network. + * @return The signed transaction hash. + */ + fun signTransactionHashForAddress(address: String, hash: String, chainId: Long): String { + val wallet = retrieveOrCreateWalletForAddress(address) + return signTxWithWallet(wallet, hash, chainId) + } + + /** + * Signs a message for a given address. + * @param address The address to sign the message for. + * @param message The message to sign. + * @return The signed message. + */ + fun signMessageForAddress(address: String, message: String): String { + val wallet = retrieveOrCreateWalletForAddress(address) + return signMessageWithWallet(wallet, message) + } + + /** + * Signs a hash for a given address. + * @param address The address to sign the hash for. + * @param hash The hash to sign. + * @param chainId The id of the blockchain network. + * @return The signed hash. + */ + fun signHashForAddress(address: String, hash: String, chainId: Long): String { + val wallet = retrieveOrCreateWalletForAddress(address) + return signHashWithWallet(wallet, hash, chainId) + } + + /** + * Retrieves an existing wallet for a given address or creates a new one if it doesn't exist. + * @param address The address of the wallet. + * @return A long representing the pointer to the wallet. + */ + private fun retrieveOrCreateWalletForAddress(address: String): Long { + val wallet = walletCache[address] + if (wallet != null) { + return wallet + } + val privateKey = retrievePrivateKey(address) + val newWallet = walletFromPrivateKey(privateKey) + walletCache[address] = newWallet + return newWallet + } + + /** + * Retrieves the private key for a given address. + * @param address The address to retrieve the private key for. + * @return The private key, or null if it doesn't exist. + */ + fun retrievePrivateKey(address: String): String? { + return keychain.getString(keychainKeyForPrivateKey(address), null) + } + /** + * Generates the keychain key for a given address. + * @param address The address to generate the key for. + * @return The keychain key. + */ + private fun keychainKeyForPrivateKey(address: String): String { + return PRIVATE_KEY_PREFIX + address + } + + companion object { + private const val PREFIX = "com.uniswap" + private const val MNEMONIC_PREFIX = ".mnemonic." + private const val PRIVATE_KEY_PREFIX = ".privateKey." + // MOB-3453 Android is currently not storing keys with PREFIX + private const val ENTIRE_MNEMONIC_PREFIX = PREFIX + MNEMONIC_PREFIX + } +} diff --git a/apps/mobile/android/app/src/main/java/com/uniswap/ThemeModule.kt b/apps/mobile/android/app/src/main/java/com/uniswap/ThemeModule.kt new file mode 100644 index 00000000..053bca77 --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/uniswap/ThemeModule.kt @@ -0,0 +1,58 @@ +package com.uniswap +import android.app.Activity +import android.os.Build +import android.view.View +import android.view.WindowInsetsController +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactContextBaseJavaModule +import com.facebook.react.bridge.ReactMethod +import android.content.Context +import android.content.res.Configuration + +import androidx.appcompat.app.AppCompatDelegate + +class ThemeModule(private val reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) { + + override fun getName() = "ThemeModule" + + @ReactMethod fun setColorScheme(style: String) { + val activity = currentActivity + when (style) { + "dark" -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES); + "light" -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO); + "system" -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM); + } + val isLightTheme = style == "light" || (style == "system" && !isSystemInDarkTheme(reactContext)) + if (activity != null) setBottomNavigationTheme(activity, isLightTheme) + } + companion object { + fun setBottomNavigationTheme(activity: Activity, isLightTheme: Boolean) { + val window = activity.window + activity.runOnUiThread { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + window.setDecorFitsSystemWindows(false) + window.insetsController?.let { + it.setSystemBarsAppearance( + if (isLightTheme) WindowInsetsController.APPEARANCE_LIGHT_NAVIGATION_BARS else 0, + WindowInsetsController.APPEARANCE_LIGHT_NAVIGATION_BARS + ) + } + } else { + //TODO: Deprecated method won't allow for dynamic switch so it's safer to hardcoded dark buttons layout + @Suppress("DEPRECATION") + window.decorView.systemUiVisibility = ( + View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION + or View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR + ) + } + } + } + fun isSystemInDarkTheme(context: Context): Boolean { + return when (context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) { + Configuration.UI_MODE_NIGHT_YES -> true + Configuration.UI_MODE_NIGHT_NO -> false + else -> false + } + } + } +} diff --git a/apps/mobile/android/app/src/main/java/com/uniswap/UniswapPackage.kt b/apps/mobile/android/app/src/main/java/com/uniswap/UniswapPackage.kt new file mode 100644 index 00000000..16776573 --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/uniswap/UniswapPackage.kt @@ -0,0 +1,34 @@ +package com.uniswap + +import android.view.View +import com.facebook.react.ReactPackage +import com.facebook.react.bridge.NativeModule +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.uimanager.ReactShadowNode +import com.facebook.react.uimanager.ViewManager +import com.uniswap.notifications.SilentPushEventEmitterModule +import com.uniswap.onboarding.backup.MnemonicConfirmationViewManager +import com.uniswap.onboarding.backup.MnemonicDisplayViewManager +import com.uniswap.onboarding.import.SeedPhraseInputViewManager +import com.uniswap.onboarding.privatekeys.PrivateKeyDisplayViewManager + +class UniswapPackage : ReactPackage { + override fun createViewManagers( + reactContext: ReactApplicationContext + ): List>> = listOf( + MnemonicConfirmationViewManager(), + MnemonicDisplayViewManager(), + SeedPhraseInputViewManager(), + PrivateKeyDisplayViewManager() + ) + + override fun createNativeModules( + reactContext: ReactApplicationContext + ): List = listOf( + AndroidDeviceModule(reactContext), + RNEthersRSModule(reactContext), + EmbeddedWalletModule(reactContext), + ThemeModule(reactContext), + SilentPushEventEmitterModule(reactContext), + ) +} diff --git a/apps/mobile/android/app/src/main/java/com/uniswap/extensions/ScrollFadeExtensions.kt b/apps/mobile/android/app/src/main/java/com/uniswap/extensions/ScrollFadeExtensions.kt new file mode 100644 index 00000000..aec34e60 --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/uniswap/extensions/ScrollFadeExtensions.kt @@ -0,0 +1,66 @@ +package com.uniswap.extensions + +import android.annotation.SuppressLint +import android.os.Build +import androidx.compose.foundation.ScrollState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.uniswap.theme.UniswapTheme +import java.lang.Float.min + +@SuppressLint("ComposableModifierFactory") +@Composable +fun Modifier.fadingEdges( + scrollState: ScrollState, + topEdgeHeight: Dp = 0.dp, + bottomEdgeHeight: Dp = UniswapTheme.spacing.spacing48, +): Modifier = this.then(Modifier + // adding layer fixes issue with blending gradient and content + .graphicsLayer { alpha = 0.99F } + .drawWithContent { + drawContent() + + val topColors = listOf(Color.Transparent, Color.Black) + val topStartY = scrollState.value.toFloat() + val topGradientHeight = min(topEdgeHeight.toPx(), topStartY) + val topGradientBrush = Brush.verticalGradient( + colors = topColors, startY = topStartY, endY = topStartY + topGradientHeight + ) + + val bottomColors = listOf(Color.Black, Color.Transparent) + val bottomEndY = size.height - scrollState.maxValue + scrollState.value + val bottomGradientHeight = + min(bottomEdgeHeight.toPx(), scrollState.maxValue.toFloat() - scrollState.value) + val bottomGradientBrush = Brush.verticalGradient( + colors = bottomColors, startY = bottomEndY - bottomGradientHeight, endY = bottomEndY + ) + + // Render gradient with blend mode on Android Q and above + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + drawRect( + brush = topGradientBrush, blendMode = BlendMode.DstIn + ) + if (bottomGradientHeight != 0f) { + drawRect( + brush = bottomGradientBrush, blendMode = BlendMode.DstIn + ) + } + // Otherwise, render gradient without blend mode if the blend mode is not supported + } else { + drawRect( + brush = topGradientBrush, + ) + if (bottomGradientHeight != 0f) { + drawRect( + brush = bottomGradientBrush + ) + } + } + }) diff --git a/apps/mobile/android/app/src/main/java/com/uniswap/notifications/SilentPushEventEmitterModule.kt b/apps/mobile/android/app/src/main/java/com/uniswap/notifications/SilentPushEventEmitterModule.kt new file mode 100644 index 00000000..9c2504ec --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/uniswap/notifications/SilentPushEventEmitterModule.kt @@ -0,0 +1,129 @@ +package com.uniswap.notifications + +import android.util.Log +import androidx.annotation.Keep +import com.facebook.react.module.annotations.ReactModule +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactContextBaseJavaModule +import com.facebook.react.bridge.ReactMethod +import com.facebook.react.modules.core.DeviceEventManagerModule +import com.uniswap.utils.toWritableMap +import org.json.JSONObject + +@Keep +@ReactModule(name = SilentPushEventEmitterModule.MODULE_NAME) +class SilentPushEventEmitterModule( + reactContext: ReactApplicationContext +) : ReactContextBaseJavaModule(reactContext) { + + override fun getName() = MODULE_NAME + + override fun initialize() { + super.initialize() + instance = this + listenerCount = 0 + Log.d(TAG, "SilentPushEventEmitter initialized") + flushPendingEvents() + } + + override fun onCatalystInstanceDestroy() { + super.onCatalystInstanceDestroy() + if (instance === this) { + instance = null + } + listenerCount = 0 + } + + @ReactMethod + fun addListener(eventName: String) { + if (eventName != EVENT_NAME) { + return + } + listenerCount += 1 + flushPendingEvents() + } + + @ReactMethod + fun removeListeners(count: Int) { + if (count <= 0) { + return + } + listenerCount = (listenerCount - count).coerceAtLeast(0) + } + + private fun flushPendingEvents() { + if (!hasListeners()) { + return + } + + val events = synchronized(pendingPayloads) { + if (pendingPayloads.isEmpty()) { + null + } else { + val copy = ArrayList(pendingPayloads) + pendingPayloads.clear() + copy + } + } ?: return + + Log.d(TAG, "Flushing ${events.size} queued silent push events") + events.forEach { sendEvent(it) } + } + + private fun sendEvent(payload: JSONObject) { + if (!reactApplicationContext.hasActiveCatalystInstance()) { + synchronized(pendingPayloads) { + Log.d(TAG, "No active Catalyst instance; queueing payload: ${payload.toString()}") + pendingPayloads.add(JSONObject(payload.toString())) + } + return + } + + val map = payload.toWritableMap() + reactApplicationContext.runOnUiQueueThread { + if (!reactApplicationContext.hasActiveCatalystInstance()) { + synchronized(pendingPayloads) { + Log.d(TAG, "Catalyst inactive on UI thread; re-queueing payload: ${payload.toString()}") + pendingPayloads.add(JSONObject(payload.toString())) + } + return@runOnUiQueueThread + } + + Log.d(TAG, "Emitting silent push payload to JS: ${payload.toString()}") + reactApplicationContext + .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) + .emit(EVENT_NAME, map) + } + } + + private fun hasListeners(): Boolean = instance != null && listenerCount > 0 + + companion object { + const val MODULE_NAME = "SilentPushEventEmitter" + private const val EVENT_NAME = "SilentPushReceived" + private const val TAG = "SilentPushEmitter" + private val pendingPayloads = mutableListOf() + + @Volatile + private var instance: SilentPushEventEmitterModule? = null + + @Volatile + private var listenerCount: Int = 0 + + fun emitEvent(payload: JSONObject?) { + val eventPayload = payload?.let { JSONObject(it.toString()) } ?: JSONObject() + val currentInstance = instance + + if (currentInstance != null && currentInstance.hasListeners()) { + Log.d(TAG, "Sending silent push event to JS immediately: $eventPayload") + currentInstance.sendEvent(eventPayload) + return + } + + synchronized(pendingPayloads) { + Log.d(TAG, "Queueing silent push payload until listeners attach: $eventPayload") + pendingPayloads.add(eventPayload) + } + } + } +} diff --git a/apps/mobile/android/app/src/main/java/com/uniswap/notifications/SilentPushNotificationServiceExtension.kt b/apps/mobile/android/app/src/main/java/com/uniswap/notifications/SilentPushNotificationServiceExtension.kt new file mode 100644 index 00000000..90321dc9 --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/uniswap/notifications/SilentPushNotificationServiceExtension.kt @@ -0,0 +1,123 @@ +package com.uniswap.notifications + +import android.util.Log +import androidx.annotation.Keep +import com.onesignal.notifications.INotification +import com.onesignal.notifications.INotificationReceivedEvent +import com.onesignal.notifications.INotificationServiceExtension +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.json.JSONException +import org.json.JSONObject + +@Keep +class SilentPushNotificationServiceExtension : INotificationServiceExtension { + override fun onNotificationReceived(event: INotificationReceivedEvent) { + val notification = event.notification + val payload = buildPayload(notification) + + val hasContentAvailableFlag = hasContentAvailable(payload) + val isMissingVisibleContent = notification.isMissingVisibleContent() + + Log.d( + TAG, + "Notification received. hasContentAvailable=$hasContentAvailableFlag, " + + "missingVisibleContent=$isMissingVisibleContent, payload=$payload", + ) + + if (!hasContentAvailableFlag && !isMissingVisibleContent) { + return + } + + Log.d(TAG, "Emitting silent push event: $payload") + val payloadForEmission = try { + JSONObject(payload.toString()) + } catch (error: JSONException) { + Log.w(TAG, "Failed to clone payload for emission: ${error.message}") + payload + } + + CoroutineScope(Dispatchers.Default).launch { + withContext(Dispatchers.Main) { + SilentPushEventEmitterModule.emitEvent(payloadForEmission) + } + } + + if (isMissingVisibleContent) { + event.preventDefault() + } + } + + private fun INotification.isMissingVisibleContent(): Boolean { + val title: String? = this.title + val body: String? = this.body + return title.isNullOrBlank() && body.isNullOrBlank() + } + + private fun buildPayload(notification: INotification): JSONObject { + val rawPayload = notification.rawPayload + val payload = try { + if (rawPayload.isNullOrBlank()) JSONObject() else JSONObject(rawPayload) + } catch (error: JSONException) { + Log.w(TAG, "Failed parsing raw payload: ${error.message}") + JSONObject() + } + + notification.additionalData?.let { additionalData -> + try { + payload.put("additionalData", additionalData) + } catch (error: JSONException) { + Log.w(TAG, "Failed to append additional data: ${error.message}") + } + } + + return payload + } + + private fun hasContentAvailable(payload: JSONObject?): Boolean { + if (payload == null) { + return false + } + + if (payload.hasContentAvailableFlag()) { + return true + } + + val aps = payload.optJSONObject("aps") + if (aps != null && aps.hasContentAvailableFlag()) { + return true + } + + val additionalData = payload.optJSONObject("additionalData") + if (additionalData != null && additionalData.hasContentAvailableFlag()) { + return true + } + + return false + } + + private fun JSONObject.hasContentAvailableFlag(): Boolean { + return opt(CONTENT_AVAILABLE_UNDERSCORE).isTruthy() || opt(CONTENT_AVAILABLE_HYPHEN).isTruthy() + } + + private fun Any?.isTruthy(): Boolean { + return when (this) { + null, JSONObject.NULL -> false + is Boolean -> this + is Int -> this == 1 + is Long -> this == 1L + is Double -> this == 1.0 + is Float -> this == 1f + is String -> equals("1", ignoreCase = true) || equals("true", ignoreCase = true) + else -> false + } + } + + companion object { + private const val TAG = "SilentPushExt" + private const val CONTENT_AVAILABLE_UNDERSCORE = "content_available" + private const val CONTENT_AVAILABLE_HYPHEN = "content-available" + } +} diff --git a/apps/mobile/android/app/src/main/java/com/uniswap/onboarding/backup/MnemonicConfirmationViewManager.kt b/apps/mobile/android/app/src/main/java/com/uniswap/onboarding/backup/MnemonicConfirmationViewManager.kt new file mode 100644 index 00000000..dddc5792 --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/uniswap/onboarding/backup/MnemonicConfirmationViewManager.kt @@ -0,0 +1,98 @@ +package com.uniswap.onboarding.backup + +import android.view.View +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.ComposeView +import com.facebook.react.bridge.ReactContext +import com.facebook.react.uimanager.ThemedReactContext +import com.facebook.react.uimanager.ViewGroupManager +import com.facebook.react.uimanager.ViewManager +import com.facebook.react.uimanager.annotations.ReactProp +import com.facebook.react.uimanager.events.RCTEventEmitter +import com.uniswap.R +import com.uniswap.RnEthersRs +import com.uniswap.onboarding.backup.ui.MnemonicConfirmation +import com.uniswap.onboarding.backup.ui.MnemonicConfirmationViewModel +import com.uniswap.theme.UniswapComponent +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update + +/** + * View manager used to import native component into React Native code + * for the MnemonicTest component used to test if user has saved their + * seed phrase + */ +class MnemonicConfirmationViewManager : ViewGroupManager() { + + override fun getName(): String = REACT_CLASS + + private val mnemonicIdFlow = MutableStateFlow("") + private val shouldShowSmallTextFlow = MutableStateFlow(false) + private val selectedWordPlaceholderFlow = MutableStateFlow("") + + override fun createViewInstance(reactContext: ThemedReactContext): ComposeView { + val ethersRs = RnEthersRs(reactContext) + val viewModel = MnemonicConfirmationViewModel(ethersRs) + + return ComposeView(reactContext).apply { + id = R.id.mnemonic_confirmation_compose_id // Needed for RN event emitter + + setContent { + val mnemonicId by mnemonicIdFlow.collectAsState() + val shouldShowSmallText by shouldShowSmallTextFlow.collectAsState() + val selectedWordPlaceholder by selectedWordPlaceholderFlow.collectAsState() + + viewModel.updatePlaceholder(selectedWordPlaceholder) + + UniswapComponent { + MnemonicConfirmation( + mnemonicId = mnemonicId, + viewModel = viewModel, + shouldShowSmallText = shouldShowSmallText, + ) { + context as ReactContext + reactContext + .getJSModule(RCTEventEmitter::class.java) + .receiveEvent(id, EVENT_COMPLETED, null) // Sends event to RN bridge + } + } + } + } + } + + /** + * Maps local event name to expected RN prop. See RN [ViewManager] docs on github for schema. + * Using bubbling instead of direct events because overriding + * getExportedCustomDirectEventTypeConstants leads to a component not found error for some reason. + * Direct events will try find callback prop on native component, and bubbled events will bubble + * up until it finds component with the right callback prop. + */ + override fun getExportedCustomBubblingEventTypeConstants(): Map { + return mapOf( + EVENT_COMPLETED to mapOf( + "phasedRegistrationNames" to mapOf("bubbled" to EVENT_COMPLETED) + ) + ) + } + + @ReactProp(name = "mnemonicId") + fun setMnemonicId(view: View, mnemonicId: String) { + mnemonicIdFlow.update { mnemonicId } + } + + @ReactProp(name = "shouldShowSmallText") + fun setShouldShowSmallText(view: View, shouldShowSmallText: Boolean) { + shouldShowSmallTextFlow.update { shouldShowSmallText } + } + + @ReactProp(name = "selectedWordPlaceholder") + fun setSelectedWordPlaceholder(view: View, selectedWordPlaceholder: String) { + selectedWordPlaceholderFlow.update { selectedWordPlaceholder } + } + + companion object { + private const val REACT_CLASS = "MnemonicConfirmation" + private const val EVENT_COMPLETED = "onConfirmComplete" + } +} diff --git a/apps/mobile/android/app/src/main/java/com/uniswap/onboarding/backup/MnemonicDisplayViewManager.kt b/apps/mobile/android/app/src/main/java/com/uniswap/onboarding/backup/MnemonicDisplayViewManager.kt new file mode 100644 index 00000000..d17b1d85 --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/uniswap/onboarding/backup/MnemonicDisplayViewManager.kt @@ -0,0 +1,121 @@ +package com.uniswap.onboarding.backup + +import android.view.View +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.ComposeView +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.WritableMap +import com.facebook.react.uimanager.ThemedReactContext +import com.facebook.react.uimanager.ViewGroupManager +import com.facebook.react.uimanager.ViewManager +import com.facebook.react.uimanager.annotations.ReactProp +import com.facebook.react.uimanager.events.RCTEventEmitter +import com.uniswap.RnEthersRs +import com.uniswap.onboarding.backup.ui.MnemonicDisplay +import com.uniswap.onboarding.backup.ui.MnemonicDisplayViewModel +import com.uniswap.theme.UniswapComponent +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update + +/** + * View manager used to import native component into React Native code + * for the MnemonicDisplay component used to show the seed phrases + */ +class MnemonicDisplayViewManager : ViewGroupManager() { + + override fun getName(): String = REACT_CLASS + + private lateinit var context: ThemedReactContext + + private val mnemonicIdFlow = MutableStateFlow("") + private val copyTextFlow = MutableStateFlow("") + private val copiedTextFlow = MutableStateFlow("") + + override fun createViewInstance(reactContext: ThemedReactContext): ComposeView { + context = reactContext + val ethersRs = RnEthersRs(reactContext) + val viewModel = MnemonicDisplayViewModel(ethersRs) + + return ComposeView(reactContext).apply { + setContent { + val mnemonicId by mnemonicIdFlow.collectAsState() + val copyText by copyTextFlow.collectAsState() + val copiedText by copiedTextFlow.collectAsState() + + UniswapComponent { + MnemonicDisplay( + mnemonicId = mnemonicId, + viewModel = viewModel, + copyText = copyText, + copiedText = copiedText, + onHeightMeasured = { + val bundle = Arguments.createMap().apply { + putDouble(FIELD_HEIGHT, it.toDouble()) + } + sendEvent(id, EVENT_HEIGHT_MEASURED, bundle) + }, + onEmptyMnemonic = { + val bundle = Arguments.createMap().apply { + putString("mnemonicId", it) + } + sendEvent(id, EVENT_EMPTY_MNEMONIC, bundle) + } + ) + } + } + } + } + + /** + * Maps local event name to expected RN prop. See RN [ViewManager] docs on github for schema. + * Using bubbling instead of direct events because overriding + * getExportedCustomDirectEventTypeConstants leads to a component not found error for some reason. + * Direct events will try find callback prop on native component, and bubbled events will bubble + * up until it finds component with the right callback prop. + */ + override fun getExportedCustomBubblingEventTypeConstants(): Map { + return mapOf( + EVENT_HEIGHT_MEASURED to mapOf( + "phasedRegistrationNames" to mapOf( + "bubbled" to EVENT_HEIGHT_MEASURED, + "captured" to EVENT_HEIGHT_MEASURED + ) + ), + EVENT_EMPTY_MNEMONIC to mapOf( + "phasedRegistrationNames" to mapOf( + "bubbled" to EVENT_EMPTY_MNEMONIC, + "captured" to EVENT_EMPTY_MNEMONIC + ) + ) + ) + } + + private fun sendEvent(id: Int, eventName: String, bundle: WritableMap? = null) { + context + .getJSModule(RCTEventEmitter::class.java) + .receiveEvent(id, eventName, bundle) + } + + @ReactProp(name = "mnemonicId") + fun setMnemonicId(view: View, mnemonicId: String) { + mnemonicIdFlow.update { mnemonicId } + } + + @ReactProp(name = "copyText") + fun setCopyText(view: View, copyText: String) { + copyTextFlow.update { copyText } + } + + @ReactProp(name = "copiedText") + fun setCopiedText(view: View, copiedText: String) { + copiedTextFlow.update { copiedText } + } + + companion object { + private const val REACT_CLASS = "MnemonicDisplay" + private const val EVENT_HEIGHT_MEASURED = "onHeightMeasured" + private const val EVENT_EMPTY_MNEMONIC = "onEmptyMnemonic" + private const val FIELD_HEIGHT = "height" + } +} diff --git a/apps/mobile/android/app/src/main/java/com/uniswap/onboarding/backup/ui/MnemonicConfirmation.kt b/apps/mobile/android/app/src/main/java/com/uniswap/onboarding/backup/ui/MnemonicConfirmation.kt new file mode 100644 index 00000000..351b942d --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/uniswap/onboarding/backup/ui/MnemonicConfirmation.kt @@ -0,0 +1,64 @@ +package com.uniswap.onboarding.backup.ui + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import com.uniswap.theme.UniswapTheme + +/** + * Renders screen for confirming that a user wrote down their seed phrase + * by testing they can select it in order + */ +@Composable +fun MnemonicConfirmation( + viewModel: MnemonicConfirmationViewModel, + mnemonicId: String, + shouldShowSmallText: Boolean, + onCompleted: () -> Unit, +) { + + val displayedWords by viewModel.selectedWords.collectAsState() + val wordBankList by viewModel.wordBankList.collectAsState() + val completed by viewModel.completed.collectAsState() + + LaunchedEffect(mnemonicId) { + viewModel.setup(mnemonicId) + } + + LaunchedEffect(completed) { + if (completed) { + onCompleted() + } + } + + Column( + modifier = Modifier + .fillMaxSize() + ) { + Column( + modifier = Modifier + .weight(1f, fill = true) + .verticalScroll(rememberScrollState()) + ) { + MnemonicWordsGroup( + words = displayedWords, + shouldShowSmallText = shouldShowSmallText, + ) + } + + MnemonicWordBank(words = wordBankList, shouldShowSmallText = shouldShowSmallText) { + viewModel.handleWordBankClick(it.index) + } + } +} diff --git a/apps/mobile/android/app/src/main/java/com/uniswap/onboarding/backup/ui/MnemonicConfirmationViewModel.kt b/apps/mobile/android/app/src/main/java/com/uniswap/onboarding/backup/ui/MnemonicConfirmationViewModel.kt new file mode 100644 index 00000000..a1cade2d --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/uniswap/onboarding/backup/ui/MnemonicConfirmationViewModel.kt @@ -0,0 +1,135 @@ +package com.uniswap.onboarding.backup.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.uniswap.RnEthersRs +import com.uniswap.onboarding.backup.ui.model.MnemonicInputStatus +import com.uniswap.onboarding.backup.ui.model.MnemonicWordBankCellUiState +import com.uniswap.onboarding.backup.ui.model.MnemonicWordUiState +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update + +class MnemonicConfirmationViewModel( + private val ethersRs: RnEthersRs, // Move to repository layer if app gets more complex +) : ViewModel() { + private val defaultMnemonicsCount = 12 + + private var sourceWords = List(defaultMnemonicsCount) { "" } + private var shuffledWords = emptyList() + private val focusedIndex = MutableStateFlow(0) + private val selectedWordsIndexes = + MutableStateFlow>(List(defaultMnemonicsCount) { null }) + private val selectedWordPlaceholderFlow = MutableStateFlow("") + + val selectedWords: StateFlow> = + combine(selectedWordsIndexes, selectedWordPlaceholderFlow, focusedIndex) { _, placeholder, focusedIndex -> + List(sourceWords.size) { index -> + getMnemonicWordUiState(index, placeholder, focusedIndex) + } + }.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) + + val wordBankList: StateFlow> = + selectedWordsIndexes.map { selectedWordsIndexes -> + shuffledWords.mapIndexed { index, word -> + MnemonicWordBankCellUiState( + index = index, + text = word, + used = selectedWordsIndexes.contains(index), + ) + } + }.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) + + private val _completed = MutableStateFlow(false) + val completed = _completed.asStateFlow() + + private var currentMnemonicId = "" + + fun setup(mnemonicId: String) { + if (mnemonicId.isNotEmpty() && mnemonicId != currentMnemonicId) { + currentMnemonicId = mnemonicId + reset() + + ethersRs.retrieveMnemonic(mnemonicId)?.let { mnemonic -> + val words = mnemonic.split(" ") + sourceWords = words + shuffledWords = words.shuffled() + selectedWordsIndexes.update { List(words.size) { null } } + } + } + } + + private fun reset() { + sourceWords = List(defaultMnemonicsCount) { "" } + shuffledWords = emptyList() + focusedIndex.update { 0 } + selectedWordsIndexes.update { emptyList() } + _completed.update { false } + } + + fun updatePlaceholder(newPlaceholder: String) { + selectedWordPlaceholderFlow.value = newPlaceholder + } + + fun handleWordBankClick(wordBankIndex: Int) { + + selectedWordsIndexes.update { indexes -> + val updatedIndexes = indexes.toMutableList() + updatedIndexes[focusedIndex.value] = wordBankIndex + updatedIndexes + } + + if (focusedIndex.value == sourceWords.size - 1) { + checkIfCompleted() + } else if (sourceWords[focusedIndex.value] == shuffledWords[wordBankIndex] && focusedIndex.value < sourceWords.size - 1) { + focusedIndex.update { it + 1 } + } + } + + private fun checkIfCompleted() { + if (selectedWordsIndexes.value.size != sourceWords.size) { + return + } + + for (i in selectedWordsIndexes.value.indices) { + val selectedWord = getSelectedWord(i) + if (sourceWords[i].isEmpty() || selectedWord != sourceWords[i]) { + return + } + } + + _completed.update { true } + } + + private fun getSelectedWord(displayIndex: Int): String { + return selectedWordsIndexes.value.getOrNull(displayIndex)?.let { shuffledWords.getOrNull(it) } + ?: "" + } + + private fun getMnemonicWordUiState( + displayIndex: Int, + placeholderText: String, + focusedIndex: Int, + ): MnemonicWordUiState { + val selectedWord = getSelectedWord(displayIndex) + var status = MnemonicInputStatus.CORRECT_INPUT + + if (selectedWord.isEmpty()) { + status = MnemonicInputStatus.NO_INPUT + } else if (selectedWord != sourceWords[displayIndex]) { + status = MnemonicInputStatus.WRONG_INPUT + } + + return MnemonicWordUiState( + num = displayIndex + 1, + text = selectedWord.ifEmpty { placeholderText }, + status = status, + isActive = displayIndex == focusedIndex, + ) + } +} diff --git a/apps/mobile/android/app/src/main/java/com/uniswap/onboarding/backup/ui/MnemonicDisplay.kt b/apps/mobile/android/app/src/main/java/com/uniswap/onboarding/backup/ui/MnemonicDisplay.kt new file mode 100644 index 00000000..9fe1f59b --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/uniswap/onboarding/backup/ui/MnemonicDisplay.kt @@ -0,0 +1,94 @@ +package com.uniswap.onboarding.backup.ui + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.unit.dp +import com.uniswap.onboarding.shared.CopyButton +import com.uniswap.theme.relativeOffset +import kotlinx.coroutines.withTimeoutOrNull +import kotlin.math.abs + +@Composable +fun MnemonicDisplay( + viewModel: MnemonicDisplayViewModel, + mnemonicId: String, + copyText: String, + copiedText: String, + onHeightMeasured: (height: Float) -> Unit, + onEmptyMnemonic: (mnemonicId: String) -> Unit +) { + val words by viewModel.words.collectAsState() + val textToCopy = AnnotatedString(words.joinToString(" ") { it.text }) + val density = LocalDensity.current.density + var buttonOffset by remember { mutableStateOf(20.dp) } + + LaunchedEffect(mnemonicId) { + viewModel.setup(mnemonicId) + + // Check and log if the mnemonic is empty after 1 second to avoid calling onEmptyMnemonic too early + withTimeoutOrNull(1000L) { + viewModel.words.collect { currentWords -> + if (currentWords.isEmpty() || currentWords.any { it.text.isBlank() }) { + onEmptyMnemonic(mnemonicId) + return@collect + } + } + } + } + + BoxWithConstraints { + Column( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight() + .verticalScroll(rememberScrollState()) + .onSizeChanged { size -> + onHeightMeasured(size.height / density) + } + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight() + .padding(top = buttonOffset) + .wrapContentSize(Alignment.Center) + ) { + MnemonicWordsGroup(words = words) + + Box( + modifier = Modifier + .align(Alignment.TopCenter) + .relativeOffset(y = -0.5f) { _, offsetY -> + buttonOffset = (abs(offsetY) / density).dp + } + ) { + CopyButton( + copyButtonText = copyText, + copiedButtonText = copiedText, + textToCopy = textToCopy, + isSensitive = true + ) + } + } + } + } +} diff --git a/apps/mobile/android/app/src/main/java/com/uniswap/onboarding/backup/ui/MnemonicDisplayViewModel.kt b/apps/mobile/android/app/src/main/java/com/uniswap/onboarding/backup/ui/MnemonicDisplayViewModel.kt new file mode 100644 index 00000000..7afc7925 --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/uniswap/onboarding/backup/ui/MnemonicDisplayViewModel.kt @@ -0,0 +1,42 @@ +package com.uniswap.onboarding.backup.ui + +import androidx.lifecycle.ViewModel +import com.uniswap.RnEthersRs +import com.uniswap.onboarding.backup.ui.model.MnemonicWordUiState +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +class MnemonicDisplayViewModel( + private val ethersRs: RnEthersRs // Move to repository layer if app gets more complex +) : ViewModel() { + private val defaultMnemonicsCount = 12 + private val _words = + MutableStateFlow(List(defaultMnemonicsCount) { MnemonicWordUiState(num = it + 1, text = "") }) + val words = _words.asStateFlow() + + private var currentMnemonicId = "" + + fun setup(mnemonicId: String) { + if (mnemonicId.isNotEmpty() && mnemonicId != currentMnemonicId) { + currentMnemonicId = mnemonicId + reset() + + ethersRs.retrieveMnemonic(mnemonicId)?.let { mnemonic -> + val phraseList = mnemonic.split(" ") + _words.update { + phraseList.mapIndexed { index, phrase -> + MnemonicWordUiState( + num = index + 1, + text = phrase, + ) + } + } + } + } + } + + private fun reset() { + _words.update { emptyList() } + } +} diff --git a/apps/mobile/android/app/src/main/java/com/uniswap/onboarding/backup/ui/MnemonicWordBank.kt b/apps/mobile/android/app/src/main/java/com/uniswap/onboarding/backup/ui/MnemonicWordBank.kt new file mode 100644 index 00000000..b6b63a75 --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/uniswap/onboarding/backup/ui/MnemonicWordBank.kt @@ -0,0 +1,84 @@ +package com.uniswap.onboarding.backup.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.unit.dp +import com.google.accompanist.flowlayout.FlowRow +import com.google.accompanist.flowlayout.MainAxisAlignment +import com.uniswap.onboarding.backup.ui.model.MnemonicWordBankCellUiState +import com.uniswap.theme.UniswapTheme + +/** + * Used to render a set of clickable mnemonic words + */ +@Composable +fun MnemonicWordBank( + words: List, + shouldShowSmallText: Boolean = false, + onClick: (word: MnemonicWordBankCellUiState) -> Unit +) { + FlowRow( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight() + .padding( + vertical = UniswapTheme.spacing.spacing16, + horizontal = UniswapTheme.spacing.spacing8 + ), + mainAxisSpacing = if (shouldShowSmallText) UniswapTheme.spacing.spacing4 else UniswapTheme.spacing.spacing8, + crossAxisSpacing = if (shouldShowSmallText) UniswapTheme.spacing.spacing4 else UniswapTheme.spacing.spacing8, + mainAxisAlignment = MainAxisAlignment.Center, + ) { + words.forEach { + MnemonicWordBankCell(word = it, shouldShowSmallText = shouldShowSmallText) { + onClick(it) + } + } + } +} + +@Composable +private fun MnemonicWordBankCell( + word: MnemonicWordBankCellUiState, + shouldShowSmallText: Boolean, + onClick: () -> Unit +) { + val textStyle = + if (shouldShowSmallText) UniswapTheme.typography.body3 else UniswapTheme.typography.body2 + val verticalPadding = + if (shouldShowSmallText) UniswapTheme.spacing.spacing8 else 10.dp + val horizontalPadding = + if (shouldShowSmallText) 10.dp else UniswapTheme.spacing.spacing12 + + Box( + modifier = Modifier + .shadow( + 10.dp, + spotColor = UniswapTheme.colors.black.copy(alpha = 0.04f), + shape = UniswapTheme.shapes.xlarge + ) + ) { + Box(modifier = Modifier + .clip(shape = UniswapTheme.shapes.xlarge) + .then(Modifier.border(1.dp, UniswapTheme.colors.surface3, UniswapTheme.shapes.xlarge)) + .clickable { onClick() } + .background(color = UniswapTheme.colors.surface1) + .padding(vertical = verticalPadding, horizontal = horizontalPadding)) { + Text( + text = word.text, + style = textStyle, + color = UniswapTheme.colors.neutral1.copy(if (word.used) 0.5f else 1f), + ) + } + } +} diff --git a/apps/mobile/android/app/src/main/java/com/uniswap/onboarding/backup/ui/MnemonicWordCell.kt b/apps/mobile/android/app/src/main/java/com/uniswap/onboarding/backup/ui/MnemonicWordCell.kt new file mode 100644 index 00000000..20bbd77d --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/uniswap/onboarding/backup/ui/MnemonicWordCell.kt @@ -0,0 +1,64 @@ +package com.uniswap.onboarding.backup.ui + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.foundation.relocation.bringIntoViewRequester +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.text.style.TextOverflow +import com.uniswap.onboarding.backup.ui.model.MnemonicInputStatus +import com.uniswap.onboarding.backup.ui.model.MnemonicWordUiState +import com.uniswap.theme.UniswapTheme + +/** + * Component used to display a single word as part of an overall seed phrase + */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun MnemonicWordCell( + word: MnemonicWordUiState, + shouldShowSmallText: Boolean = false, +) { + val bringIntoViewRequester = remember { BringIntoViewRequester() } + + LaunchedEffect(word.isActive) { + // When a cell status changes, request to bring it into view in the parent scroll container + if (word.isActive){ + bringIntoViewRequester.bringIntoView() + } + } + + val textStyle = + if (shouldShowSmallText) UniswapTheme.typography.body3 else UniswapTheme.typography.body2 + + val textColor = when (word.status) { + MnemonicInputStatus.NO_INPUT -> UniswapTheme.colors.neutral3 + MnemonicInputStatus.CORRECT_INPUT -> UniswapTheme.colors.neutral1 + MnemonicInputStatus.WRONG_INPUT -> UniswapTheme.colors.statusCritical + } + + Row(modifier = Modifier.bringIntoViewRequester(bringIntoViewRequester)) { + Text( + text = "${word.num}", + color = UniswapTheme.colors.neutral2, + modifier = Modifier.defaultMinSize(minWidth = if (shouldShowSmallText) 14.dp else 16.dp), + style = textStyle, + ) + Spacer(modifier = Modifier.width(UniswapTheme.spacing.spacing16)) + Text( + text = word.text, + style = textStyle, + color = textColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } +} diff --git a/apps/mobile/android/app/src/main/java/com/uniswap/onboarding/backup/ui/MnemonicWordsColumn.kt b/apps/mobile/android/app/src/main/java/com/uniswap/onboarding/backup/ui/MnemonicWordsColumn.kt new file mode 100644 index 00000000..b13502a5 --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/uniswap/onboarding/backup/ui/MnemonicWordsColumn.kt @@ -0,0 +1,30 @@ +package com.uniswap.onboarding.backup.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.uniswap.onboarding.backup.ui.model.MnemonicWordUiState +import com.uniswap.theme.UniswapTheme + +/** + * Component used to display a numbered column of words as part of an overall seed phrase + */ +@Composable +fun MnemonicWordsColumn( + modifier: Modifier = Modifier, + words: List, + shouldShowSmallText: Boolean = false, +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(UniswapTheme.spacing.spacing8), + ) { + words.forEach { word -> + MnemonicWordCell( + word = word, + shouldShowSmallText = shouldShowSmallText, + ) + } + } +} diff --git a/apps/mobile/android/app/src/main/java/com/uniswap/onboarding/backup/ui/MnemonicWordsGroup.kt b/apps/mobile/android/app/src/main/java/com/uniswap/onboarding/backup/ui/MnemonicWordsGroup.kt new file mode 100644 index 00000000..35b7f237 --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/uniswap/onboarding/backup/ui/MnemonicWordsGroup.kt @@ -0,0 +1,56 @@ +package com.uniswap.onboarding.backup.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.uniswap.onboarding.backup.ui.model.MnemonicWordUiState +import com.uniswap.theme.UniswapTheme + +/** + * View used to display mnemonic words for wallet seed phrase + */ +@Composable +fun MnemonicWordsGroup( + modifier: Modifier = Modifier, + words: List, + columnCount: Int = DEFAULT_COLUMN_COUNT, + shouldShowSmallText: Boolean = false, +) { + Row( + modifier = modifier + .fillMaxWidth() + .wrapContentHeight() + .background(color = UniswapTheme.colors.surface2, shape = RoundedCornerShape(20.dp)) + .border( + width = 1.dp, + color = UniswapTheme.colors.surface3, + shape = RoundedCornerShape(20.dp), + ) + .padding( + vertical = UniswapTheme.spacing.spacing24, + horizontal = UniswapTheme.spacing.spacing32 + ), + horizontalArrangement = Arrangement.spacedBy(UniswapTheme.spacing.spacing8) + ) { + val size = words.size / columnCount + for (i in 0 until columnCount) { + val starting = i * size + val ending = (i + 1) * size + MnemonicWordsColumn( + modifier = Modifier.weight(1f), + words = words.subList(starting, ending), + shouldShowSmallText = shouldShowSmallText, + ) + } + } +} + +private const val DEFAULT_COLUMN_COUNT = 2 diff --git a/apps/mobile/android/app/src/main/java/com/uniswap/onboarding/backup/ui/model/MnemonicWordBankCellUiState.kt b/apps/mobile/android/app/src/main/java/com/uniswap/onboarding/backup/ui/model/MnemonicWordBankCellUiState.kt new file mode 100644 index 00000000..166be4dc --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/uniswap/onboarding/backup/ui/model/MnemonicWordBankCellUiState.kt @@ -0,0 +1,7 @@ +package com.uniswap.onboarding.backup.ui.model + +data class MnemonicWordBankCellUiState( + val index: Int, + val text: String, + val used: Boolean = false, +) diff --git a/apps/mobile/android/app/src/main/java/com/uniswap/onboarding/backup/ui/model/MnemonicWordUiState.kt b/apps/mobile/android/app/src/main/java/com/uniswap/onboarding/backup/ui/model/MnemonicWordUiState.kt new file mode 100644 index 00000000..78766f0e --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/uniswap/onboarding/backup/ui/model/MnemonicWordUiState.kt @@ -0,0 +1,14 @@ +package com.uniswap.onboarding.backup.ui.model + +enum class MnemonicInputStatus { + NO_INPUT, + CORRECT_INPUT, + WRONG_INPUT, +} + +data class MnemonicWordUiState( + val num: Int, + val text: String, + val status: MnemonicInputStatus = MnemonicInputStatus.CORRECT_INPUT, + val isActive: Boolean = false +) diff --git a/apps/mobile/android/app/src/main/java/com/uniswap/onboarding/import/SeedPhraseInput.kt b/apps/mobile/android/app/src/main/java/com/uniswap/onboarding/import/SeedPhraseInput.kt new file mode 100644 index 00000000..4a1269cd --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/uniswap/onboarding/import/SeedPhraseInput.kt @@ -0,0 +1,188 @@ +package com.uniswap.onboarding.import + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.ContentAlpha +import androidx.compose.material.Icon +import androidx.compose.material.LocalContentColor +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.focus.focusRequester +import com.uniswap.R +import com.uniswap.onboarding.import.SeedPhraseInputViewModel.MnemonicError.InvalidPhrase +import com.uniswap.onboarding.import.SeedPhraseInputViewModel.MnemonicError.InvalidWord +import com.uniswap.onboarding.import.SeedPhraseInputViewModel.MnemonicError.NotEnoughWords +import com.uniswap.onboarding.import.SeedPhraseInputViewModel.MnemonicError.TooManyWords +import com.uniswap.onboarding.import.SeedPhraseInputViewModel.MnemonicError.WrongRecoveryPhrase +import com.uniswap.onboarding.import.SeedPhraseInputViewModel.MnemonicError.WordIsAddress +import com.uniswap.onboarding.import.SeedPhraseInputViewModel.Status.Error +import com.uniswap.onboarding.import.SeedPhraseInputViewModel.Status.Valid +import com.uniswap.onboarding.shared.PasteButton +import com.uniswap.theme.UniswapTheme +import com.uniswap.theme.relativeOffset +import kotlin.math.abs + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun SeedPhraseInput( + viewModel: SeedPhraseInputViewModel +) { + val focusRequester = remember { FocusRequester() } + val density = LocalDensity.current.density + var buttonOffset by remember { mutableStateOf(20.dp) } + val keyboardController = LocalSoftwareKeyboardController.current + + LaunchedEffect(viewModel.isFocused) { + if (viewModel.isFocused) { + focusRequester.requestFocus() + } else { + focusRequester.freeFocus() + keyboardController?.hide() + } + } + + Column( + modifier = Modifier.wrapContentHeight(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight() + .padding(bottom = buttonOffset) + ) { + BasicTextField( + modifier = Modifier + .fillMaxWidth() + .clip(UniswapTheme.shapes.small) + .background(UniswapTheme.colors.surface1) + .border( + width = 1.dp, + shape = UniswapTheme.shapes.small, + color = mapStatusToBorderColor(viewModel.status), + ) + .focusRequester(focusRequester), + value = viewModel.input, + onValueChange = { viewModel.handleInputChange(it) }, + cursorBrush = SolidColor(LocalContentColor.current.copy(ContentAlpha.high)), + textStyle = UniswapTheme.typography.subheading2.copy( + textAlign = TextAlign.Start, + color = UniswapTheme.colors.neutral1 + ), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + capitalization = KeyboardCapitalization.None + ), + decorationBox = { innerTextField -> + Box( + modifier = Modifier + .wrapContentHeight() + .heightIn(min = 120.dp) + .padding(20.dp), + ) { + if (viewModel.input.text.isEmpty()) { + Text( + text = viewModel.rnStrings.inputPlaceholder, + style = UniswapTheme.typography.subheading2.copy( + color = UniswapTheme.colors.neutral3 + ) + ) + } + innerTextField() + } + } + ) + + if (viewModel.input.text.isEmpty()) { + PasteButton( + modifier = Modifier + .align(Alignment.BottomCenter) + .relativeOffset(y = .5f) { _, offsetY -> + buttonOffset = (abs(offsetY) / density).dp + }, + pasteButtonText = viewModel.rnStrings.pasteButton, + onPaste = { + viewModel.handleInputChange( + TextFieldValue(it, selection = TextRange(it.length)) + ) + focusRequester.requestFocus() + } + ) + } + } + + SeedPhraseError(viewModel) + } +} + +@Composable +private fun SeedPhraseError(viewModel: SeedPhraseInputViewModel) { + val status = viewModel.status + val rnStrings = viewModel.rnStrings + var text = "" + + if (status is Error) { + text = when (val error = status.error) { + is InvalidWord -> "${rnStrings.errorInvalidWord} ${error.word}" + is NotEnoughWords, TooManyWords -> rnStrings.errorPhraseLength + is WrongRecoveryPhrase -> rnStrings.errorWrongPhrase + is InvalidPhrase -> rnStrings.errorInvalidPhrase + is WordIsAddress -> rnStrings.errorWordIsAddress + } + } + + Row( + horizontalArrangement = Arrangement.spacedBy(UniswapTheme.spacing.spacing4), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.alpha(if (text.isEmpty()) 0f else 1f) + ) { + Icon( + painter = painterResource(id = R.drawable.uniswap_icon_alert_triangle), + tint = UniswapTheme.colors.statusCritical, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + Text(text, style = UniswapTheme.typography.body3, color = UniswapTheme.colors.statusCritical) + } +} + +@Composable +private fun mapStatusToBorderColor(status: SeedPhraseInputViewModel.Status): Color = + when (status) { + Valid -> UniswapTheme.colors.statusSuccess + is Error -> UniswapTheme.colors.statusCritical + else -> UniswapTheme.colors.surface3 + } diff --git a/apps/mobile/android/app/src/main/java/com/uniswap/onboarding/import/SeedPhraseInputViewManager.kt b/apps/mobile/android/app/src/main/java/com/uniswap/onboarding/import/SeedPhraseInputViewManager.kt new file mode 100644 index 00000000..88d42c90 --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/uniswap/onboarding/import/SeedPhraseInputViewManager.kt @@ -0,0 +1,178 @@ +package com.uniswap.onboarding.import + +import android.view.View +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalDensity +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.ReadableArray +import com.facebook.react.bridge.ReadableMap +import com.facebook.react.bridge.WritableMap +import com.facebook.react.uimanager.ThemedReactContext +import com.facebook.react.uimanager.ViewGroupManager +import com.facebook.react.uimanager.ViewManager +import com.facebook.react.uimanager.annotations.ReactProp +import com.facebook.react.uimanager.events.RCTEventEmitter +import com.uniswap.R +import com.uniswap.RnEthersRs +import com.uniswap.theme.UniswapComponent +import kotlinx.coroutines.flow.MutableStateFlow + + +/** + * View manager used to import native component into React Native code + * for the MnemonicDisplay component used to show the seed phrases + */ +class SeedPhraseInputViewManager : ViewGroupManager() { + + override fun getName(): String = REACT_CLASS + + private lateinit var viewModel: SeedPhraseInputViewModel + private lateinit var context: ThemedReactContext + + private var rnStrings = MutableStateFlow(emptyMap()) + + override fun createViewInstance(reactContext: ThemedReactContext): ComposeView { + context = reactContext + val ethersRs = RnEthersRs(reactContext) + + return ComposeView(reactContext).apply { + id = R.id.seed_phrase_input_compose_id + viewModel = SeedPhraseInputViewModel( + ethersRs, + onInputValidated = { + val bundle = Arguments.createMap().apply { + putBoolean(FIELD_CAN_SUBMIT, it) + } + sendEvent(id, EVENT_INPUT_VALIDATED, bundle) + }, + onMnemonicStored = { + val bundle = Arguments.createMap().apply { + putString(FIELD_MNEMONIC_ID, it) + } + sendEvent(id, EVENT_MNEMONIC_STORED, bundle) + }, + onSubmitError = { + sendEvent(id, EVENT_SUBMIT_ERROR) + } + ) + + setContent { + val density = LocalDensity.current.density + + BoxWithConstraints { + UniswapComponent( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight(unbounded = true) + .align(Alignment.TopCenter) + .onSizeChanged { + val bundle = Arguments + .createMap() + .apply { + putDouble(FIELD_HEIGHT, it.height.toDouble() / density) + } + sendEvent(id, EVENT_HEIGHT_MEASURED, bundle) + }) { + SeedPhraseInput(viewModel) + } + } + } + } + } + + /** + * Maps local event name to expected RN prop. See RN [ViewManager] docs on github for schema. + * Using bubbling instead of direct events because overriding + * getExportedCustomDirectEventTypeConstants leads to a component not found error for some reason. + * Direct events will try find callback prop on native component, and bubbled events will bubble + * up until it finds component with the right callback prop. + */ + override fun getExportedCustomBubblingEventTypeConstants(): Map { + return mapOf( + EVENT_INPUT_VALIDATED to mapOf( + "phasedRegistrationNames" to mapOf( + "bubbled" to EVENT_INPUT_VALIDATED, + "captured" to EVENT_INPUT_VALIDATED + ) + ), + EVENT_MNEMONIC_STORED to mapOf( + "phasedRegistrationNames" to mapOf( + "bubbled" to EVENT_MNEMONIC_STORED, + "captured" to EVENT_MNEMONIC_STORED + ) + ), + EVENT_HEIGHT_MEASURED to mapOf( + "phasedRegistrationNames" to mapOf( + "bubbled" to EVENT_HEIGHT_MEASURED, + "captured" to EVENT_HEIGHT_MEASURED + ) + ), + EVENT_SUBMIT_ERROR to mapOf( + "phasedRegistrationNames" to mapOf( + "bubbled" to EVENT_SUBMIT_ERROR, + "captured" to EVENT_SUBMIT_ERROR + ) + ), + ) + } + + private fun sendEvent(id: Int, eventName: String, bundle: WritableMap? = null) { + context + .getJSModule(RCTEventEmitter::class.java) + .receiveEvent(id, eventName, bundle) + } + + override fun receiveCommand(root: ComposeView, commandId: String?, args: ReadableArray?) { + super.receiveCommand(root, commandId, args) + when (commandId) { + COMMAND_HANDLE_SUBMIT -> { + viewModel.handleSubmit() + } + COMMAND_FOCUS -> { + viewModel.focus() + } + COMMAND_BLUR -> { + viewModel.blur() + } + else -> Unit + } + } + + @ReactProp(name = "targetMnemonicId") + fun setTargetMnemonicId(view: View, mnemonicId: String?) { + viewModel.targetMnemonicId = mnemonicId + } + + @ReactProp(name = "strings") + fun setStrings(view: View, strings: ReadableMap) { + viewModel.rnStrings = SeedPhraseInputViewModel.ReactNativeStrings( + inputPlaceholder = strings.getString("inputPlaceholder") ?: "", + pasteButton = strings.getString("pasteButton") ?: "", + errorInvalidWord = strings.getString("errorInvalidWord") ?: "", + errorPhraseLength = strings.getString("errorPhraseLength") ?: "", + errorWrongPhrase = strings.getString("errorWrongPhrase") ?: "", + errorInvalidPhrase = strings.getString("errorInvalidPhrase") ?: "", + errorWordIsAddress = strings.getString("errorWordIsAddress") ?: "", + ) + } + + companion object { + private const val REACT_CLASS = "SeedPhraseInput" + private const val EVENT_INPUT_VALIDATED = "onInputValidated" + private const val EVENT_MNEMONIC_STORED = "onMnemonicStored" + private const val EVENT_HEIGHT_MEASURED = "onHeightMeasured" + private const val EVENT_SUBMIT_ERROR = "onSubmitError" + private const val COMMAND_HANDLE_SUBMIT = "handleSubmit" + private const val COMMAND_FOCUS = "focus" + private const val COMMAND_BLUR = "blur" + private const val FIELD_MNEMONIC_ID = "mnemonicId" + private const val FIELD_CAN_SUBMIT = "canSubmit" + private const val FIELD_HEIGHT = "height" + } +} diff --git a/apps/mobile/android/app/src/main/java/com/uniswap/onboarding/import/SeedPhraseInputViewModel.kt b/apps/mobile/android/app/src/main/java/com/uniswap/onboarding/import/SeedPhraseInputViewModel.kt new file mode 100644 index 00000000..2a12ac22 --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/uniswap/onboarding/import/SeedPhraseInputViewModel.kt @@ -0,0 +1,187 @@ +package com.uniswap.onboarding.import + +import android.util.Log +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.text.input.TextFieldValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.uniswap.EthersRs +import com.uniswap.RnEthersRs +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +class SeedPhraseInputViewModel( + private val ethersRs: RnEthersRs, + private val onInputValidated: (canSubmit: Boolean) -> Unit, + private val onMnemonicStored: (mnemonicId: String) -> Unit, + private val onSubmitError: () -> Unit, +) : ViewModel() { + + sealed interface Status { + object None : Status + object Valid : Status + class Error(val error: MnemonicError) : Status + } + + sealed interface MnemonicError { + class InvalidWord(val word: String) : MnemonicError + object TooManyWords : MnemonicError + object NotEnoughWords : MnemonicError + object WrongRecoveryPhrase : MnemonicError + object InvalidPhrase : MnemonicError + object WordIsAddress : MnemonicError + } + + data class ReactNativeStrings( + val inputPlaceholder: String, + val pasteButton: String, + val errorInvalidWord: String, + val errorPhraseLength: String, + val errorWrongPhrase: String, + val errorInvalidPhrase: String, + val errorWordIsAddress: String, + ) + + // Sourced externally from RN + var targetMnemonicId by mutableStateOf(null) + var rnStrings by mutableStateOf( + ReactNativeStrings( + inputPlaceholder = "", + pasteButton = "", + errorInvalidWord = "", + errorPhraseLength = "", + errorWrongPhrase = "", + errorInvalidPhrase = "", + errorWordIsAddress = "", + ) + ) + + var input by mutableStateOf(TextFieldValue("")) + private set + var status by mutableStateOf(Status.None) + private set + private var validateLastWordTimeout: Long = 1000 + private var validateLastWordJob: Job? = null + + var isFocused by mutableStateOf(false) + private set + + fun focus() { + isFocused = true + } + + fun blur() { + isFocused = false + } + + fun handleInputChange(value: TextFieldValue) { + input = value + val normalized = normalizeInput(value) + + val skipLastWord = normalized.lastOrNull() != ' ' + val skipInvalidWord = skipLastWord && !isAddress(normalized) + validateInput(normalized, skipInvalidWord) + + validateLastWordJob?.cancel() + + validateLastWordJob = viewModelScope.launch(Dispatchers.Default) { + delay(validateLastWordTimeout) + validateInput(normalized, false) + } + } + + private fun validateInput(normalizedInput: String, skipInvalidWord: Boolean) { + val mnemonic = normalizedInput.trim() + val words = mnemonic.split(" ") + + val prevStatus = status + val isValidLength = words.size in MIN_LENGTH..MAX_LENGTH + val firstInvalidWord = EthersRs.findInvalidWord(mnemonic) + val isFirstWordInvalid = firstInvalidWord == words.last() && skipInvalidWord + val isInvalidLengthError = + prevStatus is Status.Error && (prevStatus.error == MnemonicError.NotEnoughWords || prevStatus.error == MnemonicError.TooManyWords) && !isValidLength + + if (isFirstWordInvalid) { + return + } + + status = + if (isAddress(mnemonic)) { + Status.Error(MnemonicError.WordIsAddress) + } else if (firstInvalidWord.isNotEmpty()) { + Status.Error(MnemonicError.InvalidWord(firstInvalidWord)) + } else if (isInvalidLengthError) { + prevStatus + } else if (firstInvalidWord.isEmpty() && isValidLength) { + Status.Valid + } else { + Status.None + } + + val canSubmit = status !is Status.Error && mnemonic != "" + onInputValidated(canSubmit) + } + + private fun normalizeInput(value: TextFieldValue) = + value.text.replace("\\s+".toRegex(), " ").lowercase() + + private fun isAddress(value: String) = value.startsWith("0x") && value.length == 42 + + fun handleSubmit() { + validateLastWordJob?.cancel() + + try { + val normalized = normalizeInput(input) + val mnemonic = normalized.trim() + val words = mnemonic.split(" ") + val valid = EthersRs.validateMnemonic(mnemonic) + + if (words.size < MIN_LENGTH || words.size in MIN_LENGTH + 1.. MAX_LENGTH) { + status = Status.Error(MnemonicError.TooManyWords) + } else if (!valid) { + status = Status.Error(MnemonicError.InvalidPhrase) + } else { + submitMnemonic(mnemonic) + } + } catch (e: Exception) { + // TODO gary add production logging and update rust code to convert to Java exceptions + Log.d("SeedPhraseInputViewModel", "Storing mnemonic caused error ${e.message}") + } + + if (status is Status.Error) { + onInputValidated(false) + onSubmitError() + } + } + + private fun submitMnemonic(mnemonic: String) { + if (targetMnemonicId != null) { + val generatedId = ethersRs.generateAddressForMnemonic(mnemonic, derivationIndex = 0) + if (generatedId != targetMnemonicId) { + status = Status.Error(MnemonicError.WrongRecoveryPhrase) + onSubmitError() + } else { + storeMnemonic(mnemonic) + } + } else { + storeMnemonic(mnemonic) + } + } + + private fun storeMnemonic(mnemonic: String) { + val mnemonicId = ethersRs.importMnemonic(mnemonic) + onMnemonicStored(mnemonicId) + } + + companion object { + private const val MIN_LENGTH = 12 + private const val MAX_LENGTH = 24 + } + +} diff --git a/apps/mobile/android/app/src/main/java/com/uniswap/onboarding/privatekeys/PrivateKeyDisplay.kt b/apps/mobile/android/app/src/main/java/com/uniswap/onboarding/privatekeys/PrivateKeyDisplay.kt new file mode 100644 index 00000000..93edbd92 --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/uniswap/onboarding/privatekeys/PrivateKeyDisplay.kt @@ -0,0 +1,113 @@ +package com.uniswap.onboarding.privatekeys + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.mutableStateOf +import android.util.Log +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.tooling.preview.Preview +import com.uniswap.onboarding.shared.CopyButton +import com.uniswap.onboarding.shared.CopyButtonIcon +import com.uniswap.theme.CustomTypography +import com.uniswap.theme.UniswapComponent +import com.uniswap.theme.UniswapTheme + +@Composable +fun PrivateKeyDisplay( + viewModel: PrivateKeyDisplayViewModel, + address: String, + onHeightMeasured: (height: Float) -> Unit, +) { + val privateKey by viewModel.privateKey.collectAsState() + + + LaunchedEffect(address) { + viewModel.setup(address) + } + + PrivateKeyDisplayContent( + privateKey, + onHeightMeasured + ) +} + +@Composable +fun PrivateKeyDisplayContent( + privateKey: String, + onHeightMeasured: (height: Float) -> Unit, +) { + val density = LocalDensity.current.density + + Box( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight(unbounded = true) + .onSizeChanged { size -> + with(density) { + val heightInDp = size.height / density + onHeightMeasured(heightInDp) + } + } + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(UniswapTheme.spacing.spacing12)) + .background(UniswapTheme.colors.surface3) + .padding(UniswapTheme.spacing.spacing12) + + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .fillMaxWidth() + ) { + Text( + text = privateKey, + style = UniswapTheme.typography.monospace.copy( + color = UniswapTheme.colors.neutral1 + ), + modifier = Modifier.weight(1f), + ) + Spacer(modifier = Modifier.width(UniswapTheme.spacing.spacing8)) + CopyButtonIcon( + textToCopy = AnnotatedString(privateKey), + isSensitive = true + ) + } + } + } +} + +@Preview() +@Composable +fun PrivateKeyDisplayContentPreview() { + UniswapTheme { + PrivateKeyDisplayContent( + privateKey = "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCprc7GUFxQpp27kTdoEhd0VpR1hwPxRsPCK8f4sEC8iVab+WOOFB4WAM0V7942KwLoFmZpi6G01KYmROklO1YhYvCkOYgBgegT8vJeuaYKlmzBuAcdu4AWWHbeSndxdvqfjeyr6thgNIYqsPLi+Djm1VgCWFLGBMN9DEEEgpiIlQIDAQAB", + onHeightMeasured = {} + ) + } +} diff --git a/apps/mobile/android/app/src/main/java/com/uniswap/onboarding/privatekeys/PrivateKeyDisplayViewManager.kt b/apps/mobile/android/app/src/main/java/com/uniswap/onboarding/privatekeys/PrivateKeyDisplayViewManager.kt new file mode 100644 index 00000000..863c8f71 --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/uniswap/onboarding/privatekeys/PrivateKeyDisplayViewManager.kt @@ -0,0 +1,91 @@ +package com.uniswap.onboarding.privatekeys + +import android.view.View +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.ComposeView +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.WritableMap +import com.facebook.react.uimanager.ThemedReactContext +import com.facebook.react.uimanager.ViewGroupManager +import com.facebook.react.uimanager.ViewManager +import com.facebook.react.uimanager.annotations.ReactProp +import com.facebook.react.uimanager.events.RCTEventEmitter +import com.uniswap.RnEthersRs +import com.uniswap.theme.UniswapComponent +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update + +/** + * View manager used to import native component into React Native code + * for the PrivateKeyDisplay which shows the private key for the given + * address and enabled copying it to clipboard. + */ +class PrivateKeyDisplayViewManager : ViewGroupManager() { + + override fun getName(): String = REACT_CLASS + + private lateinit var context: ThemedReactContext + + private val addressFlow = MutableStateFlow("") + + override fun createViewInstance(reactContext: ThemedReactContext): ComposeView { + context = reactContext + val ethersRs = RnEthersRs(reactContext) + val viewModel = PrivateKeyDisplayViewModel(ethersRs) + + return ComposeView(reactContext).apply { + setContent { + val address by addressFlow.collectAsState() + + UniswapComponent { + PrivateKeyDisplay( + address = address, + viewModel = viewModel, + onHeightMeasured = { + val bundle = Arguments.createMap().apply { + putDouble(FIELD_HEIGHT, it.toDouble()) + } + sendEvent(id, EVENT_HEIGHT_MEASURED, bundle) + }, + ) + } + } + } + } + + /** + * Maps local event name to expected RN prop. See RN [ViewManager] docs on github for schema. + * Using bubbling instead of direct events because overriding + * getExportedCustomDirectEventTypeConstants leads to a component not found error for some reason. + * Direct events will try find callback prop on native component, and bubbled events will bubble + * up until it finds component with the right callback prop. + */ + override fun getExportedCustomBubblingEventTypeConstants(): Map { + return mapOf( + EVENT_HEIGHT_MEASURED to mapOf( + "phasedRegistrationNames" to mapOf( + "bubbled" to EVENT_HEIGHT_MEASURED, + "captured" to EVENT_HEIGHT_MEASURED + ) + ), + ) + } + + private fun sendEvent(id: Int, eventName: String, bundle: WritableMap? = null) { + context + .getJSModule(RCTEventEmitter::class.java) + .receiveEvent(id, eventName, bundle) + } + + @ReactProp(name = "address") + fun setAddress(view: View, mnemonicId: String) { + addressFlow.update { mnemonicId } + } + + companion object { + private const val REACT_CLASS = "PrivateKeyDisplay" + private const val EVENT_HEIGHT_MEASURED = "onHeightMeasured" + private const val FIELD_HEIGHT = "height" + } +} diff --git a/apps/mobile/android/app/src/main/java/com/uniswap/onboarding/privatekeys/PrivateKeyDisplayViewModel.kt b/apps/mobile/android/app/src/main/java/com/uniswap/onboarding/privatekeys/PrivateKeyDisplayViewModel.kt new file mode 100644 index 00000000..56b3b98d --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/uniswap/onboarding/privatekeys/PrivateKeyDisplayViewModel.kt @@ -0,0 +1,17 @@ +package com.uniswap.onboarding.privatekeys + +import androidx.lifecycle.ViewModel +import com.uniswap.RnEthersRs +import kotlinx.coroutines.flow.MutableStateFlow + +open class PrivateKeyDisplayViewModel( + private val ethersRs: RnEthersRs +) : ViewModel() { + + val privateKey = MutableStateFlow("") + fun setup(address: String) { + ethersRs.retrievePrivateKey(address)?.let { mnemonic -> + privateKey.value = mnemonic + } + } +} diff --git a/apps/mobile/android/app/src/main/java/com/uniswap/onboarding/scantastic/ScantasticEncryption.kt b/apps/mobile/android/app/src/main/java/com/uniswap/onboarding/scantastic/ScantasticEncryption.kt new file mode 100644 index 00000000..94eea5dc --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/uniswap/onboarding/scantastic/ScantasticEncryption.kt @@ -0,0 +1,79 @@ +package com.uniswap.onboarding.scantastic + +import com.uniswap.RnEthersRs +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactContextBaseJavaModule +import com.facebook.react.bridge.ReactMethod +import javax.crypto.spec.OAEPParameterSpec +import javax.crypto.spec.PSource +import java.security.spec.MGF1ParameterSpec +import java.math.BigInteger +import java.util.Base64 +import java.security.KeyFactory +import java.security.spec.RSAPublicKeySpec +import javax.crypto.Cipher + +class ScantasticError(override val message: String) : Exception(message) + +class ScantasticEncryption(private val reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) { + override fun getName() = "ScantasticEncryption" + + private val rnEthersRS = RnEthersRs(reactContext) + + @ReactMethod + fun getEncryptedMnemonic(mnemonicId: String, n: String, e: String, promise: Promise) { + val mnemonic = rnEthersRS.retrieveMnemonic(mnemonicId) ?: run { + promise.reject(ScantasticError("Failed to retrieve mnemonic")) + return + } + + val publicKey = try { + generatePublicRSAKey(n, e) + } catch (ex: Exception) { + promise.reject(ScantasticError("Failed to generate public Key: ${ex.message}")) + return + } + + val encodedCiphertext = try { + encryptForStorage(mnemonic, publicKey) + } catch (ex: Exception) { + promise.reject(ScantasticError("Failed to encrypt the mnemonic: ${ex.message}")) + return + } + // Normal B64 not URL encoded, use getUrlDecoder() if you need URL encoded format + val b64encodedCiphertext = Base64.getEncoder().encodeToString(encodedCiphertext) + promise.resolve(b64encodedCiphertext) + } + + @Throws(Exception::class) + private fun generatePublicRSAKey(modulusStr: String, exponentStr: String): java.security.PublicKey { + val modulus = BigInteger(1, base64UrlToStandardBase64(modulusStr).let { Base64.getDecoder().decode(it) }) + val exponent = BigInteger(1, base64UrlToStandardBase64(exponentStr).let { Base64.getDecoder().decode(it) }) + val keySpec = RSAPublicKeySpec(modulus, exponent) + return KeyFactory.getInstance("RSA").generatePublic(keySpec) + } + + // It is unclear why URLDecoder doesn't do this by default and we have to do it here instead. + private fun base64UrlToStandardBase64(input: String): String { + var base64 = input.replace("-", "+").replace("_", "/") + while (base64.length % 4 != 0) { + base64 += "=" + } + return base64 + } + + @Throws(Exception::class) + private fun encryptForStorage(plaintext: String, publicKey: java.security.PublicKey): ByteArray { + val oaepParams = OAEPParameterSpec( + "SHA-256", + "MGF1", + MGF1ParameterSpec.SHA256, + PSource.PSpecified.DEFAULT + ) + + val cipher = Cipher.getInstance("RSA/ECB/OAEPPadding") + cipher.init(Cipher.ENCRYPT_MODE, publicKey, oaepParams) + return cipher.doFinal(plaintext.toByteArray()) + } +} diff --git a/apps/mobile/android/app/src/main/java/com/uniswap/onboarding/scantastic/ScantasticEncryptionModule.kt b/apps/mobile/android/app/src/main/java/com/uniswap/onboarding/scantastic/ScantasticEncryptionModule.kt new file mode 100644 index 00000000..aec8726e --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/uniswap/onboarding/scantastic/ScantasticEncryptionModule.kt @@ -0,0 +1,20 @@ +package com.uniswap.onboarding.scantastic + +import android.view.View +import com.facebook.react.ReactPackage +import com.facebook.react.bridge.NativeModule +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.uimanager.ReactShadowNode +import com.facebook.react.uimanager.ViewManager + +class ScantasticEncryptionModule : ReactPackage { + + override fun createViewManagers( + reactContext: ReactApplicationContext + ): MutableList>> = mutableListOf() + + override fun createNativeModules( + reactContext: ReactApplicationContext + ): MutableList = listOf(ScantasticEncryption(reactContext)).toMutableList() +} + diff --git a/apps/mobile/android/app/src/main/java/com/uniswap/onboarding/shared/ActionButtons.kt b/apps/mobile/android/app/src/main/java/com/uniswap/onboarding/shared/ActionButtons.kt new file mode 100644 index 00000000..12a865d5 --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/uniswap/onboarding/shared/ActionButtons.kt @@ -0,0 +1,105 @@ +package com.uniswap.onboarding.shared + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.Icon +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import com.uniswap.R +import com.uniswap.theme.UniswapTheme + +enum class ActionButtonStatus { + SUCCESS, NEUTRAL +} + +/** + * Button that calls the action when clicked + */ +@Composable +fun ActionButton( + modifier: Modifier = Modifier, + action: () -> Unit, + text: String, + status: ActionButtonStatus = ActionButtonStatus.NEUTRAL, + @DrawableRes iconDrawable: Int? = null, +) { + val iconColor = when (status) { + ActionButtonStatus.SUCCESS -> UniswapTheme.colors.statusSuccess + ActionButtonStatus.NEUTRAL -> UniswapTheme.colors.neutral2 + } + val textColor = when (status) { + ActionButtonStatus.SUCCESS -> UniswapTheme.colors.statusSuccess + ActionButtonStatus.NEUTRAL -> UniswapTheme.colors.neutral1 + } + + Box( + modifier = modifier.shadow( + 10.dp, + spotColor = UniswapTheme.colors.black.copy(alpha = 0.04f), + shape = UniswapTheme.shapes.small + ) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(UniswapTheme.spacing.spacing4), + modifier = Modifier + .clip(shape = UniswapTheme.shapes.small) + .border(1.dp, UniswapTheme.colors.surface3, UniswapTheme.shapes.small) + .clickable { action() } + .background(color = UniswapTheme.colors.surface1) + .padding( + top = UniswapTheme.spacing.spacing8, + end = UniswapTheme.spacing.spacing16, + bottom = UniswapTheme.spacing.spacing8, + start = if (iconDrawable != null) UniswapTheme.spacing.spacing8 else UniswapTheme.spacing.spacing16 + )) { + iconDrawable?.let { + Icon( + painter = painterResource(id = it), + contentDescription = null, + tint = iconColor, + modifier = Modifier.size(20.dp) + ) + } + Text( + text = text, color = textColor, style = UniswapTheme.typography.buttonLabel4 + ) + } + } +} + +@Composable +fun PasteButton( + modifier: Modifier = Modifier, + pasteButtonText: String, + onPaste: (text: String) -> Unit +) { + val clipboardManager = LocalClipboardManager.current + + fun onClick() { + clipboardManager.getText()?.toString()?.let { + onPaste(it) + } + } + + ActionButton( + action = { onClick() }, + text = pasteButtonText, + iconDrawable = R.drawable.uniswap_icon_paste, + modifier = modifier + ) +} diff --git a/apps/mobile/android/app/src/main/java/com/uniswap/onboarding/shared/CopyButtons.kt b/apps/mobile/android/app/src/main/java/com/uniswap/onboarding/shared/CopyButtons.kt new file mode 100644 index 00000000..e810bc67 --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/uniswap/onboarding/shared/CopyButtons.kt @@ -0,0 +1,177 @@ +package com.uniswap.onboarding.shared + +import android.content.ClipData +import android.content.ClipDescription +import android.content.ClipboardManager +import android.content.Context +import android.os.Build +import android.os.PersistableBundle +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.size +import androidx.compose.material.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.uniswap.R +import com.uniswap.theme.UniswapTheme +import kotlinx.coroutines.delay +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit + + +/** + * A composable that displays a copy icon button. + * The icon changes to success color when content is copied to the clipboard. + * + * @param modifier The modifier to be applied to the component + * @param textToCopy The text to be copied to clipboard as an AnnotatedString + * @param isSensitive Whether the text contains sensitive information that should be cleared from clipboard after a delay + */ +@Composable +fun CopyButtonIcon( + modifier: Modifier = Modifier, + textToCopy: AnnotatedString, + isSensitive: Boolean = false, +) { + val (isCopied, onClick) = rememberCopyState(textToCopy, isSensitive) + + Box( + modifier = modifier + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { onClick() } + ) { + Icon( + painter = painterResource(id = R.drawable.uniswap_icon_copy_outline), + contentDescription = null, + tint = if (isCopied) UniswapTheme.colors.statusSuccess else UniswapTheme.colors.neutral1, + modifier = Modifier.size(20.dp) + ) + } +} + +/** + * A composable that displays a copy button with text. + * The button text changes when content is copied to the clipboard to provide visual feedback. + * + * @param modifier The modifier to be applied to the component + * @param copyButtonText The text to display on the button before copying + * @param copiedButtonText The text to display on the button after copying + * @param textToCopy The text to be copied to clipboard as an AnnotatedString + * @param isSensitive Whether the text contains sensitive information that should be cleared from clipboard after a delay + */ +@Composable +fun CopyButton( + modifier: Modifier = Modifier, + copyButtonText: String, + copiedButtonText: String, + textToCopy: AnnotatedString, + isSensitive: Boolean = false, +) { + val (isCopied, onClick) = rememberCopyState(textToCopy, isSensitive) + + ActionButton( + action = { onClick() }, + text = if (isCopied) copiedButtonText else copyButtonText, + iconDrawable = R.drawable.uniswap_icon_copy, + status = if (isCopied) ActionButtonStatus.SUCCESS else ActionButtonStatus.NEUTRAL, + modifier = modifier + ) +} + +/** + * A composable state function that manages clipboard operations. + * Handles copying text to clipboard and maintaining copied state with visual feedback. + * + * @param textToCopy The text to be copied to clipboard as an AnnotatedString + * @param isSensitive Whether the text contains sensitive information that should be handled securely. + * When true, sensitive data will be marked as such and will be cleared from clipboard after 2 minutes. + * @return A Pair containing: + * - Boolean: Whether the text has been copied (true) or not (false) + * - Function: Lambda function to trigger the copy operation + */ +@Composable +fun rememberCopyState( + textToCopy: AnnotatedString, + isSensitive: Boolean = false +): Pair Unit> { + val context = LocalContext.current + val backgroundExecutor = remember { + Executors.newSingleThreadScheduledExecutor() + } + DisposableEffect(Unit) { + onDispose { + backgroundExecutor.shutdown() + } + } + + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + + // Time after which the button reverts to the copy state + val copyTimeout: Long = 2000 + var isCopied by remember { mutableStateOf(false) } + var copyTimeoutId by remember { mutableStateOf(0) } + + val onClick = { + val label = if (isSensitive) "sensitive data" else "copied text" + val clipData = ClipData.newPlainText(label, textToCopy) + if (isSensitive) { + clipData.description.extras = PersistableBundle().apply { + val extraKey = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + ClipDescription.EXTRA_IS_SENSITIVE + } else { + "android.content.extra.IS_SENSITIVE" + } + putBoolean(extraKey, true) + } + backgroundExecutor.schedule({ + clipboard.clearPrimaryClip() + }, 2, TimeUnit.MINUTES) + } + + clipboard.setPrimaryClip(clipData) + copyTimeoutId++ + isCopied = true + } + + LaunchedEffect(copyTimeoutId) { + delay(copyTimeout) + isCopied = false + } + + return Pair(isCopied, onClick) +} + +@Preview(backgroundColor = 0xFFE0E0E0, showBackground = true) +@Composable +fun CopyButtonPreview() { + UniswapTheme { + Column { + CopyButtonIcon( + textToCopy = AnnotatedString("whatevs"), + isSensitive = true + ) + CopyButton( + copyButtonText = "Copy", + copiedButtonText = "Copied", + textToCopy = AnnotatedString("whatevs"), + isSensitive = true + ) + } + + } +} diff --git a/apps/mobile/android/app/src/main/java/com/uniswap/theme/Color.kt b/apps/mobile/android/app/src/main/java/com/uniswap/theme/Color.kt new file mode 100644 index 00000000..909c0fd1 --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/uniswap/theme/Color.kt @@ -0,0 +1,249 @@ +package com.uniswap.theme + +import androidx.compose.material.darkColors +import androidx.compose.material.lightColors +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color + +object UniswapColors { + val White = Color(0xFFFFFFFF) + val Black = Color(0xFF000000) + val Gray50 = Color(0xFFF5F6FC) + val Gray100 = Color(0xFFE8ECFB) + val Gray150 = Color(0xFFD2D9EE) + val Gray200 = Color(0xFFB8C0DC) + val Gray250 = Color(0xFFA6AFCA) + val Gray300 = Color(0xFF98A1C0) + val Gray350 = Color(0xFF888FAB) + val Gray400 = Color(0xFF7780A0) + val Gray450 = Color(0xFF6B7594) + val Gray500 = Color(0xFF5D6785) + val Gray550 = Color(0xFF505A78) + val Gray600 = Color(0xFF404A67) + val Gray650 = Color(0xFF333D59) + val Gray700 = Color(0xFF293249) + val Gray750 = Color(0xFF1B2236) + val Gray800 = Color(0xFF131A2A) + val Gray850 = Color(0xFF0E1524) + val Gray900 = Color(0xFF0D111C) + val Pink50 = Color(0xFFFFF2F7) + val Pink100 = Color(0xFFFFD9E4) + val Pink200 = Color(0xFFFBA4C0) + val Pink300 = Color(0xFFFF6FA3) + val Pink400 = Color(0xFFFB118E) + val Pink500 = Color(0xFFC41A69) + val Pink600 = Color(0xFF8C0F49) + val Pink700 = Color(0xFF55072A) + val Pink800 = Color(0xFF39061B) + val Pink900 = Color(0xFF2B000B) + val PinkVibrant = Color(0xFFF51A70) + val Red50 = Color(0xFFFEF0EE) + val Red100 = Color(0xFFFED5CF) + val Red200 = Color(0xFFFEA79B) + val Red300 = Color(0xFFFD766B) + val Red400 = Color(0xFFFA2B39) + val Red500 = Color(0xFFC4292F) + val Red600 = Color(0xFF891E20) + val Red700 = Color(0xFF530F0F) + val Red800 = Color(0xFF380A03) + val Red900 = Color(0xFF240800) + val RedVibrant = Color(0xFFF14544) + val Yellow50 = Color(0xFFFEF8C4) + val Yellow100 = Color(0xFFF0E49A) + val Yellow200 = Color(0xFFDBBC19) + val Yellow300 = Color(0xFFBB9F13) + val Yellow400 = Color(0xFFA08116) + val Yellow500 = Color(0xFF866311) + val Yellow600 = Color(0xFF5D4204) + val Yellow700 = Color(0xFF3E2B04) + val Yellow800 = Color(0xFF231902) + val Yellow900 = Color(0xFF180F02) + val YellowVibrant = Color(0xFFFAF40A) + val Gold50 = Color(0xFFFFF5E8) + val Gold100 = Color(0xFFF8DEB6) + val Gold200 = Color(0xFFEEB317) + val Gold300 = Color(0xFFDB900B) + val Gold400 = Color(0xFFB17900) + val Gold500 = Color(0xFF905C10) + val Gold600 = Color(0xFF643F07) + val Gold700 = Color(0xFF3F2208) + val Gold800 = Color(0xFF29160F) + val Gold900 = Color(0xFF161007) + val GoldVibrant = Color(0xFFFEB239) + val Green50 = Color(0xFFEDFDF0) + val Green100 = Color(0xFFBFEECA) + val Green200 = Color(0xFF76D191) + val Green300 = Color(0xFF40B66B) + val Green400 = Color(0xFF209853) + val Green500 = Color(0xFF0B783E) + val Green600 = Color(0xFF0C522A) + val Green700 = Color(0xFF053117) + val Green800 = Color(0xFF091F10) + val Green900 = Color(0xFF09130B) + val GreenVibrant = Color(0xFF5CFE9D) + val Blue50 = Color(0xFFF3F5FE) + val Blue100 = Color(0xFFDEE1FF) + val Blue200 = Color(0xFFADBCFF) + val Blue300 = Color(0xFF869EFF) + val Blue400 = Color(0xFF4C82FB) + val Blue500 = Color(0xFF1267D6) + val Blue600 = Color(0xFF1D4294) + val Blue700 = Color(0xFF09265E) + val Blue800 = Color(0xFF0B193F) + val Blue900 = Color(0xFF040E34) + val BlueVibrant = Color(0xFF587BFF) + val Lime50 = Color(0xFFF2FEDB) + val Lime100 = Color(0xFFD3EBA3) + val Lime200 = Color(0xFF9BCD46) + val Lime300 = Color(0xFF7BB10C) + val Lime400 = Color(0xFF649205) + val Lime500 = Color(0xFF527318) + val Lime600 = Color(0xFF344F00) + val Lime700 = Color(0xFF233401) + val Lime800 = Color(0xFF171D00) + val Lime900 = Color(0xFF0E1300) + val LimeVibrant = Color(0xFFB1F13C) + val Orange50 = Color(0xFFFEEDE5) + val Orange100 = Color(0xFFFCD9C8) + val Orange200 = Color(0xFFFBAA7F) + val Orange300 = Color(0xFFF67E3E) + val Orange400 = Color(0xFFDC5B14) + val Orange500 = Color(0xFFAF460A) + val Orange600 = Color(0xFF76330F) + val Orange700 = Color(0xFF4D220B) + val Orange800 = Color(0xFF2A1505) + val Orange900 = Color(0xFF1C0E03) + val OrangeVibrant = Color(0xFFFF6F1E) + val Magenta50 = Color(0xFFFFF1FE) + val Magenta100 = Color(0xFFFAD8F8) + val Magenta200 = Color(0xFFF5A1F5) + val Magenta300 = Color(0xFFF06DF3) + val Magenta400 = Color(0xFFDC39E3) + val Magenta500 = Color(0xFFAF2EB4) + val Magenta600 = Color(0xFF7A1C7D) + val Magenta700 = Color(0xFF550D56) + val Magenta800 = Color(0xFF330733) + val Magenta900 = Color(0xFF250225) + val MagentaVibrant = Color(0xFFFC72FF) + val Violet50 = Color(0xFFF1EFFE) + val Violet100 = Color(0xFFE2DEFD) + val Violet200 = Color(0xFFBDB8FA) + val Violet300 = Color(0xFF9D99F5) + val Violet400 = Color(0xFF7A7BEB) + val Violet500 = Color(0xFF515EDC) + val Violet600 = Color(0xFF343F9E) + val Violet700 = Color(0xFF232969) + val Violet800 = Color(0xFF121643) + val Violet900 = Color(0xFF0E0D30) + val VioletVibrant = Color(0xFF5065FD) + val Cyan50 = Color(0xFFD6F5FE) + val Cyan100 = Color(0xFFB0EDFE) + val Cyan200 = Color(0xFF63CDE8) + val Cyan300 = Color(0xFF2FB0CC) + val Cyan400 = Color(0xFF2092AB) + val Cyan500 = Color(0xFF117489) + val Cyan600 = Color(0xFF014F5F) + val Cyan700 = Color(0xFF003540) + val Cyan800 = Color(0xFF011E26) + val Cyan900 = Color(0xFF011418) + val CyanVibrant = Color(0xFF36DBFF) + val Slate50 = Color(0xFFF1FCEF) + val Slate100 = Color(0xFFDAE6D8) + val Slate200 = Color(0xFFB8C3B7) + val Slate300 = Color(0xFF9AA498) + val Slate400 = Color(0xFF7E887D) + val Slate500 = Color(0xFF646B62) + val Slate600 = Color(0xFF434942) + val Slate700 = Color(0xFF2C302C) + val Slate800 = Color(0xFF181B18) + val Slate900 = Color(0xFF0F120E) + val SlateVibrant = Color(0xFF7E887D) + val Transparent = Color.Transparent +} + +data class CustomColors( + val white: Color = UniswapColors.White, + val black: Color = UniswapColors.Black, + val surface1: Color, + val surface2: Color, + val surface3: Color, + val surface4: Color, + val surface5: Color, + val scrim: Color, + val neutral1: Color, + val neutral2: Color, + val neutral3: Color, + val accent1: Color, + val accent2: Color, + val statusActive: Color, + val statusSuccess: Color, + val statusCritical: Color, +) + +val lightCustomColors = CustomColors( + surface1 = Color(0xFFFFFFFF), + surface2 = Color(0xFFF9F9F9), + surface3 = Color(0x0D222222), + surface4 = Color(0xA3FFFFFF), + surface5 = Color(0x0A000000), + + scrim = Color(0x99000000), + + neutral1 = Color(0xFF222222), + neutral2 = Color(0xFF7D7D7D), + neutral3 = Color(0xFFCECECE), + + accent1 = Color(0xFFFC72FF), + accent2 = Color(0xFFFFEFFF), + + statusActive = Color(0xFF236EFF), + statusSuccess = Color(0xFF40B66B), + statusCritical = Color(0xFFFF5F52), +) + +val darkCustomColors = CustomColors( + surface1 = Color(0xFF131313), + surface2 = Color(0xFF1B1B1B), + surface3 = Color(0x1FFFFFFF), + surface4 = Color(0x33FFFFFF), + surface5 = Color(0x0A000000), + + scrim = Color(0x99000000), + + neutral1 = Color(0xFFFFFFFF), + neutral2 = Color(0xFF9B9B9B), + neutral3 = Color(0xFF5E5E5E), + + accent1 = Color(0xFFFC72FF), + accent2 = Color(0xFF311C31), + + statusActive = Color(0xFF236EFF), + statusSuccess = Color(0xFF40B66B), + statusCritical = Color(0xFFFF5F52), +) + +val LightColors = lightColors( + primary = lightCustomColors.accent1, + background = lightCustomColors.surface1, + surface = lightCustomColors.surface1, + error = lightCustomColors.statusCritical, + onPrimary = lightCustomColors.white, + onBackground = lightCustomColors.neutral1, + onSurface = lightCustomColors.neutral1, + onError = lightCustomColors.white, +) + +val DarkColors = darkColors( + primary = darkCustomColors.accent1, + background = darkCustomColors.surface1, + surface = darkCustomColors.surface1, + error = darkCustomColors.statusCritical, + onPrimary = darkCustomColors.white, + onBackground = darkCustomColors.neutral1, + onSurface = darkCustomColors.neutral1, + onError = darkCustomColors.white, +) + +val LocalCustomColors = staticCompositionLocalOf { + lightCustomColors +} diff --git a/apps/mobile/android/app/src/main/java/com/uniswap/theme/Modifiers.kt b/apps/mobile/android/app/src/main/java/com/uniswap/theme/Modifiers.kt new file mode 100644 index 00000000..c4197ab2 --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/uniswap/theme/Modifiers.kt @@ -0,0 +1,23 @@ +package com.uniswap.theme + +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.layout + +fun Modifier.relativeOffset( + x: Float = 0f, + y: Float = 0f, + onOffsetCalculated: (Int, Int) -> Unit = { _, _ -> } +): Modifier = this.then( + layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + + val offsetX = (placeable.width * x).toInt() + val offsetY = (placeable.height * y).toInt() + + onOffsetCalculated(offsetX, offsetY) + + layout(placeable.width, placeable.height) { + placeable.place(offsetX, offsetY) + } + } +) diff --git a/apps/mobile/android/app/src/main/java/com/uniswap/theme/Shape.kt b/apps/mobile/android/app/src/main/java/com/uniswap/theme/Shape.kt new file mode 100644 index 00000000..8367ca9f --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/uniswap/theme/Shape.kt @@ -0,0 +1,18 @@ +package com.uniswap.theme + +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.unit.dp + +data class CustomShapes( + val small: RoundedCornerShape = RoundedCornerShape(16.dp), + val medium: RoundedCornerShape = RoundedCornerShape(20.dp), + val large: RoundedCornerShape = RoundedCornerShape(24.dp), + val xlarge: RoundedCornerShape = RoundedCornerShape(100.dp), + val buttonSmall: RoundedCornerShape = RoundedCornerShape(12.dp), + val buttonMedium: RoundedCornerShape = RoundedCornerShape(16.dp), +) + +val LocalCustomShapes = staticCompositionLocalOf { + CustomShapes() +} diff --git a/apps/mobile/android/app/src/main/java/com/uniswap/theme/Spacing.kt b/apps/mobile/android/app/src/main/java/com/uniswap/theme/Spacing.kt new file mode 100644 index 00000000..d402d6a1 --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/uniswap/theme/Spacing.kt @@ -0,0 +1,26 @@ +package com.uniswap.theme + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +@Immutable +data class CustomSpacing( + val spacing1: Dp = 1.dp, + val spacing2: Dp = 2.dp, + val spacing4: Dp = 4.dp, + val spacing8: Dp = 8.dp, + val spacing12: Dp = 12.dp, + val spacing16: Dp = 16.dp, + val spacing24: Dp = 24.dp, + val spacing28: Dp = 28.dp, + val spacing32: Dp = 32.dp, + val spacing36: Dp = 36.dp, + val spacing48: Dp = 48.dp, + val spacing60: Dp = 60.dp, +) + +val LocalCustomSpacing = staticCompositionLocalOf { + CustomSpacing() +} diff --git a/apps/mobile/android/app/src/main/java/com/uniswap/theme/Typography.kt b/apps/mobile/android/app/src/main/java/com/uniswap/theme/Typography.kt new file mode 100644 index 00000000..a0fa5812 --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/uniswap/theme/Typography.kt @@ -0,0 +1,119 @@ +package com.uniswap.theme + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.sp +import com.uniswap.R + +// TODO gary update for Spore changes +@Immutable +data class CustomTypography( + val defaultFontFamily: FontFamily = FontFamily( + Font(R.font.basel_grotesk_book), + Font(R.font.basel_grotesk_medium, FontWeight.Medium), + Font(R.font.basel_grotesk_medium, FontWeight.SemiBold), + Font(R.font.basel_grotesk_medium, FontWeight.Bold), + ), + val defaultLetterSpacing: TextUnit = 0.sp, + val heading1: TextStyle = TextStyle( + fontFamily = defaultFontFamily, + fontSize = 52.sp, + lineHeight = 60.sp, + fontWeight = FontWeight.Normal, + letterSpacing = defaultLetterSpacing, + ), + val heading2: TextStyle = TextStyle( + fontFamily = defaultFontFamily, + fontSize = 36.sp, + lineHeight = 44.sp, + fontWeight = FontWeight.Normal, + letterSpacing = defaultLetterSpacing, + ), + val heading3: TextStyle = TextStyle( + fontFamily = defaultFontFamily, + fontSize = 24.sp, + lineHeight = 32.sp, + fontWeight = FontWeight.Normal, + letterSpacing = defaultLetterSpacing, + ), + val subheading1: TextStyle = TextStyle( + fontFamily = defaultFontFamily, + fontSize = 18.sp, + lineHeight = 24.sp, + fontWeight = FontWeight.Normal, + letterSpacing = defaultLetterSpacing, + ), + val subheading2: TextStyle = TextStyle( + fontFamily = defaultFontFamily, + fontSize = 16.sp, + lineHeight = 24.sp, + fontWeight = FontWeight.Normal, + letterSpacing = defaultLetterSpacing, + ), + val body1: TextStyle = TextStyle( + fontFamily = defaultFontFamily, + fontSize = 18.sp, + lineHeight = 24.sp, + fontWeight = FontWeight.Normal, + letterSpacing = defaultLetterSpacing, + ), + val body2: TextStyle = TextStyle( + fontFamily = defaultFontFamily, + fontSize = 16.sp, + lineHeight = 24.sp, + fontWeight = FontWeight.Normal, + letterSpacing = defaultLetterSpacing, + ), + val body3: TextStyle = TextStyle( + fontFamily = defaultFontFamily, + fontSize = 14.sp, + lineHeight = 16.sp, + fontWeight = FontWeight.Normal, + letterSpacing = defaultLetterSpacing, + ), + val buttonLabel1: TextStyle = TextStyle( + fontFamily = defaultFontFamily, + fontSize = 18.sp, + lineHeight = 24.sp, + fontWeight = FontWeight.Normal, + letterSpacing = defaultLetterSpacing, + ), + val buttonLabel2: TextStyle = TextStyle( + fontFamily = defaultFontFamily, + fontSize = 18.sp, + lineHeight = 24.sp, + fontWeight = FontWeight.Medium, + letterSpacing = defaultLetterSpacing, + ), + val buttonLabel3: TextStyle = TextStyle( + fontFamily = defaultFontFamily, + fontSize = 18.sp, + lineHeight = 24.sp, + fontWeight = FontWeight.Normal, + letterSpacing = defaultLetterSpacing, + ), + val buttonLabel4: TextStyle = TextStyle( + fontFamily = defaultFontFamily, + fontSize = 14.sp, + lineHeight = 16.sp, + fontWeight = FontWeight.Medium, + letterSpacing = defaultLetterSpacing, + ), + val monospace: TextStyle = TextStyle( + fontFamily = FontFamily( + Font(R.font.inputmono_regular), + ), + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = defaultLetterSpacing, + ), +) + +val LocalCustomTypography = staticCompositionLocalOf { + CustomTypography() +} diff --git a/apps/mobile/android/app/src/main/java/com/uniswap/theme/UniswapComponent.kt b/apps/mobile/android/app/src/main/java/com/uniswap/theme/UniswapComponent.kt new file mode 100644 index 00000000..7a1ba91e --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/uniswap/theme/UniswapComponent.kt @@ -0,0 +1,13 @@ +package com.uniswap.theme + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +@Composable +fun UniswapComponent(modifier: Modifier = Modifier, content: @Composable () -> Unit) { + UniswapTheme { + Surface(modifier = modifier, content = content) + } +} diff --git a/apps/mobile/android/app/src/main/java/com/uniswap/theme/UniswapTheme.kt b/apps/mobile/android/app/src/main/java/com/uniswap/theme/UniswapTheme.kt new file mode 100644 index 00000000..3aff515d --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/uniswap/theme/UniswapTheme.kt @@ -0,0 +1,56 @@ +package com.uniswap.theme + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material.MaterialTheme +import androidx.compose.material.ProvideTextStyle +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember + +@Composable +fun UniswapTheme( + darkTheme: Boolean? = null, + content: @Composable () -> Unit +) { + val isDarkTheme = darkTheme ?: isSystemInDarkTheme() + + val customSpacing = CustomSpacing() + val customTypography = CustomTypography() + val customShapes = CustomShapes() + val customColors = remember(isDarkTheme) { + if (isDarkTheme) darkCustomColors else lightCustomColors + } + + CompositionLocalProvider( + LocalCustomSpacing provides customSpacing, + LocalCustomTypography provides customTypography, + LocalCustomShapes provides customShapes, + LocalCustomColors provides customColors, + ) { + MaterialTheme( + colors = if (isDarkTheme) DarkColors else LightColors + ) { + ProvideTextStyle(value = customTypography.body1) { + content() + } + } + } +} + +object UniswapTheme { + val spacing: CustomSpacing + @Composable + get() = LocalCustomSpacing.current + + val typography: CustomTypography + @Composable + get() = LocalCustomTypography.current + + val shapes: CustomShapes + @Composable + get() = LocalCustomShapes.current + + val colors: CustomColors + @Composable + get() = LocalCustomColors.current +} diff --git a/apps/mobile/android/app/src/main/java/com/uniswap/utils/JsonWritableExtensions.kt b/apps/mobile/android/app/src/main/java/com/uniswap/utils/JsonWritableExtensions.kt new file mode 100644 index 00000000..81415055 --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/uniswap/utils/JsonWritableExtensions.kt @@ -0,0 +1,61 @@ +package com.uniswap.utils + +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.WritableArray +import com.facebook.react.bridge.WritableMap +import org.json.JSONArray +import org.json.JSONObject + +fun JSONObject.toWritableMap(): WritableMap { + val map = Arguments.createMap() + val iterator = keys() + while (iterator.hasNext()) { + val key = iterator.next() + when (val value = opt(key)) { + null, JSONObject.NULL -> map.putNull(key) + is JSONObject -> map.putMap(key, value.toWritableMap()) + is JSONArray -> map.putArray(key, value.toWritableArray()) + is Boolean -> map.putBoolean(key, value) + is Int -> map.putInt(key, value) + is Long -> { + if (value in Int.MIN_VALUE..Int.MAX_VALUE) { + map.putInt(key, value.toInt()) + } else { + map.putDouble(key, value.toDouble()) + } + } + is Double -> map.putDouble(key, value) + is Float -> map.putDouble(key, value.toDouble()) + is Number -> map.putDouble(key, value.toDouble()) + is String -> map.putString(key, value) + else -> map.putString(key, value.toString()) + } + } + return map +} + +fun JSONArray.toWritableArray(): WritableArray { + val array = Arguments.createArray() + for (index in 0 until length()) { + when (val value = opt(index)) { + null, JSONObject.NULL -> array.pushNull() + is JSONObject -> array.pushMap(value.toWritableMap()) + is JSONArray -> array.pushArray(value.toWritableArray()) + is Boolean -> array.pushBoolean(value) + is Int -> array.pushInt(value) + is Long -> { + if (value in Int.MIN_VALUE..Int.MAX_VALUE) { + array.pushInt(value.toInt()) + } else { + array.pushDouble(value.toDouble()) + } + } + is Double -> array.pushDouble(value) + is Float -> array.pushDouble(value.toDouble()) + is Number -> array.pushDouble(value.toDouble()) + is String -> array.pushString(value) + else -> array.pushString(value.toString()) + } + } + return array +} diff --git a/apps/mobile/android/app/src/main/res/drawable-hdpi/ic_stat_onesignal_default.png b/apps/mobile/android/app/src/main/res/drawable-hdpi/ic_stat_onesignal_default.png new file mode 100644 index 00000000..d5ffbcb5 Binary files /dev/null and b/apps/mobile/android/app/src/main/res/drawable-hdpi/ic_stat_onesignal_default.png differ diff --git a/apps/mobile/android/app/src/main/res/drawable-hdpi/splash_logo.png b/apps/mobile/android/app/src/main/res/drawable-hdpi/splash_logo.png new file mode 100644 index 00000000..6957bc82 Binary files /dev/null and b/apps/mobile/android/app/src/main/res/drawable-hdpi/splash_logo.png differ diff --git a/apps/mobile/android/app/src/main/res/drawable-mdpi/ic_stat_onesignal_default.png b/apps/mobile/android/app/src/main/res/drawable-mdpi/ic_stat_onesignal_default.png new file mode 100644 index 00000000..ea9d6d96 Binary files /dev/null and b/apps/mobile/android/app/src/main/res/drawable-mdpi/ic_stat_onesignal_default.png differ diff --git a/apps/mobile/android/app/src/main/res/drawable-mdpi/splash_logo.png b/apps/mobile/android/app/src/main/res/drawable-mdpi/splash_logo.png new file mode 100644 index 00000000..c344b1a3 Binary files /dev/null and b/apps/mobile/android/app/src/main/res/drawable-mdpi/splash_logo.png differ diff --git a/apps/mobile/android/app/src/main/res/drawable-xhdpi/ic_stat_onesignal_default.png b/apps/mobile/android/app/src/main/res/drawable-xhdpi/ic_stat_onesignal_default.png new file mode 100644 index 00000000..cf573d8f Binary files /dev/null and b/apps/mobile/android/app/src/main/res/drawable-xhdpi/ic_stat_onesignal_default.png differ diff --git a/apps/mobile/android/app/src/main/res/drawable-xhdpi/splash_logo.png b/apps/mobile/android/app/src/main/res/drawable-xhdpi/splash_logo.png new file mode 100644 index 00000000..65b8bf82 Binary files /dev/null and b/apps/mobile/android/app/src/main/res/drawable-xhdpi/splash_logo.png differ diff --git a/apps/mobile/android/app/src/main/res/drawable-xxhdpi/ic_stat_onesignal_default.png b/apps/mobile/android/app/src/main/res/drawable-xxhdpi/ic_stat_onesignal_default.png new file mode 100644 index 00000000..ad51cf55 Binary files /dev/null and b/apps/mobile/android/app/src/main/res/drawable-xxhdpi/ic_stat_onesignal_default.png differ diff --git a/apps/mobile/android/app/src/main/res/drawable-xxhdpi/splash_logo.png b/apps/mobile/android/app/src/main/res/drawable-xxhdpi/splash_logo.png new file mode 100644 index 00000000..614eb8e1 Binary files /dev/null and b/apps/mobile/android/app/src/main/res/drawable-xxhdpi/splash_logo.png differ diff --git a/apps/mobile/android/app/src/main/res/drawable-xxxhdpi/ic_onesignal_large_icon_default.png b/apps/mobile/android/app/src/main/res/drawable-xxxhdpi/ic_onesignal_large_icon_default.png new file mode 100644 index 00000000..16df8b52 Binary files /dev/null and b/apps/mobile/android/app/src/main/res/drawable-xxxhdpi/ic_onesignal_large_icon_default.png differ diff --git a/apps/mobile/android/app/src/main/res/drawable-xxxhdpi/ic_stat_onesignal_default.png b/apps/mobile/android/app/src/main/res/drawable-xxxhdpi/ic_stat_onesignal_default.png new file mode 100644 index 00000000..630d3498 Binary files /dev/null and b/apps/mobile/android/app/src/main/res/drawable-xxxhdpi/ic_stat_onesignal_default.png differ diff --git a/apps/mobile/android/app/src/main/res/drawable-xxxhdpi/splash_logo.png b/apps/mobile/android/app/src/main/res/drawable-xxxhdpi/splash_logo.png new file mode 100644 index 00000000..23932d00 Binary files /dev/null and b/apps/mobile/android/app/src/main/res/drawable-xxxhdpi/splash_logo.png differ diff --git a/apps/mobile/android/app/src/main/res/drawable/lux_icon_alert_triangle.xml b/apps/mobile/android/app/src/main/res/drawable/lux_icon_alert_triangle.xml new file mode 100644 index 00000000..7e8d6950 --- /dev/null +++ b/apps/mobile/android/app/src/main/res/drawable/lux_icon_alert_triangle.xml @@ -0,0 +1,9 @@ + + + diff --git a/apps/mobile/android/app/src/main/res/drawable/lux_icon_copy.xml b/apps/mobile/android/app/src/main/res/drawable/lux_icon_copy.xml new file mode 100644 index 00000000..93c09836 --- /dev/null +++ b/apps/mobile/android/app/src/main/res/drawable/lux_icon_copy.xml @@ -0,0 +1,9 @@ + + + + + diff --git a/apps/mobile/android/app/src/main/res/drawable/lux_icon_copy_outline.xml b/apps/mobile/android/app/src/main/res/drawable/lux_icon_copy_outline.xml new file mode 100644 index 00000000..55cdbfd2 --- /dev/null +++ b/apps/mobile/android/app/src/main/res/drawable/lux_icon_copy_outline.xml @@ -0,0 +1,10 @@ + + + diff --git a/apps/mobile/android/app/src/main/res/drawable/lux_icon_paste.xml b/apps/mobile/android/app/src/main/res/drawable/lux_icon_paste.xml new file mode 100644 index 00000000..aff2a179 --- /dev/null +++ b/apps/mobile/android/app/src/main/res/drawable/lux_icon_paste.xml @@ -0,0 +1,10 @@ + + + diff --git a/apps/mobile/android/app/src/main/res/drawable/lux_logo.xml b/apps/mobile/android/app/src/main/res/drawable/lux_logo.xml new file mode 100644 index 00000000..6f791adc --- /dev/null +++ b/apps/mobile/android/app/src/main/res/drawable/lux_logo.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + diff --git a/apps/mobile/android/app/src/main/res/drawable/monochrome.png b/apps/mobile/android/app/src/main/res/drawable/monochrome.png new file mode 100644 index 00000000..307d34cb Binary files /dev/null and b/apps/mobile/android/app/src/main/res/drawable/monochrome.png differ diff --git a/apps/mobile/android/app/src/main/res/drawable/splashscreen.xml b/apps/mobile/android/app/src/main/res/drawable/splashscreen.xml new file mode 100644 index 00000000..c1120e78 --- /dev/null +++ b/apps/mobile/android/app/src/main/res/drawable/splashscreen.xml @@ -0,0 +1,12 @@ + + + >>>>>> upstream/main + android:width="150dp" + android:height="150dp" + android:gravity="center" /> + diff --git a/apps/mobile/android/app/src/main/res/drawable/splashscreen_icon.xml b/apps/mobile/android/app/src/main/res/drawable/splashscreen_icon.xml new file mode 100644 index 00000000..6cee5dd9 --- /dev/null +++ b/apps/mobile/android/app/src/main/res/drawable/splashscreen_icon.xml @@ -0,0 +1,15 @@ + + + + + + diff --git a/apps/mobile/android/app/src/main/res/drawable/uniswap_icon_alert_triangle.xml b/apps/mobile/android/app/src/main/res/drawable/uniswap_icon_alert_triangle.xml new file mode 100644 index 00000000..7e8d6950 --- /dev/null +++ b/apps/mobile/android/app/src/main/res/drawable/uniswap_icon_alert_triangle.xml @@ -0,0 +1,9 @@ + + + diff --git a/apps/mobile/android/app/src/main/res/drawable/uniswap_icon_copy.xml b/apps/mobile/android/app/src/main/res/drawable/uniswap_icon_copy.xml new file mode 100644 index 00000000..93c09836 --- /dev/null +++ b/apps/mobile/android/app/src/main/res/drawable/uniswap_icon_copy.xml @@ -0,0 +1,9 @@ + + + + + diff --git a/apps/mobile/android/app/src/main/res/drawable/uniswap_icon_copy_outline.xml b/apps/mobile/android/app/src/main/res/drawable/uniswap_icon_copy_outline.xml new file mode 100644 index 00000000..55cdbfd2 --- /dev/null +++ b/apps/mobile/android/app/src/main/res/drawable/uniswap_icon_copy_outline.xml @@ -0,0 +1,10 @@ + + + diff --git a/apps/mobile/android/app/src/main/res/drawable/uniswap_icon_paste.xml b/apps/mobile/android/app/src/main/res/drawable/uniswap_icon_paste.xml new file mode 100644 index 00000000..aff2a179 --- /dev/null +++ b/apps/mobile/android/app/src/main/res/drawable/uniswap_icon_paste.xml @@ -0,0 +1,10 @@ + + + diff --git a/apps/mobile/android/app/src/main/res/drawable/uniswap_logo.xml b/apps/mobile/android/app/src/main/res/drawable/uniswap_logo.xml new file mode 100644 index 00000000..6f791adc --- /dev/null +++ b/apps/mobile/android/app/src/main/res/drawable/uniswap_logo.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + diff --git a/apps/mobile/android/app/src/main/res/font/basel_grotesk_book.otf b/apps/mobile/android/app/src/main/res/font/basel_grotesk_book.otf new file mode 100644 index 00000000..77dc33d7 Binary files /dev/null and b/apps/mobile/android/app/src/main/res/font/basel_grotesk_book.otf differ diff --git a/apps/mobile/android/app/src/main/res/font/basel_grotesk_medium.otf b/apps/mobile/android/app/src/main/res/font/basel_grotesk_medium.otf new file mode 100644 index 00000000..344235fe Binary files /dev/null and b/apps/mobile/android/app/src/main/res/font/basel_grotesk_medium.otf differ diff --git a/apps/mobile/android/app/src/main/res/font/inputmono_regular.ttf b/apps/mobile/android/app/src/main/res/font/inputmono_regular.ttf new file mode 100644 index 00000000..1da56040 Binary files /dev/null and b/apps/mobile/android/app/src/main/res/font/inputmono_regular.ttf differ diff --git a/apps/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/apps/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 00000000..bea0d99b --- /dev/null +++ b/apps/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/apps/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 00000000..1fd09965 --- /dev/null +++ b/apps/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/apps/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 00000000..bd788c5a Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/apps/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp b/apps/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp new file mode 100644 index 00000000..12a69992 Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp differ diff --git a/apps/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/apps/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 00000000..1273ce39 Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/apps/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/apps/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 00000000..fcb10308 Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/apps/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp b/apps/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp new file mode 100644 index 00000000..e78ad9d0 Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp differ diff --git a/apps/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/apps/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 00000000..84d763e2 Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/apps/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/apps/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 00000000..67e301cc Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/apps/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp b/apps/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp new file mode 100644 index 00000000..7e605fc5 Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp differ diff --git a/apps/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/apps/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 00000000..f2142707 Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/apps/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/apps/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 00000000..1e339dc5 Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/apps/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp b/apps/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp new file mode 100644 index 00000000..a40118a9 Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp differ diff --git a/apps/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/apps/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 00000000..6dcf5760 Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/apps/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/apps/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 00000000..96f504df Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/apps/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp b/apps/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp new file mode 100644 index 00000000..85e8bd09 Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp differ diff --git a/apps/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/apps/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 00000000..a48db62f Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/apps/mobile/android/app/src/main/res/playstore-icon.png b/apps/mobile/android/app/src/main/res/playstore-icon.png new file mode 100644 index 00000000..20a1535c Binary files /dev/null and b/apps/mobile/android/app/src/main/res/playstore-icon.png differ diff --git a/apps/mobile/android/app/src/main/res/raw/onboarding_dark.riv b/apps/mobile/android/app/src/main/res/raw/onboarding_dark.riv new file mode 100644 index 00000000..64d5092d Binary files /dev/null and b/apps/mobile/android/app/src/main/res/raw/onboarding_dark.riv differ diff --git a/apps/mobile/android/app/src/main/res/raw/onboarding_light.riv b/apps/mobile/android/app/src/main/res/raw/onboarding_light.riv new file mode 100644 index 00000000..5a6da1da Binary files /dev/null and b/apps/mobile/android/app/src/main/res/raw/onboarding_light.riv differ diff --git a/apps/mobile/android/app/src/main/res/values-night/colors.xml b/apps/mobile/android/app/src/main/res/values-night/colors.xml new file mode 100644 index 00000000..2c3665cb --- /dev/null +++ b/apps/mobile/android/app/src/main/res/values-night/colors.xml @@ -0,0 +1,5 @@ + + #131313 + #303030 + #ffffff + diff --git a/apps/mobile/android/app/src/main/res/values-night/styles.xml b/apps/mobile/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 00000000..a6384f9c --- /dev/null +++ b/apps/mobile/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,15 @@ + + + + + + diff --git a/apps/mobile/android/app/src/main/res/values/colors.xml b/apps/mobile/android/app/src/main/res/values/colors.xml new file mode 100644 index 00000000..6bba0da3 --- /dev/null +++ b/apps/mobile/android/app/src/main/res/values/colors.xml @@ -0,0 +1,5 @@ + + #FFFFFF + #ffffff + #000000 + diff --git a/apps/mobile/android/app/src/main/res/values/ic_launcher_background.xml b/apps/mobile/android/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 00000000..6995ddf8 --- /dev/null +++ b/apps/mobile/android/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #FEF4FF + diff --git a/apps/mobile/android/app/src/main/res/values/ids.xml b/apps/mobile/android/app/src/main/res/values/ids.xml new file mode 100644 index 00000000..ea6b36bf --- /dev/null +++ b/apps/mobile/android/app/src/main/res/values/ids.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/mobile/android/app/src/main/res/values/strings.xml b/apps/mobile/android/app/src/main/res/values/strings.xml new file mode 100644 index 00000000..81893f29 --- /dev/null +++ b/apps/mobile/android/app/src/main/res/values/strings.xml @@ -0,0 +1,11 @@ + + +<<<<<<< HEAD + Lux +======= + Uniswap +>>>>>>> upstream/main + cover + true + f50db4 + diff --git a/apps/mobile/android/app/src/main/res/values/styles.xml b/apps/mobile/android/app/src/main/res/values/styles.xml new file mode 100644 index 00000000..945f470f --- /dev/null +++ b/apps/mobile/android/app/src/main/res/values/styles.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/apps/mobile/android/app/src/main/res/xml/backup_rules.xml b/apps/mobile/android/app/src/main/res/xml/backup_rules.xml new file mode 100644 index 00000000..5221f693 --- /dev/null +++ b/apps/mobile/android/app/src/main/res/xml/backup_rules.xml @@ -0,0 +1,6 @@ + + + + diff --git a/apps/mobile/android/app/src/main/res/xml/data_extraction_rules.xml b/apps/mobile/android/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 00000000..ee264a16 --- /dev/null +++ b/apps/mobile/android/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,13 @@ + + + + + + + + + diff --git a/apps/mobile/android/app/src/main/res/xml/locales_config.xml b/apps/mobile/android/app/src/main/res/xml/locales_config.xml new file mode 100644 index 00000000..5e351306 --- /dev/null +++ b/apps/mobile/android/app/src/main/res/xml/locales_config.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/mobile/android/app/src/prod/AndroidManifest.xml b/apps/mobile/android/app/src/prod/AndroidManifest.xml new file mode 100644 index 00000000..8ff176bb --- /dev/null +++ b/apps/mobile/android/app/src/prod/AndroidManifest.xml @@ -0,0 +1,10 @@ + + + + + + diff --git a/apps/mobile/android/build.gradle b/apps/mobile/android/build.gradle new file mode 100644 index 00000000..5aa41d8b --- /dev/null +++ b/apps/mobile/android/build.gradle @@ -0,0 +1,62 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. + +buildscript { + ext { + kotlinVersion = "2.0.21" + + appCompat = "1.6.1" + compose = "1.4.3" + corePerf = "1.0.0-beta01" + flowlayout = "0.23.1" + kotlinSerialization = "1.5.1" + lifecycle = "2.5.1" + } + repositories { + google() + mavenCentral() + maven { url 'https://jitpack.io' } + } + dependencies { + classpath("com.android.tools.build:gradle") + classpath("com.facebook.react:react-native-gradle-plugin") + classpath('com.google.gms:google-services:4.3.15') + // NOTE: Do not place your application dependencies here; they belong + // in the individual module build.gradle files + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion") + } +} + +plugins { + id 'org.jetbrains.kotlin.plugin.serialization' version "$kotlinVersion" + id 'org.jetbrains.kotlin.plugin.compose' version "$kotlinVersion" apply false +} + +def reactNativeAndroidDir = new File( + providers.exec { + workingDir(rootDir) + commandLine("node", "--print", "require.resolve('react-native/package.json')") + }.standardOutput.asText.get().trim(), + "../android" +) + +allprojects { + project.pluginManager.withPlugin("com.facebook.react") { + react { + reactNativeDir = rootProject.file("../../../node_modules/react-native/") + codegenDir = rootProject.file("../../../node_modules/react-native-codegen/") + } + } + + repositories { + google() + mavenCentral() + maven { url 'https://www.jitpack.io' } + // All of React Native (JS, Obj-C sources, Android binaries) is installed from npm + maven { url(reactNativeAndroidDir) } + // expo-camera bundles a custom com.google.android:cameraview + maven { url "$rootDir/../../../node_modules/expo-camera/android/maven" } + } +} + +apply plugin: "expo-root-project" +apply plugin: "com.facebook.react.rootproject" diff --git a/apps/mobile/android/gradle.properties b/apps/mobile/android/gradle.properties new file mode 100644 index 00000000..bfd8a5c3 --- /dev/null +++ b/apps/mobile/android/gradle.properties @@ -0,0 +1,37 @@ +# Project-wide Gradle settings. + +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. + +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html + +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +# Default value: -Xmx10248m -XX:MaxPermSize=256m +# org.gradle.jvmargs=-Xmx2048m -XX:MaxPermSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 +# org.gradle.daemon=true +org.gradle.jvmargs=-Xmx8g + +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true + +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app's APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true +# Automatically convert third-party libraries to use AndroidX +# It can be removed if all used libraries have migrated to AndroidX +android.enableJetifier=true + +# Enable AAPT2 PNG crunching +android.enablePngCrunchInReleaseBuilds=true + +# Use this property to enable or disable the Hermes JS engine. +# If set to false, you will be using JSC instead. +hermesEnabled=true + +newArchEnabled=false diff --git a/apps/mobile/android/gradle/wrapper/gradle-wrapper.jar b/apps/mobile/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..e708b1c0 Binary files /dev/null and b/apps/mobile/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/apps/mobile/android/gradle/wrapper/gradle-wrapper.properties b/apps/mobile/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..4dd5cf37 --- /dev/null +++ b/apps/mobile/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,8 @@ +#Wed April 10 16:30:26 EDT 2024 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/apps/mobile/android/gradlew b/apps/mobile/android/gradlew new file mode 100755 index 00000000..faf93008 --- /dev/null +++ b/apps/mobile/android/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/apps/mobile/android/gradlew.bat b/apps/mobile/android/gradlew.bat new file mode 100644 index 00000000..772d5874 --- /dev/null +++ b/apps/mobile/android/gradlew.bat @@ -0,0 +1,95 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! + +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not "" == "%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/apps/mobile/android/link-assets-manifest.json b/apps/mobile/android/link-assets-manifest.json new file mode 100644 index 00000000..32ddfd86 --- /dev/null +++ b/apps/mobile/android/link-assets-manifest.json @@ -0,0 +1,17 @@ +{ + "migIndex": 1, + "data": [ + { + "path": "src/assets/fonts/Basel-Grotesk-Book.otf", + "sha1": "3d74b09feab9de003c5ef7861bcda7fe9c8f744f" + }, + { + "path": "src/assets/fonts/Basel-Grotesk-Medium.otf", + "sha1": "b860c729d64ac027624cc52a059132211a1665a6" + }, + { + "path": "src/assets/fonts/InputMono-Regular.ttf", + "sha1": "c27a811b8a8e05a578f67f71bb89ecb03dca5905" + } + ] +} diff --git a/apps/mobile/android/settings.gradle b/apps/mobile/android/settings.gradle new file mode 100644 index 00000000..c3a83862 --- /dev/null +++ b/apps/mobile/android/settings.gradle @@ -0,0 +1,39 @@ +pluginManagement { + def reactNativeGradlePlugin = new File( + providers.exec { + workingDir(rootDir) + commandLine("node", "--print", "require.resolve('@react-native/gradle-plugin/package.json', { paths: [require.resolve('react-native/package.json')] })") + }.standardOutput.asText.get().trim() + ).getParentFile().absolutePath + includeBuild(reactNativeGradlePlugin) + + def expoPluginsPath = new File( + providers.exec { + workingDir(rootDir) + commandLine("node", "--print", "require.resolve('expo-modules-autolinking/package.json', { paths: [require.resolve('expo/package.json')] })") + }.standardOutput.asText.get().trim(), + "../android/expo-gradle-plugin" + ).absolutePath + includeBuild(expoPluginsPath) +} + +plugins { + id("com.facebook.react.settings") + id("expo-autolinking-settings") +} + +extensions.configure(com.facebook.react.ReactSettingsExtension) { ex -> + ex.autolinkLibrariesFromCommand(expoAutolinking.rnConfigCommand) +} + +expoAutolinking.useExpoModules() +<<<<<<< HEAD +rootProject.name = 'Lux' +======= +rootProject.name = 'Uniswap' +>>>>>>> upstream/main + +expoAutolinking.useExpoVersionCatalog() +includeBuild(expoAutolinking.reactNativeGradlePlugin) + +include ':app' diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts new file mode 100644 index 00000000..03aa4e6f --- /dev/null +++ b/apps/mobile/app.config.ts @@ -0,0 +1,18 @@ +import { ExpoConfig } from 'expo/config' + +const config: ExpoConfig = { + name: 'Uniswap', + slug: 'uniswapmobile', + scheme: 'uniswap', + owner: 'uniswap', + extra: { + eas: { + projectId: 'f1be3813-43d7-49ac-a792-7f42cf8500f5', + }, + }, + experiments: { + buildCacheProvider: 'eas', + }, +} + +export default config diff --git a/apps/mobile/app.json b/apps/mobile/app.json new file mode 100644 index 00000000..5f2e6ac3 --- /dev/null +++ b/apps/mobile/app.json @@ -0,0 +1,40 @@ +{ + "expo": { + "name": "Lux Exchange", + "slug": "lux-exchange", + "version": "1.0.0", + "orientation": "portrait", + "icon": "./assets/icon.png", + "scheme": "luxexchange", + "userInterfaceStyle": "automatic", + "newArchEnabled": true, + "splash": { + "image": "./assets/splash.png", + "resizeMode": "contain", + "backgroundColor": "#1a1a2e" + }, + "assetBundlePatterns": ["**/*"], + "ios": { + "supportsTablet": true, + "bundleIdentifier": "fi.lux.exchange" + }, + "android": { + "adaptiveIcon": { + "foregroundImage": "./assets/adaptive-icon.png", + "backgroundColor": "#1a1a2e" + }, + "package": "fi.lux.exchange" + }, + "web": { + "bundler": "metro", + "output": "static" + }, + "plugins": [ + "expo-router", + "expo-secure-store" + ], + "experiments": { + "typedRoutes": true + } + } +} diff --git a/apps/mobile/babel.config.js b/apps/mobile/babel.config.js new file mode 100644 index 00000000..dc9f5486 --- /dev/null +++ b/apps/mobile/babel.config.js @@ -0,0 +1,68 @@ +const { NODE_ENV } = process.env + +const inProduction = NODE_ENV === 'production' + +module.exports = function (api) { + api.cache.using(() => process.env.NODE_ENV) + + let plugins = inProduction ? ['transform-remove-console'] : [] + + plugins = [ + ...plugins, + + // Disable compiler to fix mobile theme issues and media queries + // process.env.NODE_ENV === 'test' + // ? null + // : [ + // '@tamagui/babel-plugin', + // { + // components: ['ui'], + // // experimentalFlattenThemesOnNative: true, + // config: '../../packages/ui/src/tamagui.config.ts', + // }, + // ], + + [ + 'module-resolver', + { + alias: { + src: './src', + }, + }, + ], + [ + 'module:react-native-dotenv', + { + // ideally use envName here to add a mobile namespace but this doesn't work when sharing with dotenv-webpack + moduleName: 'react-native-dotenv', + path: '../../.env.defaults', // must use this path so this file can be shared with web since dotenv-webpack is less flexible + safe: true, + allowUndefined: false, + }, + ], + 'transform-inline-environment-variables', + // TypeScript compiles this, but in production builds, metro doesn't use tsc + '@babel/plugin-proposal-logical-assignment-operators', + // metro doesn't like these + '@babel/plugin-proposal-numeric-separator', + // https://github.com/software-mansion/react-native-reanimated/issues/3364#issuecomment-1268591867 + '@babel/plugin-proposal-export-namespace-from', + // 'react-native-reanimated/plugin' must be listed last + // https://arc.net/l/quote/plrvpkad + [ + 'react-native-reanimated/plugin', + { + globals: ['__scanCodes', '__scanOCR'], + }, + ], + ].filter(Boolean) + + return { + ignore: [ + // speeds up compile + '**/@tamagui/**/dist/**', + ], + presets: ['babel-preset-expo'], + plugins, + } +} diff --git a/apps/mobile/declarations.d.ts b/apps/mobile/declarations.d.ts new file mode 100644 index 00000000..6ba3cad8 --- /dev/null +++ b/apps/mobile/declarations.d.ts @@ -0,0 +1,8 @@ +declare module '*.svg' { + import React from 'react' + import { SvgProps } from 'react-native-svg' + const content: React.FC + export default content +} + +declare module 'react-native-device-info/jest/react-native-device-info-mock' diff --git a/apps/mobile/eas.json b/apps/mobile/eas.json new file mode 100644 index 00000000..4068f674 --- /dev/null +++ b/apps/mobile/eas.json @@ -0,0 +1,90 @@ +{ + "cli": { + "version": ">= 15.0.15", + "appVersionSource": "remote" + }, + "build": { + "development": { + "bun": "1.3.11", + "node": "22.13.1", + "developmentClient": true, + "distribution": "internal", + "android": { + "buildType": "apk", + "gradleCommand": ":app:assembleDevDebug" + } + }, + "development-simulator": { + "bun": "1.3.11", + "node": "22.13.1", + "developmentClient": true, + "distribution": "internal", + "android": { + "buildType": "apk", + "gradleCommand": ":app:assembleDevDebug", + "withoutCredentials": true + }, + "ios": { + "simulator": true, + "withoutCredentials": true + } + }, + "e2e-simulator": { + "bun": "1.3.11", + "node": "22.13.1", + "developmentClient": false, + "distribution": "internal", + "env": { + "IS_E2E_TEST": "true" + }, + "android": { + "buildType": "apk", + "gradleCommand": ":app:assembleDevDebug", + "withoutCredentials": true + }, + "ios": { + "simulator": true, + "withoutCredentials": true + } + }, + "development-release": { + "bun": "1.3.11", + "node": "22.13.1", + "distribution": "internal", + "android": { + "buildType": "apk", + "gradleCommand": ":app:assembleDevRelease" + } + }, + "beta": { + "bun": "1.3.11", + "node": "22.13.1", + "distribution": "internal", + "android": { + "buildType": "apk", + "gradleCommand": ":app:assembleBetaDebug" + } + }, + "beta-release": { + "bun": "1.3.11", + "node": "22.13.1", + "distribution": "internal", + "android": { + "buildType": "apk", + "gradleCommand": ":app:assembleBetaRelease" + } + }, + "production": { + "bun": "1.3.11", + "node": "22.13.1", + "autoIncrement": true, + "android": { + "buildType": "apk", + "gradleCommand": ":app:assembleProdRelease" + } + } + }, + "submit": { + "production": {} + } +} diff --git a/apps/mobile/env.d.ts b/apps/mobile/env.d.ts new file mode 100644 index 00000000..35eb9b98 --- /dev/null +++ b/apps/mobile/env.d.ts @@ -0,0 +1,14 @@ +/* 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' + INCLUDE_PROTOTYPE_FEATURES?: string + IS_E2E_TEST?: string + } + } +} + +export {} diff --git a/apps/mobile/eslint.config.mjs b/apps/mobile/eslint.config.mjs new file mode 100644 index 00000000..cb1a8969 --- /dev/null +++ b/apps/mobile/eslint.config.mjs @@ -0,0 +1,32 @@ +import minimal from '@uniswap/eslint-config/minimal-native' + +export default [ + { + ignores: [ + '.storybook/storybook.requires.ts', + 'app.config.ts', + 'babel.config.js', + 'fingerprint.config.js', + 'index.js', + 'jest.config.js', + 'metro.config.js', + 'react-native.config.js', + 'ReactotronConfig.ts', + 'wdyr.ts', + 'node_modules', + 'storybook-static', + 'coverage', + '.maestro/**', + '.storybook/**', + ], + }, + ...minimal, + { + languageOptions: { + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, + }, + }, +] diff --git a/apps/mobile/fingerprint.config.js b/apps/mobile/fingerprint.config.js new file mode 100644 index 00000000..0f3617ae --- /dev/null +++ b/apps/mobile/fingerprint.config.js @@ -0,0 +1,8 @@ +/** @type {import('@expo/fingerprint').Config} */ +const config = { + sourceSkips: [ + 'PackageJsonScriptsAll', // Skip all package.json scripts + 'ExpoConfigVersions', // Skip version bumps if you want + ], +} +module.exports = config diff --git a/apps/mobile/global.d.ts b/apps/mobile/global.d.ts new file mode 100644 index 00000000..f1bbb762 --- /dev/null +++ b/apps/mobile/global.d.ts @@ -0,0 +1,19 @@ +/* oxlint-disable typescript/no-explicit-any -- required here */ +/** + * The global chrome object is not available at runtime in mobile but is + * required for TypeScript compilation due to its use in the utilities package + */ +declare let chrome: { + // oxlint-disable-next-line typescript/no-explicit-any -- biome-parity: oxlint is stricter here + runtime: any + // oxlint-disable-next-line typescript/no-explicit-any -- biome-parity: oxlint is stricter here + [key: string]: any +} + +/** + * Module augmentation to @datadog deep import for tsgo compatibility + */ +declare module '@datadog/mobile-react-native/lib/typescript/rum/eventMappers/errorEventMapper' { + // oxlint-disable-next-line typescript/no-explicit-any -- biome-parity: oxlint is stricter here + export type ErrorEventMapper = (event: any) => any | null +} diff --git a/apps/mobile/index.js b/apps/mobile/index.js new file mode 100644 index 00000000..c1524458 --- /dev/null +++ b/apps/mobile/index.js @@ -0,0 +1,22 @@ +// biome-ignore assist/source/organizeImports: we want to keep the import order +import './wdyr' +// biome-ignore assist/source/organizeImports: we want to keep the import order +import { isNonTestDev } from 'utilities/src/environment/constants' + +if (isNonTestDev) { + require('./ReactotronConfig') +} + +>>>>>>> upstream/main +import 'react-native-gesture-handler' +import 'react-native-reanimated' +import 'src/logbox' +import 'src/polyfills' +<<<<<<< HEAD +======= +import { AppRegistry } from 'react-native' +// biome-ignore assist/source/organizeImports: we want to keep the import order +import App from 'src/app/App' +import AppConfig from './app.config' + +AppRegistry.registerComponent(AppConfig.name, () => App) diff --git a/apps/mobile/ios/.xcode.env b/apps/mobile/ios/.xcode.env new file mode 100644 index 00000000..772b339b --- /dev/null +++ b/apps/mobile/ios/.xcode.env @@ -0,0 +1 @@ +export NODE_BINARY=$(command -v node) diff --git a/apps/mobile/ios/Appearance/RCTThemeModule.h b/apps/mobile/ios/Appearance/RCTThemeModule.h new file mode 100644 index 00000000..1e0a38d3 --- /dev/null +++ b/apps/mobile/ios/Appearance/RCTThemeModule.h @@ -0,0 +1,4 @@ +#import + +@interface ThemeModule : NSObject +@end diff --git a/apps/mobile/ios/Appearance/RCTThemeModule.m b/apps/mobile/ios/Appearance/RCTThemeModule.m new file mode 100644 index 00000000..66895aea --- /dev/null +++ b/apps/mobile/ios/Appearance/RCTThemeModule.m @@ -0,0 +1,26 @@ +#import "RCTThemeModule.h" +#import + +@implementation ThemeModule + +RCT_EXPORT_MODULE(); + +RCT_EXPORT_METHOD(setColorScheme : (NSString *)style) { + UIUserInterfaceStyle userInterfaceStyle; + + if ([style isEqualToString:@"dark"]) { + userInterfaceStyle = UIUserInterfaceStyleDark; + } else if ([style isEqualToString:@"light"]) { + userInterfaceStyle = UIUserInterfaceStyleLight; + } else { + userInterfaceStyle = UIUserInterfaceStyleUnspecified; + } + + dispatch_async(dispatch_get_main_queue(), ^{ + for (UIWindow *window in [UIApplication sharedApplication].windows) { + window.overrideUserInterfaceStyle = userInterfaceStyle; + } + }); +} + +@end diff --git a/apps/mobile/ios/Assets.xcassets/Contents.json b/apps/mobile/ios/Assets.xcassets/Contents.json new file mode 100644 index 00000000..74d6a722 --- /dev/null +++ b/apps/mobile/ios/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/apps/mobile/ios/Components/PrivateKeyDisplay/PrivateKeyDisplayManager.m b/apps/mobile/ios/Components/PrivateKeyDisplay/PrivateKeyDisplayManager.m new file mode 100644 index 00000000..459bcac4 --- /dev/null +++ b/apps/mobile/ios/Components/PrivateKeyDisplay/PrivateKeyDisplayManager.m @@ -0,0 +1,18 @@ +// +// PrivateKeyDisplayManager.m +<<<<<<< HEAD +// Lux +======= +// Uniswap +>>>>>>> upstream/main +// +// Created by Chris Lee on 5/9/2025. +// +#import "React/RCTViewManager.h" + +@interface RCT_EXTERN_MODULE(PrivateKeyDisplayManager, RCTViewManager) + +RCT_EXPORT_VIEW_PROPERTY(address, NSString?) +RCT_EXPORT_VIEW_PROPERTY(onHeightMeasured, RCTDirectEventBlock); + +@end diff --git a/apps/mobile/ios/Components/PrivateKeyDisplay/PrivateKeyDisplayManager.swift b/apps/mobile/ios/Components/PrivateKeyDisplay/PrivateKeyDisplayManager.swift new file mode 100644 index 00000000..67e1d80a --- /dev/null +++ b/apps/mobile/ios/Components/PrivateKeyDisplay/PrivateKeyDisplayManager.swift @@ -0,0 +1,21 @@ +// +// PrivateKeyDisplayManager.swift +<<<<<<< HEAD +// Lux +======= +// Uniswap +>>>>>>> upstream/main +// +// Created by Chris Lee on 5/9/2025. +// +@objc(PrivateKeyDisplayManager) +class PrivateKeyDisplayManager: RCTViewManager { + + override func view() -> UIView! { + return PrivateKeyDisplayView() + } + + override class func requiresMainQueueSetup() -> Bool { + true + } +} diff --git a/apps/mobile/ios/Components/PrivateKeyDisplay/PrivateKeyDisplayView.swift b/apps/mobile/ios/Components/PrivateKeyDisplay/PrivateKeyDisplayView.swift new file mode 100644 index 00000000..33577567 --- /dev/null +++ b/apps/mobile/ios/Components/PrivateKeyDisplay/PrivateKeyDisplayView.swift @@ -0,0 +1,105 @@ +// +// PrivateKeyDisplayView.swift +<<<<<<< HEAD +// Lux +======= +// Uniswap +>>>>>>> upstream/main +// +// Created by Chris Lee on 5/9/2025. +// +import React +import SwiftUI + +@objcMembers class PrivateKeyDisplayView: UIView { + static let storage = NSMutableDictionary() + + private var vc = UIHostingController(rootView: PrivateKeyDisplay()) + + override init(frame: CGRect) { + super.init(frame: frame) + setup() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setup() + } + + private func setup() { + vc.view.translatesAutoresizingMaskIntoConstraints = false + vc.view.backgroundColor = .clear + addSubview(vc.view) + NSLayoutConstraint.activate([ + vc.view.topAnchor.constraint(equalTo: topAnchor), + vc.view.bottomAnchor.constraint(equalTo: bottomAnchor), + vc.view.leadingAnchor.constraint(equalTo: leadingAnchor), + vc.view.trailingAnchor.constraint(equalTo: trailingAnchor), + ]) + } + + @objc + var address: String { + set { vc.rootView.setAddress(address: newValue) } + get { return vc.rootView.props.address } + } + + @objc + var onHeightMeasured: RCTDirectEventBlock { + set { vc.rootView.props.onHeightMeasured = newValue } + get { return vc.rootView.props.onHeightMeasured } + } +} + +class PrivateKeyDisplayProps: ObservableObject { + @Published var address: String = "" + @Published var privateKey: String = "" + @Published var height: CGFloat = 0 + var onHeightMeasured: RCTDirectEventBlock = { _ in } +} + +struct PrivateKeyDisplay: View { + @ObservedObject var props = PrivateKeyDisplayProps() + @State private var buttonPadding: CGFloat = 20 + + let rnEthersRS = RNEthersRS() + let interFont = UIFont(name: "BaselGrotesk-Medium", size: 14) + + func setAddress(address: String) { + props.address = address + props.privateKey = rnEthersRS.retrievePrivateKey(address: address) ?? "" + } + + var body: some View { + HStack(spacing: 0) { + Text(props.privateKey) + .font(.system(.body, design: .monospaced)) + .lineLimit(nil) + .fixedSize(horizontal: false, vertical: true) + .padding(.trailing, 8) + .frame(maxWidth: .infinity, alignment: .leading) + CopyIconButton( + textToCopy: props.privateKey + ) + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Colors.surface3, lineWidth: 1) + ) + .background(Colors.surface2) + .cornerRadius(12) + .overlay( + GeometryReader { geometry in + Color.clear + .onAppear { + props.onHeightMeasured(["height": geometry.size.height]) + } + .onChange(of: geometry.size.height) { newValue in + props.onHeightMeasured(["height": newValue]) + } + } + ) + } +} diff --git a/apps/mobile/ios/Components/ScrollFadeExtensions.swift b/apps/mobile/ios/Components/ScrollFadeExtensions.swift new file mode 100644 index 00000000..86e79f07 --- /dev/null +++ b/apps/mobile/ios/Components/ScrollFadeExtensions.swift @@ -0,0 +1,30 @@ +// +// ScrollExtension.swift +<<<<<<< HEAD +// Lux +======= +// Uniswap +>>>>>>> upstream/main +// +// Created by Gary Ye on 8/31/23. +// + +import SwiftUI + +extension View { + func fadeOutBottom(fadeLength:CGFloat=32) -> some View { + return mask( + VStack(spacing: 0) { + + Rectangle().fill(Color.black) + + LinearGradient(gradient: + Gradient( + colors: [Color.black, Color.black.opacity(0)]), + startPoint: .top, endPoint: .bottom + ) + .frame(height: fadeLength) + } + ) + } +} diff --git a/apps/mobile/ios/GoogleServiceInfo/GoogleService-Info-Dev.plist b/apps/mobile/ios/GoogleServiceInfo/GoogleService-Info-Dev.plist new file mode 100644 index 00000000..18784c23 --- /dev/null +++ b/apps/mobile/ios/GoogleServiceInfo/GoogleService-Info-Dev.plist @@ -0,0 +1,34 @@ + + + + + CLIENT_ID + placeholder + REVERSED_CLIENT_ID + placeholder + API_KEY + placeholder + GCM_SENDER_ID + placeholder + PLIST_VERSION + 1 + BUNDLE_ID + com.luxfi.mobile + PROJECT_ID + placeholder + STORAGE_BUCKET + placeholder + IS_ADS_ENABLED + + IS_ANALYTICS_ENABLED + + IS_APPINVITE_ENABLED + + IS_GCM_ENABLED + + IS_SIGNIN_ENABLED + + GOOGLE_APP_ID + 1:placeholder:ios:placeholder + + diff --git a/apps/mobile/ios/Lux.xcodeproj/project.pbxproj b/apps/mobile/ios/Lux.xcodeproj/project.pbxproj new file mode 100644 index 00000000..da8de61f --- /dev/null +++ b/apps/mobile/ios/Lux.xcodeproj/project.pbxproj @@ -0,0 +1,3929 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 00E356F31AD99517003FC87E /* LuxTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 00E356F21AD99517003FC87E /* LuxTests.m */; }; + 037C5AAA2C04970B00B1D808 /* CopyIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = 037C5AA92C04970B00B1D808 /* CopyIcon.swift */; }; + 038FC04C9385A04F3EA5EBF4 /* TokenQuery.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16F706375DFAFB58F8440CF9 /* TokenQuery.graphql.swift */; }; + 03C788232C10E7390011E5DC /* ActionButtons.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03C788222C10E7390011E5DC /* ActionButtons.swift */; }; + 03D2F3182C218D390030D987 /* RelativeOffsetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03D2F3172C218D380030D987 /* RelativeOffsetView.swift */; }; + 0703EE032A5734A600AED1DA /* UserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0703EE022A5734A600AED1DA /* UserDefaults.swift */; }; + 0703EE052A57351800AED1DA /* Logging.swift in Sources */ = {isa = PBXBuildFile; fileRef = 072F6C372A44BECC00DA720A /* Logging.swift */; }; + 072E238E2A44D5BD006AD6C9 /* WidgetsCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 072E23862A44D5BC006AD6C9 /* WidgetsCore.framework */; }; + 072E23952A44D5BD006AD6C9 /* WidgetsCoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 072E23942A44D5BD006AD6C9 /* WidgetsCoreTests.swift */; }; + 072E23962A44D5BD006AD6C9 /* WidgetsCore.h in Headers */ = {isa = PBXBuildFile; fileRef = 072E23882A44D5BD006AD6C9 /* WidgetsCore.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 072F6C212A44A32E00DA720A /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 072F6C202A44A32E00DA720A /* WidgetKit.framework */; }; + 072F6C262A44A32E00DA720A /* WidgetsBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 072F6C252A44A32E00DA720A /* WidgetsBundle.swift */; }; + 072F6C282A44A32E00DA720A /* TokenPriceWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 072F6C272A44A32E00DA720A /* TokenPriceWidget.swift */; }; + 072F6C2B2A44A32F00DA720A /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 072F6C2A2A44A32F00DA720A /* Assets.xcassets */; }; + 072F6C2D2A44A32F00DA720A /* TokenPriceWidget.intentdefinition in Sources */ = {isa = PBXBuildFile; fileRef = 072F6C292A44A32E00DA720A /* TokenPriceWidget.intentdefinition */; }; + 072F6C2E2A44A32F00DA720A /* TokenPriceWidget.intentdefinition in Sources */ = {isa = PBXBuildFile; fileRef = 072F6C292A44A32E00DA720A /* TokenPriceWidget.intentdefinition */; }; + 072F6C312A44A32F00DA720A /* Widgets.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 072F6C1F2A44A32E00DA720A /* Widgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 07378F492A5C83ED00D26D3E /* IntentHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 078E79492A55EB3300F59CF2 /* IntentHandler.swift */; }; + 074086FA2A703B76006E3053 /* FormatTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 074086F92A703B76006E3053 /* FormatTests.swift */; }; + 0741433E2A588CCC00A157D3 /* TokenPriceWidget.intentdefinition in Sources */ = {isa = PBXBuildFile; fileRef = 072F6C292A44A32E00DA720A /* TokenPriceWidget.intentdefinition */; }; + 074143402A588F5800A157D3 /* Structs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0741433F2A588F5800A157D3 /* Structs.swift */; }; + 074322342A83E3CA00F8518D /* SchemaConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 074321DE2A83E3C900F8518D /* SchemaConfiguration.swift */; }; + 074322402A841BBD00F8518D /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0743223F2A841BBD00F8518D /* Constants.swift */; }; + 0767E0382A65C8330042ADA2 /* Colors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0767E0372A65C8330042ADA2 /* Colors.swift */; }; + 0767E03B2A65D2550042ADA2 /* Styling.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0767E03A2A65D2550042ADA2 /* Styling.swift */; }; + 0781032B76A7F58B5776207B /* FeedTransactionListQuery.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = E21B5EF6BC4582DD2BB0DA13 /* FeedTransactionListQuery.graphql.swift */; }; + 0783F7B42A619E7C009ED617 /* UIComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0783F7B32A619E7C009ED617 /* UIComponents.swift */; }; + 078E79472A55EB3300F59CF2 /* Intents.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 078E79462A55EB3300F59CF2 /* Intents.framework */; }; + 078E794E2A55EB3300F59CF2 /* WidgetIntentExtension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 078E79452A55EB3300F59CF2 /* WidgetIntentExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 07B0676C2A7D6EC8001DD9B9 /* RNWidgets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07B0676A2A7D6EC8001DD9B9 /* RNWidgets.swift */; }; + 07B0676D2A7D6EC8001DD9B9 /* RNWidgets.m in Sources */ = {isa = PBXBuildFile; fileRef = 07B0676B2A7D6EC8001DD9B9 /* RNWidgets.m */; }; + 07F0C28F2A5F3E2E00D5353E /* Env.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07F0C28E2A5F3E2E00D5353E /* Env.swift */; }; + 07F136402A575EC00067004F /* DataQueries.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07F1363F2A575EC00067004F /* DataQueries.swift */; }; + 07F136422A5763480067004F /* Network.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07F136412A5763480067004F /* Network.swift */; }; + 07F5CF712A6AD97D00C648A5 /* Chart.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07F5CF702A6AD97D00C648A5 /* Chart.swift */; }; + 07F5CF752A7020FD00C648A5 /* Format.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07F5CF742A7020FD00C648A5 /* Format.swift */; }; + 0901AAD2DCDD615889B6680A /* TransactionDetails.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47148657FBA8DCC92DD69573 /* TransactionDetails.graphql.swift */; }; + 0DB282262CDADB260014CF77 /* EmbeddedWallet.m in Sources */ = {isa = PBXBuildFile; fileRef = 0DB282242CDADB260014CF77 /* EmbeddedWallet.m */; }; + 0DB282272CDADB260014CF77 /* EmbeddedWallet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0DB282252CDADB260014CF77 /* EmbeddedWallet.swift */; }; + 0DF7B81A4727A8CA063CD5B5 /* SwapOrderDetails.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32764E27B5D9FCF0AA4217CF /* SwapOrderDetails.graphql.swift */; }; + 100A336AFEC40D106F257664 /* OnRampTransfer.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60195493B9C71EDEECA46E80 /* OnRampTransfer.graphql.swift */; }; + 121CD8F92A5E382AD1A84AA9 /* NftBalanceAssetInput.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = C394AC5DB58BDF1C33CC0927 /* NftBalanceAssetInput.graphql.swift */; }; + 12A7E560842C954098B3A558 /* NftsTabQuery.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EA8FE9029A1E7C4EAD7F547 /* NftsTabQuery.graphql.swift */; }; + 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; }; + 1440B371A1C9A42F3E91DAAE /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DF5F26A06553EFDD4D99214 /* ExpoModulesProvider.swift */; }; + 19AB4E8D176A5656BBB2D797 /* TokenProjectsQuery.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1B3A0C152B683215291E3BD /* TokenProjectsQuery.graphql.swift */; }; + 1A4145B8CC5F5F5B9297B9D6 /* NftAsset.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD3573B4E6FD61F5DDBD9C75 /* NftAsset.graphql.swift */; }; + 1B9BE681A85AE55F18054F37 /* NftTransfer.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 792EF7A7820987EA9E99D792 /* NftTransfer.graphql.swift */; }; + 1E01B5593B54A53D822972C1 /* HomeScreenTokenParts.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB3EEA677984132330D8D279 /* HomeScreenTokenParts.graphql.swift */; }; + 1F9BD005762B8BFA302E1B0C /* NftBalanceConnection.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56093E216B82288E1A4018F0 /* NftBalanceConnection.graphql.swift */; }; + 1FB2F652907A72BD28CF6DD4 /* OffRampTransfer.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3063061F7C203A1DA47B8FBB /* OffRampTransfer.graphql.swift */; }; + 213BE982C7E2CCE304B88A6E /* Image.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 284C5150E9B50FB5BE531572 /* Image.graphql.swift */; }; + 23FC1CDFE7B5E25CEDF338F2 /* TokenProjectMarketsParts.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BA467FB84503BB53B081C73 /* TokenProjectMarketsParts.graphql.swift */; }; + 29BC0E8E68D0365EB77456AF /* PortfolioBalancesQuery.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8604CAA8F7E44D704A6F4AAF /* PortfolioBalancesQuery.graphql.swift */; }; + 2C7ED9DF09914F82D85DE7C9 /* NftBalanceEdge.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = F182567B705FC0E15DA8F34A /* NftBalanceEdge.graphql.swift */; }; + 2DE0A41ECCCE673BAE68F715 /* FeeData.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 290B66F38FEC486AF1709BE4 /* FeeData.graphql.swift */; }; + 2FF904EA65F2A7B960547609 /* NftBalancesFilterInput.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = F70F4D05A45BC18EA88868A0 /* NftBalancesFilterInput.graphql.swift */; }; + 333C6D9C267BB7783F9CA56E /* TokenBalanceMainParts.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F0C115796D33739746778E3 /* TokenBalanceMainParts.graphql.swift */; }; + 340A73A44EC57C9376FF24E9 /* SwapOrderType.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A7D41CB7AA0496123E2153D /* SwapOrderType.graphql.swift */; }; + 37A47FF4EEEB8E9D839D5DD6 /* TokenMarket.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = A02D64B698D203A7F3643C18 /* TokenMarket.graphql.swift */; }; + 3B27BBB18E152F19B68D6FAB /* TokenMarketParts.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6754D4BD9CE76AAC915F814 /* TokenMarketParts.graphql.swift */; }; + 44B612C45F986F8ACB66D366 /* Query.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58641E30674C4C4241702902 /* Query.graphql.swift */; }; + 45FFF7DF2E8C2A8100362570 /* SilentPushEventEmitter.m in Sources */ = {isa = PBXBuildFile; fileRef = 45FFF7DE2E8C2A6400362570 /* SilentPushEventEmitter.m */; }; + 45FFF7E12E8C2E6900362570 /* SilentPushEventEmitter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45FFF7E02E8C2E6100362570 /* SilentPushEventEmitter.swift */; }; + 462433873C56042BDAA3D8E1 /* MultiplePortfolioBalancesQuery.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3AF9944E853669C2BC9B5AB /* MultiplePortfolioBalancesQuery.graphql.swift */; }; + 463BA791004B1B7AC1773914 /* Pods_Lux.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2226DF79BEAFECEE11A51347 /* Pods_Lux.framework */; }; + 498F01286C660E8241774216 /* TokenTransfer.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93783F96FA74CB9A1BEB32EE /* TokenTransfer.graphql.swift */; }; + 4AA5D71F770AA5E8A5AB8D6A /* TokenBalanceParts.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = B97D41BC2760803FD1C7CB3C /* TokenBalanceParts.graphql.swift */; }; + 4D846E92BAEED7A4F5B72919 /* TokenStandard.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE5E0F71E23968DEFCEB7A4B /* TokenStandard.graphql.swift */; }; + 4ECB63464100262E5A163345 /* TokenBalanceQuantityParts.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D5FF4316461BD4FB5847D62 /* TokenBalanceQuantityParts.graphql.swift */; }; + 50F87796A463D0224875F0F4 /* TransactionHistoryUpdaterQuery.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AE6A0CAEDE1DC1D2FA14DEA /* TransactionHistoryUpdaterQuery.graphql.swift */; }; + 530FBF9EB2190828460BD496 /* AmountChange.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43AB42F3ED903CF49D7D4DE7 /* AmountChange.graphql.swift */; }; + 554AB3FB0D09B2DBC870E044 /* TokenDetailsScreenQuery.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7ED83FFCB410FCCA2C6B97B /* TokenDetailsScreenQuery.graphql.swift */; }; + 57D4FACDC13E20AD220D7AEC /* Currency.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5DDE9BCC6A539F6E9AE1664 /* Currency.graphql.swift */; }; + 5A9BA87096096C8BDC5FF210 /* SchemaMetadata.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73D981A2D31B6F8822C19324 /* SchemaMetadata.graphql.swift */; }; + 5B4398EC2DD3B22C00F6BE08 /* PrivateKeyDisplayManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 5B4398E82DD3B22C00F6BE08 /* PrivateKeyDisplayManager.m */; }; + 5B4398ED2DD3B22C00F6BE08 /* PrivateKeyDisplayManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B4398E92DD3B22C00F6BE08 /* PrivateKeyDisplayManager.swift */; }; + 5B4398EE2DD3B22C00F6BE08 /* PrivateKeyDisplayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B4398EA2DD3B22C00F6BE08 /* PrivateKeyDisplayView.swift */; }; + 5B4CEC5F2DD65DD4009F082B /* CopyIconOutline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B4CEC5E2DD65DD4009F082B /* CopyIconOutline.swift */; }; + 5CC5D4B276CC1E3BA1906120 /* NftContract.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A2AA83B4F9F4290A2BC7CCB /* NftContract.graphql.swift */; }; + 5E5E0A632D380F5800E166AA /* Env.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E5E0A622D380F5700E166AA /* Env.swift */; }; + 5F14564B8F6814A85EF53C46 /* TokenSortableField.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D96819BA563CB218D29C657 /* TokenSortableField.graphql.swift */; }; + 5F581541EFD85236EF98D3F3 /* TokenApproval.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 009F4258CE111989223E99A4 /* TokenApproval.graphql.swift */; }; + 5FED383DD705AEFE8B7DA8DC /* TransactionListQuery.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = C27225607FACF638581E25B5 /* TransactionListQuery.graphql.swift */; }; + 63E84504F6D89EFBF97F4ACF /* SelectWalletScreenQuery.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB3CBF221D5D2E3C036A1BA0 /* SelectWalletScreenQuery.graphql.swift */; }; + 649A7A782D9AE70B00B53589 /* KeychainUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649A7A772D9AE70B00B53589 /* KeychainUtils.swift */; }; + 649A7A792D9AE70B00B53589 /* KeychainConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649A7A762D9AE70B00B53589 /* KeychainConstants.swift */; }; + 6602483D8F6C6481FB8128E9 /* HistoryDuration.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC6517FF8972DA448CDD2DFC /* HistoryDuration.graphql.swift */; }; + 6682BC9B8E38430BE82FADDA /* NftBalance.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8BAFEAABF78617095D44E32 /* NftBalance.graphql.swift */; }; + 6B8E2BD6B9A12FF05F826BDB /* TopTokensQuery.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = C050FFEC067334CF05C17EA0 /* TopTokensQuery.graphql.swift */; }; + 6BC7D07E2B5FF02400617C95 /* ScantasticEncryption.m in Sources */ = {isa = PBXBuildFile; fileRef = 6BC7D07B2B5FF02400617C95 /* ScantasticEncryption.m */; }; + 6BC7D07F2B5FF02400617C95 /* ScantasticEncryption.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6BC7D07C2B5FF02400617C95 /* ScantasticEncryption.swift */; }; + 6BC7D0802B5FF02400617C95 /* EncryptionUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6BC7D07D2B5FF02400617C95 /* EncryptionUtils.swift */; }; + 6C8EFC2D2891B99100FBD8EB /* EncryptionHelperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C8EFC2C2891B99100FBD8EB /* EncryptionHelperTests.swift */; }; + 6CA91BDB2A95223C00C4063E /* RNEthersRS.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CA91BD92A95223C00C4063E /* RNEthersRS.swift */; }; + 6CA91BDC2A95223C00C4063E /* RnEthersRS.m in Sources */ = {isa = PBXBuildFile; fileRef = 6CA91BDA2A95223C00C4063E /* RnEthersRS.m */; }; + 6CA91BE12A95226200C4063E /* RNCloudStorageBackupsManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 6CA91BDE2A95226200C4063E /* RNCloudStorageBackupsManager.m */; }; + 6CA91BE22A95226200C4063E /* EncryptionHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CA91BDF2A95226200C4063E /* EncryptionHelper.swift */; }; + 6CA91BE32A95226200C4063E /* RNCloudStorageBackupsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CA91BE02A95226200C4063E /* RNCloudStorageBackupsManager.swift */; }; + 6E2E4593B2C40DBBA7C861BF /* IContract.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D88F29D12AB0D8B4C00D065 /* IContract.graphql.swift */; }; + 6FAEE2539C8AFB99445F29BE /* TokenFeeDataParts.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 413CEB2443D6DD1DA7CC7231 /* TokenFeeDataParts.graphql.swift */; }; + 70EB8338CA39744B7DBD553E /* Pods_WidgetIntentExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1064E23E366D0C2C2B20C30E /* Pods_WidgetIntentExtension.framework */; }; + 7154698A2773F05CAD639211 /* TokenParts.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A266B733FD2FA9C1C1A7731 /* TokenParts.graphql.swift */; }; + 72272D14F0DB24D05F1162FE /* NftCollection.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2ECFE86020AB7A252CDC9BB1 /* NftCollection.graphql.swift */; }; + 722E7F2ECE654320C2452CC8 /* TokenProtectionInfoParts.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93D1BA7F635F61FC7E62C6C1 /* TokenProtectionInfoParts.graphql.swift */; }; + 72567EB83861EBC04FAA0941 /* TokenProjectDescriptionQuery.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C66DD1D03A824EB6A231DCE /* TokenProjectDescriptionQuery.graphql.swift */; }; + 74D0262298BD1BE5363444B5 /* TokenBasicProjectParts.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB9FB14C8B68CE1D05643256 /* TokenBasicProjectParts.graphql.swift */; }; + 75FA3F2F18047B759472AEA8 /* NetworkFee.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FB70424D7C456B9B72536C6 /* NetworkFee.graphql.swift */; }; + 77206ACF669BD9DAAC096227 /* PageInfo.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F2898E709490CC198B98228 /* PageInfo.graphql.swift */; }; + 77CF6065C8A24FE48204A2C1 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = BF9176E944C84910B1C0B057 /* SplashScreen.storyboard */; }; + 7B49698C356931577828B41E /* TimestampedAmount.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C9E8E39C13967E447FC193F /* TimestampedAmount.graphql.swift */; }; + 8273FC23FB1AE47B80C5E09F /* Pods_OneSignalNotificationServiceExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 15092E550A1C78508ABA3280 /* Pods_OneSignalNotificationServiceExtension.framework */; }; + 8385A47D3C765B841F450090 /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = C26D739993D5C939C6FBB58A /* ExpoModulesProvider.swift */; }; + 890E98060D98F2F5617D1914 /* HomeScreenTokensQuery.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2240891823DAE7BF8BB88FF4 /* HomeScreenTokensQuery.graphql.swift */; }; + 8989D182DEC9682661D588F3 /* NftApproval.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42158AF500DDBEEF6B7E98E3 /* NftApproval.graphql.swift */; }; + 8B2A92172EB3E78E00990413 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B2A92162EB3E78E00990413 /* AppDelegate.swift */; }; + 8C19BAD465EA9DFEB20EFB24 /* ActivityDetails.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = B68E391195D4587F61A33380 /* ActivityDetails.graphql.swift */; }; + 8D90B4306573344A1FFC4832 /* OnRampTransactionDetails.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 614A491FAB9659FA08CD9AE4 /* OnRampTransactionDetails.graphql.swift */; }; + 8E89C3AE2AB8AAA400C84DE5 /* MnemonicConfirmationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E89C3A62AB8AAA400C84DE5 /* MnemonicConfirmationView.swift */; }; + 8E89C3AF2AB8AAA400C84DE5 /* MnemonicDisplayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E89C3A72AB8AAA400C84DE5 /* MnemonicDisplayView.swift */; }; + 8E89C3B12AB8AAA400C84DE5 /* MnemonicConfirmationWordBankView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E89C3A92AB8AAA400C84DE5 /* MnemonicConfirmationWordBankView.swift */; }; + 8E89C3B22AB8AAA400C84DE5 /* MnemonicTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E89C3AA2AB8AAA400C84DE5 /* MnemonicTextField.swift */; }; + 8E89C3B32AB8AAA400C84DE5 /* MnemonicDisplayManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 8E89C3AB2AB8AAA400C84DE5 /* MnemonicDisplayManager.m */; }; + 8E89C3B42AB8AAA400C84DE5 /* MnemonicConfirmationManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 8E89C3AC2AB8AAA400C84DE5 /* MnemonicConfirmationManager.m */; }; + 8EA8AB3B2AB7ED3C004E7EF3 /* SeedPhraseInputManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 8EA8AB302AB7ED3C004E7EF3 /* SeedPhraseInputManager.m */; }; + 8EA8AB3C2AB7ED3C004E7EF3 /* SeedPhraseInputViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8EA8AB312AB7ED3C004E7EF3 /* SeedPhraseInputViewModel.swift */; }; + 8EA8AB3D2AB7ED3C004E7EF3 /* SeedPhraseInputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8EA8AB322AB7ED3C004E7EF3 /* SeedPhraseInputView.swift */; }; + 8EA8AB3E2AB7ED3C004E7EF3 /* SeedPhraseInputManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8EA8AB332AB7ED3C004E7EF3 /* SeedPhraseInputManager.swift */; }; + 8EA8AB412AB7ED76004E7EF3 /* AlertTriangleIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8EA8AB402AB7ED76004E7EF3 /* AlertTriangleIcon.swift */; }; + 8EBFB1552ABA6AA6006B32A8 /* PasteIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8EBFB1542ABA6AA6006B32A8 /* PasteIcon.swift */; }; + 8ED0562C2AA78E2C009BD5A2 /* ScrollFadeExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8ED0562B2AA78E2C009BD5A2 /* ScrollFadeExtensions.swift */; }; + 90B93D7970186C8FC83F9292 /* TokenBasicInfoParts.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 319AAC5377C60EB6579CC5C6 /* TokenBasicInfoParts.graphql.swift */; }; + 914DFEC13BB1853D25D23E34 /* ProtectionAttackType.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE4AB3FCC91BE6C73557E0E3 /* ProtectionAttackType.graphql.swift */; }; + 939256080FE6141E53B49E90 /* Dimensions.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DCC7E203AB163C9C2D3D789 /* Dimensions.graphql.swift */; }; + 95AA27A2056B51265EE643F1 /* AssetActivity.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BB8B6395E3C86412FA793CB /* AssetActivity.graphql.swift */; }; + 99ED3ACCFE04709CFA7FD490 /* WidgetTokensQuery.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0C956C520AAB54BCE8C5CCC /* WidgetTokensQuery.graphql.swift */; }; + 9F1AE0C43E80AE592CA4AD7E /* ProtectionInfo.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 346B304DE3207797FD1FDE71 /* ProtectionInfo.graphql.swift */; }; + 9F78980B2A819CC4004D5A98 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 072F6C222A44A32E00DA720A /* SwiftUI.framework */; }; + 9F78980E2A819D2B004D5A98 /* WidgetsCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 072E23862A44D5BC006AD6C9 /* WidgetsCore.framework */; }; + 9F7898112A819D32004D5A98 /* WidgetsCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 072E23862A44D5BC006AD6C9 /* WidgetsCore.framework */; }; + 9F7898142A819D62004D5A98 /* WidgetsCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 072E23862A44D5BC006AD6C9 /* WidgetsCore.framework */; }; + 9F7898152A819D62004D5A98 /* WidgetsCore.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 072E23862A44D5BC006AD6C9 /* WidgetsCore.framework */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 9FCEBF002A95A8E00079EDDB /* RNWalletConnect.m in Sources */ = {isa = PBXBuildFile; fileRef = 9FCEBEFE2A95A8E00079EDDB /* RNWalletConnect.m */; }; + 9FCEBF012A95A8E00079EDDB /* RNWalletConnect.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FCEBEFF2A95A8E00079EDDB /* RNWalletConnect.swift */; }; + 9FCEBF042A95A99C0079EDDB /* RCTThemeModule.m in Sources */ = {isa = PBXBuildFile; fileRef = 9FCEBF032A95A99B0079EDDB /* RCTThemeModule.m */; }; + A0ACC9C3ABF174616E0CBCA4 /* OffRampTransactionDetails.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AFC48B9A573915BFA6FC6E2 /* OffRampTransactionDetails.graphql.swift */; }; + A32F9FBD272343C9002CFCDB /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = A32F9FBC272343C8002CFCDB /* GoogleService-Info.plist */; }; + A3551F2CAC134AD49D40927F /* Basel-Grotesk-Book.otf in Resources */ = {isa = PBXBuildFile; fileRef = 6F33E8069B7B40AFB313B8B0 /* Basel-Grotesk-Book.otf */; }; + A3EEE7EE3CC93DDBA3CE6A5C /* PortfolioValueModifier.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13150074BB66C20E7EA271A /* PortfolioValueModifier.graphql.swift */; }; + A3F0A5B1272B1DFA00895B25 /* KeychainSwiftDistrib.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3F0A5B0272B1DFA00895B25 /* KeychainSwiftDistrib.swift */; }; + AC0EE0982BD826E700BCCF07 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = AC0EE0972BD826E700BCCF07 /* PrivacyInfo.xcprivacy */; }; + AC2EF4032C914B1600EEEFDB /* fonts in Resources */ = {isa = PBXBuildFile; fileRef = AC2EF4022C914B1600EEEFDB /* fonts */; }; + ADD898CA4B87F6E7C990E268 /* NftCollectionMarket.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66697C913CF365C9460F42E6 /* NftCollectionMarket.graphql.swift */; }; + AEA0B1AC57BB6F11F5861BCE /* Token.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88B481513A2E780E6489DC4F /* Token.graphql.swift */; }; + AF467A9F1C200706537B24E5 /* TokenBalance.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AF8EE05291534B82ACC1105 /* TokenBalance.graphql.swift */; }; + B009D229E81544EA0F47C6DD /* TransactionStatus.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = A182D5FF8C0919083F2E333C /* TransactionStatus.graphql.swift */; }; + B193AD315CF844A3BDC3D11D /* Basel-Grotesk-Medium.otf in Resources */ = {isa = PBXBuildFile; fileRef = 3C606D2C81014A0A8898F38E /* Basel-Grotesk-Medium.otf */; }; + B2692B7521F4E7767DECE974 /* NftsQuery.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42013D777BAA40652AB28985 /* NftsQuery.graphql.swift */; }; + B4F84A94618C5A8D4F12007E /* ContractInput.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 957594D21A42B9D77F5AD685 /* ContractInput.graphql.swift */; }; + B5EF58A67FC42D684B96C0F0 /* TokenProjectMarket.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3E638BE80543E905D072D7F /* TokenProjectMarket.graphql.swift */; }; + B83B63F900E565F5C3F11589 /* FavoriteTokenCardQuery.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48DE72B77CD2D89B75BB934D /* FavoriteTokenCardQuery.graphql.swift */; }; + B8ADB9BF8BB2D4E456D80B23 /* NftStandard.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D973A66C23859D34E6FE175 /* NftStandard.graphql.swift */; }; + BA8FC9627A40644259D9E2F9 /* Pods_WidgetsCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CB29AC0C0907A833F23D2C30 /* Pods_WidgetsCore.framework */; }; + BD07375602C71B48961CD5A0 /* Portfolio.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = A34E3A6E13A4066767AA6757 /* Portfolio.graphql.swift */; }; + C403639465231038D196D7B5 /* BridgedWithdrawalInfo.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44D824154584CD39C82904E9 /* BridgedWithdrawalInfo.graphql.swift */; }; + CA4994AA5EF5F36F42117ECA /* TopTokenParts.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43D7F54953D864DC067FADD1 /* TopTokenParts.graphql.swift */; }; + CBFF07DDFB9C638D6E383290 /* SchemaConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D25B69F4A69D733A5DAEC42 /* SchemaConfiguration.swift */; }; + CFA408C9A92DB1F808BA57CD /* MobileSchema.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = A24882D069B34B7DC9E8B4CE /* MobileSchema.graphql.swift */; }; + D0AC45D21734567F720877AD /* TokenProjectUrlsParts.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F1055EFA8467996754739A7 /* TokenProjectUrlsParts.graphql.swift */; }; + D1642682124F702EE2454A64 /* IAmount.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = A372929BEDB706B6144334F0 /* IAmount.graphql.swift */; }; + D179B805D7A77EA127F41F13 /* DescriptionTranslations.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F02DBC2C51EFC6B1AA9C1CD /* DescriptionTranslations.graphql.swift */; }; + D3B63ACA9B0C42F68080B080 /* InputMono-Regular.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 1834199AFFB04D91B05FFB64 /* InputMono-Regular.ttf */; }; + D420E10F9BA8C789530E70F4 /* ApplicationContract.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BF540E1110D0FD1C219C45C /* ApplicationContract.graphql.swift */; }; + D6B69EFB74ACEF485C289266 /* ConvertQuery.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D3112654E5722A2F862D3FC /* ConvertQuery.graphql.swift */; }; + D7926D4A878B2237137B300F /* Pods_WidgetsCoreTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 021E59CE7ECBD4FE0F3BFCFD /* Pods_WidgetsCoreTests.framework */; }; + D86DB22D27B00DA71F968324 /* TransactionType.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37A6D5D76D31F8C5CDD29814 /* TransactionType.graphql.swift */; }; + DE2F24512E7204C2CA255C50 /* Pods_Widgets.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2E8B7D36D2E14D9488F351EB /* Pods_Widgets.framework */; }; + DEBB37600A7C5C9A273DA38E /* BlockaidFees.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 201FF7E30EC98ABC121BC8BF /* BlockaidFees.graphql.swift */; }; + DFBC904E6C0B818152912819 /* OnRampServiceProvider.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E5478334318BD5D6F8366D6 /* OnRampServiceProvider.graphql.swift */; }; + E4B3067A930D2E57558E5229 /* Pods_Lux_LuxTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0929C0B4AE1570B8C0B45D4D /* Pods_Lux_LuxTests.framework */; }; + E6CC238CB9AF565DE89087B7 /* SafetyLevel.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41FFD02A227ACFB661A44813 /* SafetyLevel.graphql.swift */; }; + E774B10E7FB502BE97D3895B /* ProtectionResult.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA586C522ABE5DA5363D27C7 /* ProtectionResult.graphql.swift */; }; + ECB6546D9AA307163172BEA3 /* NftApproveForAll.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = F99987D29B574C20584CE11A /* NftApproveForAll.graphql.swift */; }; + EEEE88236C7EBC4B67BBE858 /* TokenProject.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = A424FA93DB720DD7684C7674 /* TokenProject.graphql.swift */; }; + F35AFD3E27EE49990011A725 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F35AFD3D27EE49990011A725 /* NotificationService.swift */; }; + F35AFD4227EE49990011A725 /* OneSignalNotificationServiceExtension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = F35AFD3B27EE49990011A725 /* OneSignalNotificationServiceExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + F36073C9BDFF611771D04683 /* TokenPriceHistoryQuery.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDA02DA46906E4CDF827104A /* TokenPriceHistoryQuery.graphql.swift */; }; + F3E5BB84916B1F496A0C740D /* TokensQuery.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5442480B3C91EDC515728606 /* TokensQuery.graphql.swift */; }; + F5FD688A33BCADBF601A2D7F /* TransactionDirection.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EC9CABC0B4E73B25C7A2128 /* TransactionDirection.graphql.swift */; }; + F6EA6445BF4E0DEEDD076C7A /* Chain.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25DDE9DCAFA0EE2838508994 /* Chain.graphql.swift */; }; + F784F1CA9FCEB5FFE4C1DD62 /* NftProfile.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = E21EB4FB53B7B6B5848946D9 /* NftProfile.graphql.swift */; }; + F7B8B2FAEB30B3343CFF6F29 /* SwapOrderStatus.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 132FC1B05AE3D510C3B71EBA /* SwapOrderStatus.graphql.swift */; }; + F7CA47B62C91F3B0DA4106C0 /* AssetChange.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = F38DFE921F0467FF51A3D717 /* AssetChange.graphql.swift */; }; + F8E54B79B2742008CF1C0AA9 /* OnRampTransactionsAuth.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38EAF64134981DC76B6C7E90 /* OnRampTransactionsAuth.graphql.swift */; }; + FAB057109F187E5375D137FD /* Amount.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74673620CE906C9AF34C6750 /* Amount.graphql.swift */; }; + FD54D51D296C79A4007A37E9 /* GoogleServiceInfo in Resources */ = {isa = PBXBuildFile; fileRef = FD54D51C296C79A4007A37E9 /* GoogleServiceInfo */; }; + FD7304CE28A364FC0085BDEA /* Colors.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = FD7304CD28A364FC0085BDEA /* Colors.xcassets */; }; + FD7304D028A3650A0085BDEA /* Colors.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD7304CF28A3650A0085BDEA /* Colors.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 00E356F41AD99517003FC87E /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 83CBB9F71A601CBA00E9B192 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 13B07F861A680F5B00A75B9A; + remoteInfo = Lux; + }; + 0703EE082A57355400AED1DA /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 83CBB9F71A601CBA00E9B192 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 072E23852A44D5BC006AD6C9; + remoteInfo = LuxWidgetsCore; + }; + 072E238F2A44D5BD006AD6C9 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 83CBB9F71A601CBA00E9B192 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 072E23852A44D5BC006AD6C9; + remoteInfo = LuxWidgetsCore; + }; + 072E23912A44D5BD006AD6C9 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 83CBB9F71A601CBA00E9B192 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 13B07F861A680F5B00A75B9A; + remoteInfo = Lux; + }; + 072F6C2F2A44A32F00DA720A /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 83CBB9F71A601CBA00E9B192 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 072F6C1E2A44A32E00DA720A; + remoteInfo = LuxWidgetExtension; + }; + 078E794C2A55EB3300F59CF2 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 83CBB9F71A601CBA00E9B192 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 078E79442A55EB3300F59CF2; + remoteInfo = LuxWidgetsIntentExtension; + }; + 9F7898052A819AA2004D5A98 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 83CBB9F71A601CBA00E9B192 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 072E23852A44D5BC006AD6C9; + remoteInfo = WidgetsCore; + }; + 9F7898162A819D62004D5A98 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 83CBB9F71A601CBA00E9B192 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 072E23852A44D5BC006AD6C9; + remoteInfo = WidgetsCore; + }; + F35AFD4027EE49990011A725 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 83CBB9F71A601CBA00E9B192 /* Project object */; + proxyType = 1; + remoteGlobalIDString = F35AFD3A27EE49990011A725; + remoteInfo = OneSignalNotificationServiceExtension; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9F7898182A819D62004D5A98 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + 9F7898152A819D62004D5A98 /* WidgetsCore.framework in Embed Frameworks */, + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; + F35AFD4627EE49990011A725 /* Embed App Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + 072F6C312A44A32F00DA720A /* Widgets.appex in Embed App Extensions */, + 078E794E2A55EB3300F59CF2 /* WidgetIntentExtension.appex in Embed App Extensions */, + F35AFD4227EE49990011A725 /* OneSignalNotificationServiceExtension.appex in Embed App Extensions */, + ); + name = "Embed App Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 009F4258CE111989223E99A4 /* TokenApproval.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = TokenApproval.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Objects/TokenApproval.graphql.swift; sourceTree = ""; }; + 00E356EE1AD99517003FC87E /* LuxTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = LuxTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 00E356F11AD99517003FC87E /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 00E356F21AD99517003FC87E /* LuxTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = LuxTests.m; sourceTree = ""; }; + 021E59CE7ECBD4FE0F3BFCFD /* Pods_WidgetsCoreTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_WidgetsCoreTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 037C5AA92C04970B00B1D808 /* CopyIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CopyIcon.swift; sourceTree = ""; }; + 03C788222C10E7390011E5DC /* ActionButtons.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionButtons.swift; sourceTree = ""; }; + 03D2F3172C218D380030D987 /* RelativeOffsetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelativeOffsetView.swift; sourceTree = ""; }; + 065A981F892F7A06A900FCD5 /* Pods-WidgetsCoreTests.dev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WidgetsCoreTests.dev.xcconfig"; path = "Target Support Files/Pods-WidgetsCoreTests/Pods-WidgetsCoreTests.dev.xcconfig"; sourceTree = ""; }; + 0703EE022A5734A600AED1DA /* UserDefaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefaults.swift; sourceTree = ""; }; + 070480372A58A507009006CE /* WidgetIntentExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = WidgetIntentExtension.entitlements; sourceTree = ""; }; + 0712B3629C74D1F958DF35FB /* Pods-Lux-LuxTests.dev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Lux-LuxTests.dev.xcconfig"; path = "Target Support Files/Pods-Lux-LuxTests/Pods-Lux-LuxTests.dev.xcconfig"; sourceTree = ""; }; + 072E23862A44D5BC006AD6C9 /* WidgetsCore.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = WidgetsCore.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 072E23882A44D5BD006AD6C9 /* WidgetsCore.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = WidgetsCore.h; sourceTree = ""; }; + 072E238D2A44D5BD006AD6C9 /* WidgetsCoreTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = WidgetsCoreTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 072E23942A44D5BD006AD6C9 /* WidgetsCoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetsCoreTests.swift; sourceTree = ""; }; + 072E23A62A44D61A006AD6C9 /* Widgets.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Widgets.entitlements; sourceTree = ""; }; + 072F6C1F2A44A32E00DA720A /* Widgets.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = Widgets.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + 072F6C202A44A32E00DA720A /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; + 072F6C222A44A32E00DA720A /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; }; + 072F6C252A44A32E00DA720A /* WidgetsBundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetsBundle.swift; sourceTree = ""; }; + 072F6C272A44A32E00DA720A /* TokenPriceWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenPriceWidget.swift; sourceTree = ""; }; + 072F6C292A44A32E00DA720A /* TokenPriceWidget.intentdefinition */ = {isa = PBXFileReference; lastKnownFileType = file.intentdefinition; path = TokenPriceWidget.intentdefinition; sourceTree = ""; }; + 072F6C2A2A44A32F00DA720A /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 072F6C2C2A44A32F00DA720A /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 072F6C372A44BECC00DA720A /* Logging.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logging.swift; sourceTree = ""; }; + 074086F92A703B76006E3053 /* FormatTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormatTests.swift; sourceTree = ""; }; + 0741433F2A588F5800A157D3 /* Structs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Structs.swift; sourceTree = ""; }; + 074321DE2A83E3C900F8518D /* SchemaConfiguration.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SchemaConfiguration.swift; sourceTree = ""; }; + 0743223F2A841BBD00F8518D /* Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Constants.swift; sourceTree = ""; }; + 0767E0372A65C8330042ADA2 /* Colors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Colors.swift; sourceTree = ""; }; + 0767E03A2A65D2550042ADA2 /* Styling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Styling.swift; sourceTree = ""; }; + 0783F7B32A619E7C009ED617 /* UIComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIComponents.swift; sourceTree = ""; }; + 078E79452A55EB3300F59CF2 /* WidgetIntentExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = WidgetIntentExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + 078E79462A55EB3300F59CF2 /* Intents.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Intents.framework; path = System/Library/Frameworks/Intents.framework; sourceTree = SDKROOT; }; + 078E79492A55EB3300F59CF2 /* IntentHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntentHandler.swift; sourceTree = ""; }; + 078E794B2A55EB3300F59CF2 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 07B0676A2A7D6EC8001DD9B9 /* RNWidgets.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RNWidgets.swift; sourceTree = ""; }; + 07B0676B2A7D6EC8001DD9B9 /* RNWidgets.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RNWidgets.m; sourceTree = ""; }; + 07F0C28E2A5F3E2E00D5353E /* Env.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Env.swift; sourceTree = ""; }; + 07F1363F2A575EC00067004F /* DataQueries.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataQueries.swift; sourceTree = ""; }; + 07F136412A5763480067004F /* Network.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Network.swift; sourceTree = ""; }; + 07F5CF702A6AD97D00C648A5 /* Chart.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Chart.swift; sourceTree = ""; }; + 07F5CF742A7020FD00C648A5 /* Format.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Format.swift; sourceTree = ""; }; + 08C60D53AB82A6D0D31D0F78 /* Pods-WidgetIntentExtension.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WidgetIntentExtension.release.xcconfig"; path = "Target Support Files/Pods-WidgetIntentExtension/Pods-WidgetIntentExtension.release.xcconfig"; sourceTree = ""; }; + 08EBF075A4482F701892270B /* Pods-Widgets.dev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Widgets.dev.xcconfig"; path = "Target Support Files/Pods-Widgets/Pods-Widgets.dev.xcconfig"; sourceTree = ""; }; + 0929C0B4AE1570B8C0B45D4D /* Pods_Lux_LuxTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Lux_LuxTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 0A266B733FD2FA9C1C1A7731 /* TokenParts.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = TokenParts.graphql.swift; path = WidgetsCore/MobileSchema/Fragments/TokenParts.graphql.swift; sourceTree = ""; }; + 0A7D41CB7AA0496123E2153D /* SwapOrderType.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SwapOrderType.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Enums/SwapOrderType.graphql.swift; sourceTree = ""; }; + 0B7E5D62E11408EB5F0F5A80 /* Pods-WidgetsCore.beta.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WidgetsCore.beta.xcconfig"; path = "Target Support Files/Pods-WidgetsCore/Pods-WidgetsCore.beta.xcconfig"; sourceTree = ""; }; + 0C19DE44A750FB17647FF2B6 /* Pods-Widgets.beta.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Widgets.beta.xcconfig"; path = "Target Support Files/Pods-Widgets/Pods-Widgets.beta.xcconfig"; sourceTree = ""; }; + 0D25B69F4A69D733A5DAEC42 /* SchemaConfiguration.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SchemaConfiguration.swift; path = WidgetsCore/MobileSchema/Schema/SchemaConfiguration.swift; sourceTree = ""; }; + 0DB282242CDADB260014CF77 /* EmbeddedWallet.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = EmbeddedWallet.m; sourceTree = ""; }; + 0DB282252CDADB260014CF77 /* EmbeddedWallet.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EmbeddedWallet.swift; sourceTree = ""; }; + 0E5478334318BD5D6F8366D6 /* OnRampServiceProvider.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = OnRampServiceProvider.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Objects/OnRampServiceProvider.graphql.swift; sourceTree = ""; }; + 0EA8FE9029A1E7C4EAD7F547 /* NftsTabQuery.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = NftsTabQuery.graphql.swift; path = WidgetsCore/MobileSchema/Operations/Queries/NftsTabQuery.graphql.swift; sourceTree = ""; }; + 1064E23E366D0C2C2B20C30E /* Pods_WidgetIntentExtension.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_WidgetIntentExtension.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 1193B3A845BC3BE8CAA00D01 /* Pods-OneSignalNotificationServiceExtension.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-OneSignalNotificationServiceExtension.release.xcconfig"; path = "Target Support Files/Pods-OneSignalNotificationServiceExtension/Pods-OneSignalNotificationServiceExtension.release.xcconfig"; sourceTree = ""; }; + 132FC1B05AE3D510C3B71EBA /* SwapOrderStatus.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SwapOrderStatus.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Enums/SwapOrderStatus.graphql.swift; sourceTree = ""; }; + 13B07F961A680F5B00A75B9A /* Lux.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Lux.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = Lux/Images.xcassets; sourceTree = ""; }; + 13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = Lux/Info.plist; sourceTree = ""; }; + 15092E550A1C78508ABA3280 /* Pods_OneSignalNotificationServiceExtension.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_OneSignalNotificationServiceExtension.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 16F706375DFAFB58F8440CF9 /* TokenQuery.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = TokenQuery.graphql.swift; path = WidgetsCore/MobileSchema/Operations/Queries/TokenQuery.graphql.swift; sourceTree = ""; }; + 178644A78AB62609EFDB66B3 /* Pods-Lux.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Lux.release.xcconfig"; path = "Target Support Files/Pods-Lux/Pods-Lux.release.xcconfig"; sourceTree = ""; }; + 1834199AFFB04D91B05FFB64 /* InputMono-Regular.ttf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; name = "InputMono-Regular.ttf"; path = "../src/assets/fonts/InputMono-Regular.ttf"; sourceTree = ""; }; + 1CC6ADAADCA38FDAEB181E86 /* Pods-WidgetIntentExtension.dev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WidgetIntentExtension.dev.xcconfig"; path = "Target Support Files/Pods-WidgetIntentExtension/Pods-WidgetIntentExtension.dev.xcconfig"; sourceTree = ""; }; + 1D88F29D12AB0D8B4C00D065 /* IContract.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = IContract.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Interfaces/IContract.graphql.swift; sourceTree = ""; }; + 201FF7E30EC98ABC121BC8BF /* BlockaidFees.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = BlockaidFees.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Objects/BlockaidFees.graphql.swift; sourceTree = ""; }; + 2226DF79BEAFECEE11A51347 /* Pods_Lux.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Lux.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 2240891823DAE7BF8BB88FF4 /* HomeScreenTokensQuery.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = HomeScreenTokensQuery.graphql.swift; path = WidgetsCore/MobileSchema/Operations/Queries/HomeScreenTokensQuery.graphql.swift; sourceTree = ""; }; + 25DDE9DCAFA0EE2838508994 /* Chain.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = Chain.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Enums/Chain.graphql.swift; sourceTree = ""; }; + 284C5150E9B50FB5BE531572 /* Image.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = Image.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Objects/Image.graphql.swift; sourceTree = ""; }; + 290B66F38FEC486AF1709BE4 /* FeeData.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = FeeData.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Objects/FeeData.graphql.swift; sourceTree = ""; }; + 2C9E8E39C13967E447FC193F /* TimestampedAmount.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = TimestampedAmount.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Objects/TimestampedAmount.graphql.swift; sourceTree = ""; }; + 2E8B7D36D2E14D9488F351EB /* Pods_Widgets.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Widgets.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 2ECFE86020AB7A252CDC9BB1 /* NftCollection.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = NftCollection.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Objects/NftCollection.graphql.swift; sourceTree = ""; }; + 2FB70424D7C456B9B72536C6 /* NetworkFee.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = NetworkFee.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Objects/NetworkFee.graphql.swift; sourceTree = ""; }; + 3063061F7C203A1DA47B8FBB /* OffRampTransfer.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = OffRampTransfer.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Objects/OffRampTransfer.graphql.swift; sourceTree = ""; }; + 319AAC5377C60EB6579CC5C6 /* TokenBasicInfoParts.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = TokenBasicInfoParts.graphql.swift; path = WidgetsCore/MobileSchema/Fragments/TokenBasicInfoParts.graphql.swift; sourceTree = ""; }; + 32764E27B5D9FCF0AA4217CF /* SwapOrderDetails.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SwapOrderDetails.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Objects/SwapOrderDetails.graphql.swift; sourceTree = ""; }; + 346B304DE3207797FD1FDE71 /* ProtectionInfo.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ProtectionInfo.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Objects/ProtectionInfo.graphql.swift; sourceTree = ""; }; + 37A6D5D76D31F8C5CDD29814 /* TransactionType.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = TransactionType.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Enums/TransactionType.graphql.swift; sourceTree = ""; }; + 38EAF64134981DC76B6C7E90 /* OnRampTransactionsAuth.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = OnRampTransactionsAuth.graphql.swift; path = WidgetsCore/MobileSchema/Schema/InputObjects/OnRampTransactionsAuth.graphql.swift; sourceTree = ""; }; + 3A2186B1FF7FB85663D96EA9 /* Pods-OneSignalNotificationServiceExtension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-OneSignalNotificationServiceExtension.debug.xcconfig"; path = "Target Support Files/Pods-OneSignalNotificationServiceExtension/Pods-OneSignalNotificationServiceExtension.debug.xcconfig"; sourceTree = ""; }; + 3AE6A0CAEDE1DC1D2FA14DEA /* TransactionHistoryUpdaterQuery.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = TransactionHistoryUpdaterQuery.graphql.swift; path = WidgetsCore/MobileSchema/Operations/Queries/TransactionHistoryUpdaterQuery.graphql.swift; sourceTree = ""; }; + 3BA467FB84503BB53B081C73 /* TokenProjectMarketsParts.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = TokenProjectMarketsParts.graphql.swift; path = WidgetsCore/MobileSchema/Fragments/TokenProjectMarketsParts.graphql.swift; sourceTree = ""; }; + 3C606D2C81014A0A8898F38E /* Basel-Grotesk-Medium.otf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; name = "Basel-Grotesk-Medium.otf"; path = "../src/assets/fonts/Basel-Grotesk-Medium.otf"; sourceTree = ""; }; + 3D3112654E5722A2F862D3FC /* ConvertQuery.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ConvertQuery.graphql.swift; path = WidgetsCore/MobileSchema/Operations/Queries/ConvertQuery.graphql.swift; sourceTree = ""; }; + 3D8FCE4CD401350CA74DCC89 /* Pods-WidgetsCoreTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WidgetsCoreTests.debug.xcconfig"; path = "Target Support Files/Pods-WidgetsCoreTests/Pods-WidgetsCoreTests.debug.xcconfig"; sourceTree = ""; }; + 3D96819BA563CB218D29C657 /* TokenSortableField.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = TokenSortableField.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Enums/TokenSortableField.graphql.swift; sourceTree = ""; }; + 3E279F675B02CBC50D3B57D5 /* Pods-WidgetsCore.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WidgetsCore.release.xcconfig"; path = "Target Support Files/Pods-WidgetsCore/Pods-WidgetsCore.release.xcconfig"; sourceTree = ""; }; + 413CEB2443D6DD1DA7CC7231 /* TokenFeeDataParts.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = TokenFeeDataParts.graphql.swift; path = WidgetsCore/MobileSchema/Fragments/TokenFeeDataParts.graphql.swift; sourceTree = ""; }; + 41FFD02A227ACFB661A44813 /* SafetyLevel.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SafetyLevel.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Enums/SafetyLevel.graphql.swift; sourceTree = ""; }; + 42013D777BAA40652AB28985 /* NftsQuery.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = NftsQuery.graphql.swift; path = WidgetsCore/MobileSchema/Operations/Queries/NftsQuery.graphql.swift; sourceTree = ""; }; + 42158AF500DDBEEF6B7E98E3 /* NftApproval.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = NftApproval.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Objects/NftApproval.graphql.swift; sourceTree = ""; }; + 43AB42F3ED903CF49D7D4DE7 /* AmountChange.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AmountChange.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Objects/AmountChange.graphql.swift; sourceTree = ""; }; + 43D7F54953D864DC067FADD1 /* TopTokenParts.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = TopTokenParts.graphql.swift; path = WidgetsCore/MobileSchema/Fragments/TopTokenParts.graphql.swift; sourceTree = ""; }; + 44D824154584CD39C82904E9 /* BridgedWithdrawalInfo.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = BridgedWithdrawalInfo.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Objects/BridgedWithdrawalInfo.graphql.swift; sourceTree = ""; }; + 45FFF7DE2E8C2A6400362570 /* SilentPushEventEmitter.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SilentPushEventEmitter.m; sourceTree = ""; }; + 45FFF7E02E8C2E6100362570 /* SilentPushEventEmitter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SilentPushEventEmitter.swift; sourceTree = ""; }; + 47148657FBA8DCC92DD69573 /* TransactionDetails.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = TransactionDetails.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Objects/TransactionDetails.graphql.swift; sourceTree = ""; }; + 4781CD4CDD95B5792B793F75 /* Pods-Lux-LuxTests.beta.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Lux-LuxTests.beta.xcconfig"; path = "Target Support Files/Pods-Lux-LuxTests/Pods-Lux-LuxTests.beta.xcconfig"; sourceTree = ""; }; + 48DE72B77CD2D89B75BB934D /* FavoriteTokenCardQuery.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = FavoriteTokenCardQuery.graphql.swift; path = WidgetsCore/MobileSchema/Operations/Queries/FavoriteTokenCardQuery.graphql.swift; sourceTree = ""; }; + 4AF8EE05291534B82ACC1105 /* TokenBalance.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = TokenBalance.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Objects/TokenBalance.graphql.swift; sourceTree = ""; }; + 4C445DB9798210862C34D0E0 /* Pods-WidgetsCoreTests.beta.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WidgetsCoreTests.beta.xcconfig"; path = "Target Support Files/Pods-WidgetsCoreTests/Pods-WidgetsCoreTests.beta.xcconfig"; sourceTree = ""; }; + 4DF5F26A06553EFDD4D99214 /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-Lux/ExpoModulesProvider.swift"; sourceTree = ""; }; + 4EC9CABC0B4E73B25C7A2128 /* TransactionDirection.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = TransactionDirection.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Enums/TransactionDirection.graphql.swift; sourceTree = ""; }; + 5442480B3C91EDC515728606 /* TokensQuery.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = TokensQuery.graphql.swift; path = WidgetsCore/MobileSchema/Operations/Queries/TokensQuery.graphql.swift; sourceTree = ""; }; + 56093E216B82288E1A4018F0 /* NftBalanceConnection.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = NftBalanceConnection.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Objects/NftBalanceConnection.graphql.swift; sourceTree = ""; }; + 56FE9C9AF785221B7E3F4C04 /* Pods-Lux.dev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Lux.dev.xcconfig"; path = "Target Support Files/Pods-Lux/Pods-Lux.dev.xcconfig"; sourceTree = ""; }; + 58641E30674C4C4241702902 /* Query.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = Query.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Objects/Query.graphql.swift; sourceTree = ""; }; + 5B4398E82DD3B22C00F6BE08 /* PrivateKeyDisplayManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = PrivateKeyDisplayManager.m; sourceTree = ""; }; + 5B4398E92DD3B22C00F6BE08 /* PrivateKeyDisplayManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivateKeyDisplayManager.swift; sourceTree = ""; }; + 5B4398EA2DD3B22C00F6BE08 /* PrivateKeyDisplayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivateKeyDisplayView.swift; sourceTree = ""; }; + 5B4CEC5E2DD65DD4009F082B /* CopyIconOutline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CopyIconOutline.swift; sourceTree = ""; }; + 5E5E0A622D380F5700E166AA /* Env.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Env.swift; sourceTree = ""; }; + 5F2898E709490CC198B98228 /* PageInfo.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PageInfo.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Objects/PageInfo.graphql.swift; sourceTree = ""; }; + 60195493B9C71EDEECA46E80 /* OnRampTransfer.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = OnRampTransfer.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Objects/OnRampTransfer.graphql.swift; sourceTree = ""; }; + 614A491FAB9659FA08CD9AE4 /* OnRampTransactionDetails.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = OnRampTransactionDetails.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Objects/OnRampTransactionDetails.graphql.swift; sourceTree = ""; }; + 62CEA9F2D5176D20A6402A3E /* Pods-Lux.beta.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Lux.beta.xcconfig"; path = "Target Support Files/Pods-Lux/Pods-Lux.beta.xcconfig"; sourceTree = ""; }; + 649A7A762D9AE70B00B53589 /* KeychainConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainConstants.swift; sourceTree = ""; }; + 649A7A772D9AE70B00B53589 /* KeychainUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainUtils.swift; sourceTree = ""; }; + 66697C913CF365C9460F42E6 /* NftCollectionMarket.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = NftCollectionMarket.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Objects/NftCollectionMarket.graphql.swift; sourceTree = ""; }; + 6AFC48B9A573915BFA6FC6E2 /* OffRampTransactionDetails.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = OffRampTransactionDetails.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Objects/OffRampTransactionDetails.graphql.swift; sourceTree = ""; }; + 6BC7D07B2B5FF02400617C95 /* ScantasticEncryption.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ScantasticEncryption.m; sourceTree = ""; }; + 6BC7D07C2B5FF02400617C95 /* ScantasticEncryption.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ScantasticEncryption.swift; sourceTree = ""; }; + 6BC7D07D2B5FF02400617C95 /* EncryptionUtils.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EncryptionUtils.swift; sourceTree = ""; }; + 6C8EFC2C2891B99100FBD8EB /* EncryptionHelperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncryptionHelperTests.swift; sourceTree = ""; }; + 6CA91BD82A95223C00C4063E /* RNEthersRS-Bridging-Header.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "RNEthersRS-Bridging-Header.h"; sourceTree = ""; }; + 6CA91BD92A95223C00C4063E /* RNEthersRS.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RNEthersRS.swift; sourceTree = ""; }; + 6CA91BDA2A95223C00C4063E /* RnEthersRS.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RnEthersRS.m; sourceTree = ""; }; + 6CA91BDE2A95226200C4063E /* RNCloudStorageBackupsManager.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RNCloudStorageBackupsManager.m; sourceTree = ""; }; + 6CA91BDF2A95226200C4063E /* EncryptionHelper.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EncryptionHelper.swift; sourceTree = ""; }; + 6CA91BE02A95226200C4063E /* RNCloudStorageBackupsManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RNCloudStorageBackupsManager.swift; sourceTree = ""; }; + 6DCC7E203AB163C9C2D3D789 /* Dimensions.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = Dimensions.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Objects/Dimensions.graphql.swift; sourceTree = ""; }; + 6F02DBC2C51EFC6B1AA9C1CD /* DescriptionTranslations.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = DescriptionTranslations.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Objects/DescriptionTranslations.graphql.swift; sourceTree = ""; }; + 6F33E8069B7B40AFB313B8B0 /* Basel-Grotesk-Book.otf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; name = "Basel-Grotesk-Book.otf"; path = "../src/assets/fonts/Basel-Grotesk-Book.otf"; sourceTree = ""; }; + 6F3DC921A65D749C0852B10C /* Pods-Lux-LuxTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Lux-LuxTests.debug.xcconfig"; path = "Target Support Files/Pods-Lux-LuxTests/Pods-Lux-LuxTests.debug.xcconfig"; sourceTree = ""; }; + 6F7814C6D40D9C348EA1F1C7 /* Pods-OneSignalNotificationServiceExtension.dev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-OneSignalNotificationServiceExtension.dev.xcconfig"; path = "Target Support Files/Pods-OneSignalNotificationServiceExtension/Pods-OneSignalNotificationServiceExtension.dev.xcconfig"; sourceTree = ""; }; + 73D981A2D31B6F8822C19324 /* SchemaMetadata.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SchemaMetadata.graphql.swift; path = WidgetsCore/MobileSchema/Schema/SchemaMetadata.graphql.swift; sourceTree = ""; }; + 74673620CE906C9AF34C6750 /* Amount.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = Amount.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Objects/Amount.graphql.swift; sourceTree = ""; }; + 792EF7A7820987EA9E99D792 /* NftTransfer.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = NftTransfer.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Objects/NftTransfer.graphql.swift; sourceTree = ""; }; + 7A7637BBC9B3A68E0338D96E /* Pods-WidgetIntentExtension.beta.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WidgetIntentExtension.beta.xcconfig"; path = "Target Support Files/Pods-WidgetIntentExtension/Pods-WidgetIntentExtension.beta.xcconfig"; sourceTree = ""; }; + 7F0C115796D33739746778E3 /* TokenBalanceMainParts.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = TokenBalanceMainParts.graphql.swift; path = WidgetsCore/MobileSchema/Fragments/TokenBalanceMainParts.graphql.swift; sourceTree = ""; }; + 82C9871585F60F92D079FB95 /* Pods-Widgets.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Widgets.release.xcconfig"; path = "Target Support Files/Pods-Widgets/Pods-Widgets.release.xcconfig"; sourceTree = ""; }; + 8604CAA8F7E44D704A6F4AAF /* PortfolioBalancesQuery.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PortfolioBalancesQuery.graphql.swift; path = WidgetsCore/MobileSchema/Operations/Queries/PortfolioBalancesQuery.graphql.swift; sourceTree = ""; }; + 8719E5872CC41AB64503E903 /* Pods-WidgetIntentExtension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WidgetIntentExtension.debug.xcconfig"; path = "Target Support Files/Pods-WidgetIntentExtension/Pods-WidgetIntentExtension.debug.xcconfig"; sourceTree = ""; }; + 88B481513A2E780E6489DC4F /* Token.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = Token.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Objects/Token.graphql.swift; sourceTree = ""; }; + 8B2A92162EB3E78E00990413 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = Lux/AppDelegate.swift; sourceTree = ""; }; + 8B2A92182EB3E79500990413 /* Lux-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = "Lux-Bridging-Header.h"; path = "Lux/Lux-Bridging-Header.h"; sourceTree = ""; }; + 8BB8B6395E3C86412FA793CB /* AssetActivity.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AssetActivity.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Objects/AssetActivity.graphql.swift; sourceTree = ""; }; + 8C66DD1D03A824EB6A231DCE /* TokenProjectDescriptionQuery.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = TokenProjectDescriptionQuery.graphql.swift; path = WidgetsCore/MobileSchema/Operations/Queries/TokenProjectDescriptionQuery.graphql.swift; sourceTree = ""; }; + 8D5FF4316461BD4FB5847D62 /* TokenBalanceQuantityParts.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = TokenBalanceQuantityParts.graphql.swift; path = WidgetsCore/MobileSchema/Fragments/TokenBalanceQuantityParts.graphql.swift; sourceTree = ""; }; + 8D973A66C23859D34E6FE175 /* NftStandard.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = NftStandard.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Enums/NftStandard.graphql.swift; sourceTree = ""; }; + 8E89C3A62AB8AAA400C84DE5 /* MnemonicConfirmationView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MnemonicConfirmationView.swift; sourceTree = ""; }; + 8E89C3A72AB8AAA400C84DE5 /* MnemonicDisplayView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MnemonicDisplayView.swift; sourceTree = ""; }; + 8E89C3A92AB8AAA400C84DE5 /* MnemonicConfirmationWordBankView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MnemonicConfirmationWordBankView.swift; sourceTree = ""; }; + 8E89C3AA2AB8AAA400C84DE5 /* MnemonicTextField.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MnemonicTextField.swift; sourceTree = ""; }; + 8E89C3AB2AB8AAA400C84DE5 /* MnemonicDisplayManager.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MnemonicDisplayManager.m; sourceTree = ""; }; + 8E89C3AC2AB8AAA400C84DE5 /* MnemonicConfirmationManager.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MnemonicConfirmationManager.m; sourceTree = ""; }; + 8E89C3AD2AB8AAA400C84DE5 /* RNSwiftUI-Bridging-Header.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "RNSwiftUI-Bridging-Header.h"; sourceTree = ""; }; + 8EA8AB302AB7ED3C004E7EF3 /* SeedPhraseInputManager.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = SeedPhraseInputManager.m; sourceTree = ""; }; + 8EA8AB312AB7ED3C004E7EF3 /* SeedPhraseInputViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SeedPhraseInputViewModel.swift; sourceTree = ""; }; + 8EA8AB322AB7ED3C004E7EF3 /* SeedPhraseInputView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SeedPhraseInputView.swift; sourceTree = ""; }; + 8EA8AB332AB7ED3C004E7EF3 /* SeedPhraseInputManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SeedPhraseInputManager.swift; sourceTree = ""; }; + 8EA8AB402AB7ED76004E7EF3 /* AlertTriangleIcon.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AlertTriangleIcon.swift; sourceTree = ""; }; + 8EBFB1542ABA6AA6006B32A8 /* PasteIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasteIcon.swift; sourceTree = ""; }; + 8ED0562B2AA78E2C009BD5A2 /* ScrollFadeExtensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ScrollFadeExtensions.swift; sourceTree = ""; }; + 93783F96FA74CB9A1BEB32EE /* TokenTransfer.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = TokenTransfer.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Objects/TokenTransfer.graphql.swift; sourceTree = ""; }; + 93D1BA7F635F61FC7E62C6C1 /* TokenProtectionInfoParts.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = TokenProtectionInfoParts.graphql.swift; path = WidgetsCore/MobileSchema/Fragments/TokenProtectionInfoParts.graphql.swift; sourceTree = ""; }; + 957594D21A42B9D77F5AD685 /* ContractInput.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ContractInput.graphql.swift; path = WidgetsCore/MobileSchema/Schema/InputObjects/ContractInput.graphql.swift; sourceTree = ""; }; + 9A2AA83B4F9F4290A2BC7CCB /* NftContract.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = NftContract.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Objects/NftContract.graphql.swift; sourceTree = ""; }; + 9BF540E1110D0FD1C219C45C /* ApplicationContract.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ApplicationContract.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Objects/ApplicationContract.graphql.swift; sourceTree = ""; }; + 9F1055EFA8467996754739A7 /* TokenProjectUrlsParts.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = TokenProjectUrlsParts.graphql.swift; path = WidgetsCore/MobileSchema/Fragments/TokenProjectUrlsParts.graphql.swift; sourceTree = ""; }; + 9F3500812A8AA5890077BFC5 /* EXSplashScreen.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = EXSplashScreen.xcframework; path = "../../../node_modules/expo-splash-screen/ios/EXSplashScreen.xcframework"; sourceTree = ""; }; + 9FCEBEFD2A95A8E00079EDDB /* RNWalletConnect.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = RNWalletConnect.h; path = Lux/WalletConnect/RNWalletConnect.h; sourceTree = ""; }; + 9FCEBEFE2A95A8E00079EDDB /* RNWalletConnect.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = RNWalletConnect.m; path = Lux/WalletConnect/RNWalletConnect.m; sourceTree = ""; }; + 9FCEBEFF2A95A8E00079EDDB /* RNWalletConnect.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = RNWalletConnect.swift; path = Lux/WalletConnect/RNWalletConnect.swift; sourceTree = ""; }; + 9FCEBF022A95A99B0079EDDB /* RCTThemeModule.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = RCTThemeModule.h; path = Appearance/RCTThemeModule.h; sourceTree = ""; }; + 9FCEBF032A95A99B0079EDDB /* RCTThemeModule.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = RCTThemeModule.m; path = Appearance/RCTThemeModule.m; sourceTree = ""; }; + A02D64B698D203A7F3643C18 /* TokenMarket.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = TokenMarket.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Objects/TokenMarket.graphql.swift; sourceTree = ""; }; + A182D5FF8C0919083F2E333C /* TransactionStatus.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = TransactionStatus.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Enums/TransactionStatus.graphql.swift; sourceTree = ""; }; + A24882D069B34B7DC9E8B4CE /* MobileSchema.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = MobileSchema.graphql.swift; path = WidgetsCore/MobileSchema/MobileSchema.graphql.swift; sourceTree = ""; }; + A32F9FBC272343C8002CFCDB /* GoogleService-Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; + A34E3A6E13A4066767AA6757 /* Portfolio.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = Portfolio.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Objects/Portfolio.graphql.swift; sourceTree = ""; }; + A372929BEDB706B6144334F0 /* IAmount.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = IAmount.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Interfaces/IAmount.graphql.swift; sourceTree = ""; }; + A3E638BE80543E905D072D7F /* TokenProjectMarket.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = TokenProjectMarket.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Objects/TokenProjectMarket.graphql.swift; sourceTree = ""; }; + A3F0A5B0272B1DFA00895B25 /* KeychainSwiftDistrib.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainSwiftDistrib.swift; sourceTree = ""; }; + A424FA93DB720DD7684C7674 /* TokenProject.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = TokenProject.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Objects/TokenProject.graphql.swift; sourceTree = ""; }; + A7C9F415D0E128A43003E071 /* Pods-Lux.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Lux.debug.xcconfig"; path = "Target Support Files/Pods-Lux/Pods-Lux.debug.xcconfig"; sourceTree = ""; }; + AB3EEA677984132330D8D279 /* HomeScreenTokenParts.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = HomeScreenTokenParts.graphql.swift; path = WidgetsCore/MobileSchema/Fragments/HomeScreenTokenParts.graphql.swift; sourceTree = ""; }; + AC0EE0972BD826E700BCCF07 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = Lux/PrivacyInfo.xcprivacy; sourceTree = ""; }; + AC2794442C51541E00F9AF68 /* sourcemaps-datadog.sh */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = "sourcemaps-datadog.sh"; sourceTree = ""; }; + AC2EF4022C914B1600EEEFDB /* fonts */ = {isa = PBXFileReference; lastKnownFileType = folder; name = fonts; path = ../src/assets/fonts; sourceTree = ""; }; + B0DA4D39B1A6D74A1D05B99F /* Pods-WidgetsCore.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WidgetsCore.debug.xcconfig"; path = "Target Support Files/Pods-WidgetsCore/Pods-WidgetsCore.debug.xcconfig"; sourceTree = ""; }; + B68E391195D4587F61A33380 /* ActivityDetails.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ActivityDetails.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Unions/ActivityDetails.graphql.swift; sourceTree = ""; }; + B97D41BC2760803FD1C7CB3C /* TokenBalanceParts.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = TokenBalanceParts.graphql.swift; path = WidgetsCore/MobileSchema/Fragments/TokenBalanceParts.graphql.swift; sourceTree = ""; }; + BB3CBF221D5D2E3C036A1BA0 /* SelectWalletScreenQuery.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SelectWalletScreenQuery.graphql.swift; path = WidgetsCore/MobileSchema/Operations/Queries/SelectWalletScreenQuery.graphql.swift; sourceTree = ""; }; + BB9FB14C8B68CE1D05643256 /* TokenBasicProjectParts.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = TokenBasicProjectParts.graphql.swift; path = WidgetsCore/MobileSchema/Fragments/TokenBasicProjectParts.graphql.swift; sourceTree = ""; }; + BCB2A43E5FB0D7B69CA02312 /* Pods-WidgetsCore.dev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WidgetsCore.dev.xcconfig"; path = "Target Support Files/Pods-WidgetsCore/Pods-WidgetsCore.dev.xcconfig"; sourceTree = ""; }; + BE4AB3FCC91BE6C73557E0E3 /* ProtectionAttackType.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ProtectionAttackType.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Enums/ProtectionAttackType.graphql.swift; sourceTree = ""; }; + BF9176E944C84910B1C0B057 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = Lux/SplashScreen.storyboard; sourceTree = ""; }; + C050FFEC067334CF05C17EA0 /* TopTokensQuery.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = TopTokensQuery.graphql.swift; path = WidgetsCore/MobileSchema/Operations/Queries/TopTokensQuery.graphql.swift; sourceTree = ""; }; + C0C956C520AAB54BCE8C5CCC /* WidgetTokensQuery.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WidgetTokensQuery.graphql.swift; path = WidgetsCore/MobileSchema/Operations/Queries/WidgetTokensQuery.graphql.swift; sourceTree = ""; }; + C26D739993D5C939C6FBB58A /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-Lux-LuxTests/ExpoModulesProvider.swift"; sourceTree = ""; }; + C27225607FACF638581E25B5 /* TransactionListQuery.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = TransactionListQuery.graphql.swift; path = WidgetsCore/MobileSchema/Operations/Queries/TransactionListQuery.graphql.swift; sourceTree = ""; }; + C394AC5DB58BDF1C33CC0927 /* NftBalanceAssetInput.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = NftBalanceAssetInput.graphql.swift; path = WidgetsCore/MobileSchema/Schema/InputObjects/NftBalanceAssetInput.graphql.swift; sourceTree = ""; }; + C6754D4BD9CE76AAC915F814 /* TokenMarketParts.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = TokenMarketParts.graphql.swift; path = WidgetsCore/MobileSchema/Fragments/TokenMarketParts.graphql.swift; sourceTree = ""; }; + C89238E3ED9F3AC98876B573 /* Pods-WidgetsCoreTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WidgetsCoreTests.release.xcconfig"; path = "Target Support Files/Pods-WidgetsCoreTests/Pods-WidgetsCoreTests.release.xcconfig"; sourceTree = ""; }; + C8BAFEAABF78617095D44E32 /* NftBalance.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = NftBalance.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Objects/NftBalance.graphql.swift; sourceTree = ""; }; + CA586C522ABE5DA5363D27C7 /* ProtectionResult.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ProtectionResult.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Enums/ProtectionResult.graphql.swift; sourceTree = ""; }; + CB29AC0C0907A833F23D2C30 /* Pods_WidgetsCore.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_WidgetsCore.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + CD3573B4E6FD61F5DDBD9C75 /* NftAsset.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = NftAsset.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Objects/NftAsset.graphql.swift; sourceTree = ""; }; + CE5E0F71E23968DEFCEB7A4B /* TokenStandard.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = TokenStandard.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Enums/TokenStandard.graphql.swift; sourceTree = ""; }; + D79B717BEAEA7857469D770A /* Pods-Widgets.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Widgets.debug.xcconfig"; path = "Target Support Files/Pods-Widgets/Pods-Widgets.debug.xcconfig"; sourceTree = ""; }; + DC6517FF8972DA448CDD2DFC /* HistoryDuration.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = HistoryDuration.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Enums/HistoryDuration.graphql.swift; sourceTree = ""; }; + E13150074BB66C20E7EA271A /* PortfolioValueModifier.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PortfolioValueModifier.graphql.swift; path = WidgetsCore/MobileSchema/Schema/InputObjects/PortfolioValueModifier.graphql.swift; sourceTree = ""; }; + E1B3A0C152B683215291E3BD /* TokenProjectsQuery.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = TokenProjectsQuery.graphql.swift; path = WidgetsCore/MobileSchema/Operations/Queries/TokenProjectsQuery.graphql.swift; sourceTree = ""; }; + E21B5EF6BC4582DD2BB0DA13 /* FeedTransactionListQuery.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = FeedTransactionListQuery.graphql.swift; path = WidgetsCore/MobileSchema/Operations/Queries/FeedTransactionListQuery.graphql.swift; sourceTree = ""; }; + E21EB4FB53B7B6B5848946D9 /* NftProfile.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = NftProfile.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Objects/NftProfile.graphql.swift; sourceTree = ""; }; + E3AF9944E853669C2BC9B5AB /* MultiplePortfolioBalancesQuery.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = MultiplePortfolioBalancesQuery.graphql.swift; path = WidgetsCore/MobileSchema/Operations/Queries/MultiplePortfolioBalancesQuery.graphql.swift; sourceTree = ""; }; + E5DDE9BCC6A539F6E9AE1664 /* Currency.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = Currency.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Enums/Currency.graphql.swift; sourceTree = ""; }; + E7ED83FFCB410FCCA2C6B97B /* TokenDetailsScreenQuery.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = TokenDetailsScreenQuery.graphql.swift; path = WidgetsCore/MobileSchema/Operations/Queries/TokenDetailsScreenQuery.graphql.swift; sourceTree = ""; }; + ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; }; + EDA02DA46906E4CDF827104A /* TokenPriceHistoryQuery.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = TokenPriceHistoryQuery.graphql.swift; path = WidgetsCore/MobileSchema/Operations/Queries/TokenPriceHistoryQuery.graphql.swift; sourceTree = ""; }; + F182567B705FC0E15DA8F34A /* NftBalanceEdge.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = NftBalanceEdge.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Objects/NftBalanceEdge.graphql.swift; sourceTree = ""; }; + F1A3F4DDD7E40DA9E4BBAAD1 /* Pods-Lux-LuxTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Lux-LuxTests.release.xcconfig"; path = "Target Support Files/Pods-Lux-LuxTests/Pods-Lux-LuxTests.release.xcconfig"; sourceTree = ""; }; + F35AFD3627EE49230011A725 /* Lux.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; name = Lux.entitlements; path = Lux/Lux.entitlements; sourceTree = ""; }; + F35AFD3B27EE49990011A725 /* OneSignalNotificationServiceExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = OneSignalNotificationServiceExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + F35AFD3D27EE49990011A725 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = ""; }; + F35AFD3F27EE49990011A725 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + F35AFD4727EE4B400011A725 /* OneSignalNotificationServiceExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = OneSignalNotificationServiceExtension.entitlements; sourceTree = ""; }; + F38DFE921F0467FF51A3D717 /* AssetChange.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AssetChange.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Unions/AssetChange.graphql.swift; sourceTree = ""; }; + F56CC08FBB20FAC0DF6B93DA /* Pods-OneSignalNotificationServiceExtension.beta.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-OneSignalNotificationServiceExtension.beta.xcconfig"; path = "Target Support Files/Pods-OneSignalNotificationServiceExtension/Pods-OneSignalNotificationServiceExtension.beta.xcconfig"; sourceTree = ""; }; + F70F4D05A45BC18EA88868A0 /* NftBalancesFilterInput.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = NftBalancesFilterInput.graphql.swift; path = WidgetsCore/MobileSchema/Schema/InputObjects/NftBalancesFilterInput.graphql.swift; sourceTree = ""; }; + F99987D29B574C20584CE11A /* NftApproveForAll.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = NftApproveForAll.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Objects/NftApproveForAll.graphql.swift; sourceTree = ""; }; + FD54D51C296C79A4007A37E9 /* GoogleServiceInfo */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = GoogleServiceInfo; sourceTree = SOURCE_ROOT; }; + FD7304CD28A364FC0085BDEA /* Colors.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Colors.xcassets; path = Lux/Colors.xcassets; sourceTree = ""; }; + FD7304CF28A3650A0085BDEA /* Colors.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = Colors.swift; path = Lux/Colors.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 00E356EB1AD99517003FC87E /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + E4B3067A930D2E57558E5229 /* Pods_Lux_LuxTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 072E23832A44D5BC006AD6C9 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + BA8FC9627A40644259D9E2F9 /* Pods_WidgetsCore.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 072E238A2A44D5BD006AD6C9 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 072E238E2A44D5BD006AD6C9 /* WidgetsCore.framework in Frameworks */, + D7926D4A878B2237137B300F /* Pods_WidgetsCoreTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 072F6C1C2A44A32E00DA720A /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 9F78980B2A819CC4004D5A98 /* SwiftUI.framework in Frameworks */, + 9F7898112A819D32004D5A98 /* WidgetsCore.framework in Frameworks */, + 072F6C212A44A32E00DA720A /* WidgetKit.framework in Frameworks */, + DE2F24512E7204C2CA255C50 /* Pods_Widgets.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 078E79422A55EB3300F59CF2 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 078E79472A55EB3300F59CF2 /* Intents.framework in Frameworks */, + 9F78980E2A819D2B004D5A98 /* WidgetsCore.framework in Frameworks */, + 70EB8338CA39744B7DBD553E /* Pods_WidgetIntentExtension.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 13B07F8C1A680F5B00A75B9A /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 9F7898142A819D62004D5A98 /* WidgetsCore.framework in Frameworks */, + 463BA791004B1B7AC1773914 /* Pods_Lux.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + F35AFD3827EE49990011A725 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 8273FC23FB1AE47B80C5E09F /* Pods_OneSignalNotificationServiceExtension.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 00E356EF1AD99517003FC87E /* LuxTests */ = { + isa = PBXGroup; + children = ( + 6C8EFC2C2891B99100FBD8EB /* EncryptionHelperTests.swift */, + 00E356F21AD99517003FC87E /* LuxTests.m */, + 00E356F01AD99517003FC87E /* Supporting Files */, + ); + path = LuxTests; + sourceTree = ""; + }; + 00E356F01AD99517003FC87E /* Supporting Files */ = { + isa = PBXGroup; + children = ( + 00E356F11AD99517003FC87E /* Info.plist */, + ); + name = "Supporting Files"; + sourceTree = ""; + }; + 03C788212C10E71D0011E5DC /* Shared */ = { + isa = PBXGroup; + children = ( + 03C788222C10E7390011E5DC /* ActionButtons.swift */, + 03D2F3172C218D380030D987 /* RelativeOffsetView.swift */, + ); + path = Shared; + sourceTree = ""; + }; + 03DD298C2A4CE34B00E3E0F5 /* Appearance */ = { + isa = PBXGroup; + children = ( + 9FCEBF022A95A99B0079EDDB /* RCTThemeModule.h */, + 9FCEBF032A95A99B0079EDDB /* RCTThemeModule.m */, + ); + name = Appearance; + sourceTree = ""; + }; + 0703EE042A57350400AED1DA /* Utils */ = { + isa = PBXGroup; + children = ( + 0741433F2A588F5800A157D3 /* Structs.swift */, + 0767E0392A65CA590042ADA2 /* UI */, + 0703EE022A5734A600AED1DA /* UserDefaults.swift */, + 0743223F2A841BBD00F8518D /* Constants.swift */, + 07F136412A5763480067004F /* Network.swift */, + 072F6C372A44BECC00DA720A /* Logging.swift */, + 07F1363F2A575EC00067004F /* DataQueries.swift */, + 07F5CF742A7020FD00C648A5 /* Format.swift */, + ); + path = Utils; + sourceTree = ""; + }; + 072E23872A44D5BD006AD6C9 /* WidgetsCore */ = { + isa = PBXGroup; + children = ( + 07F0C28E2A5F3E2E00D5353E /* Env.swift */, + 073C67F42A5C8FBE00F6DAD8 /* MobileSchema */, + 072E23882A44D5BD006AD6C9 /* WidgetsCore.h */, + 0703EE042A57350400AED1DA /* Utils */, + ); + path = WidgetsCore; + sourceTree = ""; + }; + 072E23932A44D5BD006AD6C9 /* WidgetsCoreTests */ = { + isa = PBXGroup; + children = ( + 072E23942A44D5BD006AD6C9 /* WidgetsCoreTests.swift */, + 074086F92A703B76006E3053 /* FormatTests.swift */, + ); + path = WidgetsCoreTests; + sourceTree = ""; + }; + 072F6C242A44A32E00DA720A /* Widgets */ = { + isa = PBXGroup; + children = ( + 072E23A62A44D61A006AD6C9 /* Widgets.entitlements */, + 072F6C252A44A32E00DA720A /* WidgetsBundle.swift */, + 072F6C272A44A32E00DA720A /* TokenPriceWidget.swift */, + 072F6C292A44A32E00DA720A /* TokenPriceWidget.intentdefinition */, + 072F6C2A2A44A32F00DA720A /* Assets.xcassets */, + 072F6C2C2A44A32F00DA720A /* Info.plist */, + ); + path = Widgets; + sourceTree = ""; + }; + 073C67F42A5C8FBE00F6DAD8 /* MobileSchema */ = { + isa = PBXGroup; + children = ( + 074321A32A83E3C900F8518D /* Fragments */, + 0743218E2A83E3C900F8518D /* Operations */, + 074321A62A83E3C900F8518D /* Schema */, + ); + path = MobileSchema; + sourceTree = ""; + }; + 0743218E2A83E3C900F8518D /* Operations */ = { + isa = PBXGroup; + children = ( + 0743218F2A83E3C900F8518D /* Queries */, + ); + path = Operations; + sourceTree = ""; + }; + 0743218F2A83E3C900F8518D /* Queries */ = { + isa = PBXGroup; + children = ( + ); + path = Queries; + sourceTree = ""; + }; + 074321A32A83E3C900F8518D /* Fragments */ = { + isa = PBXGroup; + children = ( + ); + path = Fragments; + sourceTree = ""; + }; + 074321A62A83E3C900F8518D /* Schema */ = { + isa = PBXGroup; + children = ( + 074321A72A83E3C900F8518D /* Unions */, + 074321A92A83E3C900F8518D /* Enums */, + 074321B62A83E3C900F8518D /* Objects */, + 074321DE2A83E3C900F8518D /* SchemaConfiguration.swift */, + 074321DF2A83E3C900F8518D /* InputObjects */, + 074321E82A83E3C900F8518D /* Interfaces */, + ); + path = Schema; + sourceTree = ""; + }; + 074321A72A83E3C900F8518D /* Unions */ = { + isa = PBXGroup; + children = ( + ); + path = Unions; + sourceTree = ""; + }; + 074321A92A83E3C900F8518D /* Enums */ = { + isa = PBXGroup; + children = ( + ); + path = Enums; + sourceTree = ""; + }; + 074321B62A83E3C900F8518D /* Objects */ = { + isa = PBXGroup; + children = ( + ); + path = Objects; + sourceTree = ""; + }; + 074321DF2A83E3C900F8518D /* InputObjects */ = { + isa = PBXGroup; + children = ( + ); + path = InputObjects; + sourceTree = ""; + }; + 074321E82A83E3C900F8518D /* Interfaces */ = { + isa = PBXGroup; + children = ( + ); + path = Interfaces; + sourceTree = ""; + }; + 0767E0392A65CA590042ADA2 /* UI */ = { + isa = PBXGroup; + children = ( + 0767E0372A65C8330042ADA2 /* Colors.swift */, + 07F5CF702A6AD97D00C648A5 /* Chart.swift */, + 0783F7B32A619E7C009ED617 /* UIComponents.swift */, + 0767E03A2A65D2550042ADA2 /* Styling.swift */, + ); + path = UI; + sourceTree = ""; + }; + 078E79482A55EB3300F59CF2 /* WidgetIntentExtension */ = { + isa = PBXGroup; + children = ( + 070480372A58A507009006CE /* WidgetIntentExtension.entitlements */, + 078E79492A55EB3300F59CF2 /* IntentHandler.swift */, + 078E794B2A55EB3300F59CF2 /* Info.plist */, + ); + path = WidgetIntentExtension; + sourceTree = ""; + }; + 07B067692A7D6EC8001DD9B9 /* Widget */ = { + isa = PBXGroup; + children = ( + 07B0676A2A7D6EC8001DD9B9 /* RNWidgets.swift */, + 07B0676B2A7D6EC8001DD9B9 /* RNWidgets.m */, + ); + name = Widget; + path = Lux/Widget; + sourceTree = ""; + }; + 0DB282232CDADB260014CF77 /* EmbeddedWallet */ = { + isa = PBXGroup; + children = ( + 0DB282242CDADB260014CF77 /* EmbeddedWallet.m */, + 0DB282252CDADB260014CF77 /* EmbeddedWallet.swift */, + ); + name = EmbeddedWallet; + path = Lux/Onboarding/EmbeddedWallet; + sourceTree = ""; + }; + 0E2552A1FE1E968DB6E45EDB /* Enums */ = { + isa = PBXGroup; + children = ( + 25DDE9DCAFA0EE2838508994 /* Chain.graphql.swift */, + E5DDE9BCC6A539F6E9AE1664 /* Currency.graphql.swift */, + DC6517FF8972DA448CDD2DFC /* HistoryDuration.graphql.swift */, + 8D973A66C23859D34E6FE175 /* NftStandard.graphql.swift */, + BE4AB3FCC91BE6C73557E0E3 /* ProtectionAttackType.graphql.swift */, + CA586C522ABE5DA5363D27C7 /* ProtectionResult.graphql.swift */, + 41FFD02A227ACFB661A44813 /* SafetyLevel.graphql.swift */, + 132FC1B05AE3D510C3B71EBA /* SwapOrderStatus.graphql.swift */, + 0A7D41CB7AA0496123E2153D /* SwapOrderType.graphql.swift */, + 3D96819BA563CB218D29C657 /* TokenSortableField.graphql.swift */, + CE5E0F71E23968DEFCEB7A4B /* TokenStandard.graphql.swift */, + 4EC9CABC0B4E73B25C7A2128 /* TransactionDirection.graphql.swift */, + A182D5FF8C0919083F2E333C /* TransactionStatus.graphql.swift */, + 37A6D5D76D31F8C5CDD29814 /* TransactionType.graphql.swift */, + ); + name = Enums; + sourceTree = ""; + }; + 13B07FAE1A68108700A75B9A /* Lux */ = { + isa = PBXGroup; + children = ( + AC0EE0972BD826E700BCCF07 /* PrivacyInfo.xcprivacy */, + 8EA8AB3F2AB7ED76004E7EF3 /* Icons */, + 8E566D9F2AA1095000D4AA76 /* Components */, + 07B067692A7D6EC8001DD9B9 /* Widget */, + 03DD298C2A4CE34B00E3E0F5 /* Appearance */, + F35AFD3627EE49230011A725 /* Lux.entitlements */, + 45FFF7DD2E8C2A3A00362570 /* Notifications */, + 6C84F055283D83CF0071FA2E /* Onboarding */, + 6CE631B928186D4500716D29 /* WalletConnect */, + A32F9FBC272343C8002CFCDB /* GoogleService-Info.plist */, + 6CA91BD72A95223C00C4063E /* RNEthersRs */, + 6CA91BDD2A95226200C4063E /* RNCloudBackupsManager */, + 13B07FB51A68108700A75B9A /* Images.xcassets */, + 13B07FB61A68108700A75B9A /* Info.plist */, + BF9176E944C84910B1C0B057 /* SplashScreen.storyboard */, + FD7304CD28A364FC0085BDEA /* Colors.xcassets */, + FD7304CF28A3650A0085BDEA /* Colors.swift */, + 8B2A92162EB3E78E00990413 /* AppDelegate.swift */, + 8B2A92182EB3E79500990413 /* Lux-Bridging-Header.h */, + ); + name = Lux; + sourceTree = ""; + }; + 14138E8BFDEA86F0825B9C6D /* MobileSchema */ = { + isa = PBXGroup; + children = ( + A24882D069B34B7DC9E8B4CE /* MobileSchema.graphql.swift */, + 14D11F5F6A001B2F9CC05DC9 /* Fragments */, + 7A9EBBAEE00CEC84AFF4B49E /* Operations */, + 73DBB3ED6E3D25FBAA098F91 /* Schema */, + ); + name = MobileSchema; + sourceTree = ""; + }; + 14D11F5F6A001B2F9CC05DC9 /* Fragments */ = { + isa = PBXGroup; + children = ( + AB3EEA677984132330D8D279 /* HomeScreenTokenParts.graphql.swift */, + 7F0C115796D33739746778E3 /* TokenBalanceMainParts.graphql.swift */, + B97D41BC2760803FD1C7CB3C /* TokenBalanceParts.graphql.swift */, + 8D5FF4316461BD4FB5847D62 /* TokenBalanceQuantityParts.graphql.swift */, + 319AAC5377C60EB6579CC5C6 /* TokenBasicInfoParts.graphql.swift */, + BB9FB14C8B68CE1D05643256 /* TokenBasicProjectParts.graphql.swift */, + 413CEB2443D6DD1DA7CC7231 /* TokenFeeDataParts.graphql.swift */, + C6754D4BD9CE76AAC915F814 /* TokenMarketParts.graphql.swift */, + 0A266B733FD2FA9C1C1A7731 /* TokenParts.graphql.swift */, + 3BA467FB84503BB53B081C73 /* TokenProjectMarketsParts.graphql.swift */, + 9F1055EFA8467996754739A7 /* TokenProjectUrlsParts.graphql.swift */, + 93D1BA7F635F61FC7E62C6C1 /* TokenProtectionInfoParts.graphql.swift */, + 43D7F54953D864DC067FADD1 /* TopTokenParts.graphql.swift */, + ); + name = Fragments; + sourceTree = ""; + }; + 2D16E6871FA4F8E400B85C8A /* Frameworks */ = { + isa = PBXGroup; + children = ( + 9F3500812A8AA5890077BFC5 /* EXSplashScreen.xcframework */, + ED297162215061F000B7C4FE /* JavaScriptCore.framework */, + 072F6C202A44A32E00DA720A /* WidgetKit.framework */, + 072F6C222A44A32E00DA720A /* SwiftUI.framework */, + 078E79462A55EB3300F59CF2 /* Intents.framework */, + 15092E550A1C78508ABA3280 /* Pods_OneSignalNotificationServiceExtension.framework */, + 2226DF79BEAFECEE11A51347 /* Pods_Lux.framework */, + 0929C0B4AE1570B8C0B45D4D /* Pods_Lux_LuxTests.framework */, + 1064E23E366D0C2C2B20C30E /* Pods_WidgetIntentExtension.framework */, + 2E8B7D36D2E14D9488F351EB /* Pods_Widgets.framework */, + CB29AC0C0907A833F23D2C30 /* Pods_WidgetsCore.framework */, + 021E59CE7ECBD4FE0F3BFCFD /* Pods_WidgetsCoreTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + 2F1F2ABA5F5FE75352ED9B7C /* Objects */ = { + isa = PBXGroup; + children = ( + 74673620CE906C9AF34C6750 /* Amount.graphql.swift */, + 43AB42F3ED903CF49D7D4DE7 /* AmountChange.graphql.swift */, + 9BF540E1110D0FD1C219C45C /* ApplicationContract.graphql.swift */, + 8BB8B6395E3C86412FA793CB /* AssetActivity.graphql.swift */, + 201FF7E30EC98ABC121BC8BF /* BlockaidFees.graphql.swift */, + 44D824154584CD39C82904E9 /* BridgedWithdrawalInfo.graphql.swift */, + 6F02DBC2C51EFC6B1AA9C1CD /* DescriptionTranslations.graphql.swift */, + 6DCC7E203AB163C9C2D3D789 /* Dimensions.graphql.swift */, + 290B66F38FEC486AF1709BE4 /* FeeData.graphql.swift */, + 284C5150E9B50FB5BE531572 /* Image.graphql.swift */, + 2FB70424D7C456B9B72536C6 /* NetworkFee.graphql.swift */, + 42158AF500DDBEEF6B7E98E3 /* NftApproval.graphql.swift */, + F99987D29B574C20584CE11A /* NftApproveForAll.graphql.swift */, + CD3573B4E6FD61F5DDBD9C75 /* NftAsset.graphql.swift */, + C8BAFEAABF78617095D44E32 /* NftBalance.graphql.swift */, + 56093E216B82288E1A4018F0 /* NftBalanceConnection.graphql.swift */, + F182567B705FC0E15DA8F34A /* NftBalanceEdge.graphql.swift */, + 2ECFE86020AB7A252CDC9BB1 /* NftCollection.graphql.swift */, + 66697C913CF365C9460F42E6 /* NftCollectionMarket.graphql.swift */, + 9A2AA83B4F9F4290A2BC7CCB /* NftContract.graphql.swift */, + E21EB4FB53B7B6B5848946D9 /* NftProfile.graphql.swift */, + 792EF7A7820987EA9E99D792 /* NftTransfer.graphql.swift */, + 6AFC48B9A573915BFA6FC6E2 /* OffRampTransactionDetails.graphql.swift */, + 3063061F7C203A1DA47B8FBB /* OffRampTransfer.graphql.swift */, + 0E5478334318BD5D6F8366D6 /* OnRampServiceProvider.graphql.swift */, + 614A491FAB9659FA08CD9AE4 /* OnRampTransactionDetails.graphql.swift */, + 60195493B9C71EDEECA46E80 /* OnRampTransfer.graphql.swift */, + 5F2898E709490CC198B98228 /* PageInfo.graphql.swift */, + A34E3A6E13A4066767AA6757 /* Portfolio.graphql.swift */, + 346B304DE3207797FD1FDE71 /* ProtectionInfo.graphql.swift */, + 58641E30674C4C4241702902 /* Query.graphql.swift */, + 32764E27B5D9FCF0AA4217CF /* SwapOrderDetails.graphql.swift */, + 2C9E8E39C13967E447FC193F /* TimestampedAmount.graphql.swift */, + 88B481513A2E780E6489DC4F /* Token.graphql.swift */, + 009F4258CE111989223E99A4 /* TokenApproval.graphql.swift */, + 4AF8EE05291534B82ACC1105 /* TokenBalance.graphql.swift */, + A02D64B698D203A7F3643C18 /* TokenMarket.graphql.swift */, + A424FA93DB720DD7684C7674 /* TokenProject.graphql.swift */, + A3E638BE80543E905D072D7F /* TokenProjectMarket.graphql.swift */, + 93783F96FA74CB9A1BEB32EE /* TokenTransfer.graphql.swift */, + 47148657FBA8DCC92DD69573 /* TransactionDetails.graphql.swift */, + ); + name = Objects; + sourceTree = ""; + }; + 45FFF7DD2E8C2A3A00362570 /* Notifications */ = { + isa = PBXGroup; + children = ( + 45FFF7E02E8C2E6100362570 /* SilentPushEventEmitter.swift */, + 45FFF7DE2E8C2A6400362570 /* SilentPushEventEmitter.m */, + ); + name = Notifications; + path = Lux/Notifications; + sourceTree = ""; + }; + 47914D9EE3A4DE926EFC5089 /* LuxTests */ = { + isa = PBXGroup; + children = ( + C26D739993D5C939C6FBB58A /* ExpoModulesProvider.swift */, + ); + name = LuxTests; + sourceTree = ""; + }; + 49A0455CC5D07F432B367D3F /* Interfaces */ = { + isa = PBXGroup; + children = ( + A372929BEDB706B6144334F0 /* IAmount.graphql.swift */, + 1D88F29D12AB0D8B4C00D065 /* IContract.graphql.swift */, + ); + name = Interfaces; + sourceTree = ""; + }; + 5754C6A1B51170788A63F6F3 /* ExpoModulesProviders */ = { + isa = PBXGroup; + children = ( + 9759A762F61D6B2F01C79DBF /* Lux */, + 47914D9EE3A4DE926EFC5089 /* LuxTests */, + ); + name = ExpoModulesProviders; + sourceTree = ""; + }; + 5841D897B122046172ACD989 /* WidgetsCore */ = { + isa = PBXGroup; + children = ( + 14138E8BFDEA86F0825B9C6D /* MobileSchema */, + ); + name = WidgetsCore; + sourceTree = ""; + }; + 5B4398EB2DD3B22C00F6BE08 /* PrivateKeyDisplay */ = { + isa = PBXGroup; + children = ( + 5B4398E82DD3B22C00F6BE08 /* PrivateKeyDisplayManager.m */, + 5B4398E92DD3B22C00F6BE08 /* PrivateKeyDisplayManager.swift */, + 5B4398EA2DD3B22C00F6BE08 /* PrivateKeyDisplayView.swift */, + ); + path = PrivateKeyDisplay; + sourceTree = ""; + }; + 6583DF0353495046646DE918 /* InputObjects */ = { + isa = PBXGroup; + children = ( + 957594D21A42B9D77F5AD685 /* ContractInput.graphql.swift */, + C394AC5DB58BDF1C33CC0927 /* NftBalanceAssetInput.graphql.swift */, + F70F4D05A45BC18EA88868A0 /* NftBalancesFilterInput.graphql.swift */, + 38EAF64134981DC76B6C7E90 /* OnRampTransactionsAuth.graphql.swift */, + E13150074BB66C20E7EA271A /* PortfolioValueModifier.graphql.swift */, + ); + name = InputObjects; + sourceTree = ""; + }; + 6BC7D07A2B5FF02400617C95 /* Scantastic */ = { + isa = PBXGroup; + children = ( + 6BC7D07B2B5FF02400617C95 /* ScantasticEncryption.m */, + 6BC7D07C2B5FF02400617C95 /* ScantasticEncryption.swift */, + 6BC7D07D2B5FF02400617C95 /* EncryptionUtils.swift */, + ); + name = Scantastic; + path = Lux/Onboarding/Scantastic; + sourceTree = ""; + }; + 6C84F055283D83CF0071FA2E /* Onboarding */ = { + isa = PBXGroup; + children = ( + 0DB282232CDADB260014CF77 /* EmbeddedWallet */, + 03C788212C10E71D0011E5DC /* Shared */, + 6BC7D07A2B5FF02400617C95 /* Scantastic */, + 8E89C3A52AB8AAA400C84DE5 /* Backup */, + 8EA8AB2F2AB7ED3C004E7EF3 /* Import */, + ); + name = Onboarding; + sourceTree = ""; + }; + 6CA91BD72A95223C00C4063E /* RNEthersRs */ = { + isa = PBXGroup; + children = ( + 649A7A762D9AE70B00B53589 /* KeychainConstants.swift */, + 649A7A772D9AE70B00B53589 /* KeychainUtils.swift */, + A3F0A5B0272B1DFA00895B25 /* KeychainSwiftDistrib.swift */, + 6CA91BD82A95223C00C4063E /* RNEthersRS-Bridging-Header.h */, + 6CA91BD92A95223C00C4063E /* RNEthersRS.swift */, + 6CA91BDA2A95223C00C4063E /* RnEthersRS.m */, + ); + name = RNEthersRs; + path = Lux/RNEthersRs; + sourceTree = ""; + }; + 6CA91BDD2A95226200C4063E /* RNCloudBackupsManager */ = { + isa = PBXGroup; + children = ( + 6CA91BDE2A95226200C4063E /* RNCloudStorageBackupsManager.m */, + 6CA91BDF2A95226200C4063E /* EncryptionHelper.swift */, + 6CA91BE02A95226200C4063E /* RNCloudStorageBackupsManager.swift */, + ); + name = RNCloudBackupsManager; + path = Lux/RNCloudBackupsManager; + sourceTree = ""; + }; + 6CE631B928186D4500716D29 /* WalletConnect */ = { + isa = PBXGroup; + children = ( + 9FCEBEFD2A95A8E00079EDDB /* RNWalletConnect.h */, + 9FCEBEFE2A95A8E00079EDDB /* RNWalletConnect.m */, + 9FCEBEFF2A95A8E00079EDDB /* RNWalletConnect.swift */, + ); + name = WalletConnect; + sourceTree = ""; + }; + 73DBB3ED6E3D25FBAA098F91 /* Schema */ = { + isa = PBXGroup; + children = ( + 0D25B69F4A69D733A5DAEC42 /* SchemaConfiguration.swift */, + 73D981A2D31B6F8822C19324 /* SchemaMetadata.graphql.swift */, + 0E2552A1FE1E968DB6E45EDB /* Enums */, + 6583DF0353495046646DE918 /* InputObjects */, + 49A0455CC5D07F432B367D3F /* Interfaces */, + 2F1F2ABA5F5FE75352ED9B7C /* Objects */, + C371BE586C38E8B49D49C32A /* Unions */, + ); + name = Schema; + sourceTree = ""; + }; + 7A9EBBAEE00CEC84AFF4B49E /* Operations */ = { + isa = PBXGroup; + children = ( + FAB5CD57887CDC6FE536B147 /* Queries */, + ); + name = Operations; + sourceTree = ""; + }; + 832341AE1AAA6A7D00B99B32 /* Libraries */ = { + isa = PBXGroup; + children = ( + ); + name = Libraries; + sourceTree = ""; + }; + 83CBB9F61A601CBA00E9B192 = { + isa = PBXGroup; + children = ( + AC2EF4022C914B1600EEEFDB /* fonts */, + AC2794442C51541E00F9AF68 /* sourcemaps-datadog.sh */, + FD54D51C296C79A4007A37E9 /* GoogleServiceInfo */, + 13B07FAE1A68108700A75B9A /* Lux */, + 832341AE1AAA6A7D00B99B32 /* Libraries */, + 00E356EF1AD99517003FC87E /* LuxTests */, + F35AFD3C27EE49990011A725 /* OneSignalNotificationServiceExtension */, + 072F6C242A44A32E00DA720A /* Widgets */, + 072E23872A44D5BD006AD6C9 /* WidgetsCore */, + 072E23932A44D5BD006AD6C9 /* WidgetsCoreTests */, + 078E79482A55EB3300F59CF2 /* WidgetIntentExtension */, + 83CBBA001A601CBA00E9B192 /* Products */, + 2D16E6871FA4F8E400B85C8A /* Frameworks */, + E233CBF5F47BEE60B243DCF8 /* Pods */, + C2C18ECBEF5A4489BF3A314C /* Resources */, + 5754C6A1B51170788A63F6F3 /* ExpoModulesProviders */, + 5841D897B122046172ACD989 /* WidgetsCore */, + ); + indentWidth = 2; + sourceTree = ""; + tabWidth = 2; + usesTabs = 0; + }; + 83CBBA001A601CBA00E9B192 /* Products */ = { + isa = PBXGroup; + children = ( + 13B07F961A680F5B00A75B9A /* Lux.app */, + 00E356EE1AD99517003FC87E /* LuxTests.xctest */, + F35AFD3B27EE49990011A725 /* OneSignalNotificationServiceExtension.appex */, + 072F6C1F2A44A32E00DA720A /* Widgets.appex */, + 072E23862A44D5BC006AD6C9 /* WidgetsCore.framework */, + 072E238D2A44D5BD006AD6C9 /* WidgetsCoreTests.xctest */, + 078E79452A55EB3300F59CF2 /* WidgetIntentExtension.appex */, + ); + name = Products; + sourceTree = ""; + }; + 8E566D9F2AA1095000D4AA76 /* Components */ = { + isa = PBXGroup; + children = ( + 5B4398EB2DD3B22C00F6BE08 /* PrivateKeyDisplay */, + 8ED0562B2AA78E2C009BD5A2 /* ScrollFadeExtensions.swift */, + ); + path = Components; + sourceTree = ""; + }; + 8E89C3A52AB8AAA400C84DE5 /* Backup */ = { + isa = PBXGroup; + children = ( + 8E89C3A62AB8AAA400C84DE5 /* MnemonicConfirmationView.swift */, + 8E89C3A72AB8AAA400C84DE5 /* MnemonicDisplayView.swift */, + 8E89C3A92AB8AAA400C84DE5 /* MnemonicConfirmationWordBankView.swift */, + 8E89C3AA2AB8AAA400C84DE5 /* MnemonicTextField.swift */, + 8E89C3AB2AB8AAA400C84DE5 /* MnemonicDisplayManager.m */, + 8E89C3AC2AB8AAA400C84DE5 /* MnemonicConfirmationManager.m */, + 8E89C3AD2AB8AAA400C84DE5 /* RNSwiftUI-Bridging-Header.h */, + ); + name = Backup; + path = Lux/Onboarding/Backup; + sourceTree = ""; + }; + 8EA8AB2F2AB7ED3C004E7EF3 /* Import */ = { + isa = PBXGroup; + children = ( + 8EA8AB302AB7ED3C004E7EF3 /* SeedPhraseInputManager.m */, + 8EA8AB312AB7ED3C004E7EF3 /* SeedPhraseInputViewModel.swift */, + 8EA8AB322AB7ED3C004E7EF3 /* SeedPhraseInputView.swift */, + 8EA8AB332AB7ED3C004E7EF3 /* SeedPhraseInputManager.swift */, + ); + name = Import; + path = Lux/Onboarding/Import; + sourceTree = ""; + }; + 8EA8AB3F2AB7ED76004E7EF3 /* Icons */ = { + isa = PBXGroup; + children = ( + 8EA8AB402AB7ED76004E7EF3 /* AlertTriangleIcon.swift */, + 8EBFB1542ABA6AA6006B32A8 /* PasteIcon.swift */, + 037C5AA92C04970B00B1D808 /* CopyIcon.swift */, + 5B4CEC5E2DD65DD4009F082B /* CopyIconOutline.swift */, + ); + name = Icons; + path = Lux/Icons; + sourceTree = ""; + }; + 9759A762F61D6B2F01C79DBF /* Lux */ = { + isa = PBXGroup; + children = ( + 4DF5F26A06553EFDD4D99214 /* ExpoModulesProvider.swift */, + ); + name = Lux; + sourceTree = ""; + }; + C2C18ECBEF5A4489BF3A314C /* Resources */ = { + isa = PBXGroup; + children = ( + 1834199AFFB04D91B05FFB64 /* InputMono-Regular.ttf */, + 6F33E8069B7B40AFB313B8B0 /* Basel-Grotesk-Book.otf */, + 3C606D2C81014A0A8898F38E /* Basel-Grotesk-Medium.otf */, + ); + name = Resources; + sourceTree = ""; + }; + C371BE586C38E8B49D49C32A /* Unions */ = { + isa = PBXGroup; + children = ( + B68E391195D4587F61A33380 /* ActivityDetails.graphql.swift */, + F38DFE921F0467FF51A3D717 /* AssetChange.graphql.swift */, + ); + name = Unions; + sourceTree = ""; + }; + E233CBF5F47BEE60B243DCF8 /* Pods */ = { + isa = PBXGroup; + children = ( + 3A2186B1FF7FB85663D96EA9 /* Pods-OneSignalNotificationServiceExtension.debug.xcconfig */, + 1193B3A845BC3BE8CAA00D01 /* Pods-OneSignalNotificationServiceExtension.release.xcconfig */, + 6F7814C6D40D9C348EA1F1C7 /* Pods-OneSignalNotificationServiceExtension.dev.xcconfig */, + F56CC08FBB20FAC0DF6B93DA /* Pods-OneSignalNotificationServiceExtension.beta.xcconfig */, + A7C9F415D0E128A43003E071 /* Pods-Lux.debug.xcconfig */, + 178644A78AB62609EFDB66B3 /* Pods-Lux.release.xcconfig */, + 56FE9C9AF785221B7E3F4C04 /* Pods-Lux.dev.xcconfig */, + 62CEA9F2D5176D20A6402A3E /* Pods-Lux.beta.xcconfig */, + 6F3DC921A65D749C0852B10C /* Pods-Lux-LuxTests.debug.xcconfig */, + F1A3F4DDD7E40DA9E4BBAAD1 /* Pods-Lux-LuxTests.release.xcconfig */, + 0712B3629C74D1F958DF35FB /* Pods-Lux-LuxTests.dev.xcconfig */, + 4781CD4CDD95B5792B793F75 /* Pods-Lux-LuxTests.beta.xcconfig */, + 8719E5872CC41AB64503E903 /* Pods-WidgetIntentExtension.debug.xcconfig */, + 08C60D53AB82A6D0D31D0F78 /* Pods-WidgetIntentExtension.release.xcconfig */, + 1CC6ADAADCA38FDAEB181E86 /* Pods-WidgetIntentExtension.dev.xcconfig */, + 7A7637BBC9B3A68E0338D96E /* Pods-WidgetIntentExtension.beta.xcconfig */, + D79B717BEAEA7857469D770A /* Pods-Widgets.debug.xcconfig */, + 82C9871585F60F92D079FB95 /* Pods-Widgets.release.xcconfig */, + 08EBF075A4482F701892270B /* Pods-Widgets.dev.xcconfig */, + 0C19DE44A750FB17647FF2B6 /* Pods-Widgets.beta.xcconfig */, + B0DA4D39B1A6D74A1D05B99F /* Pods-WidgetsCore.debug.xcconfig */, + 3E279F675B02CBC50D3B57D5 /* Pods-WidgetsCore.release.xcconfig */, + BCB2A43E5FB0D7B69CA02312 /* Pods-WidgetsCore.dev.xcconfig */, + 0B7E5D62E11408EB5F0F5A80 /* Pods-WidgetsCore.beta.xcconfig */, + 3D8FCE4CD401350CA74DCC89 /* Pods-WidgetsCoreTests.debug.xcconfig */, + C89238E3ED9F3AC98876B573 /* Pods-WidgetsCoreTests.release.xcconfig */, + 065A981F892F7A06A900FCD5 /* Pods-WidgetsCoreTests.dev.xcconfig */, + 4C445DB9798210862C34D0E0 /* Pods-WidgetsCoreTests.beta.xcconfig */, + ); + path = Pods; + sourceTree = ""; + }; + F35AFD3C27EE49990011A725 /* OneSignalNotificationServiceExtension */ = { + isa = PBXGroup; + children = ( + 5E5E0A622D380F5700E166AA /* Env.swift */, + F35AFD4727EE4B400011A725 /* OneSignalNotificationServiceExtension.entitlements */, + F35AFD3D27EE49990011A725 /* NotificationService.swift */, + F35AFD3F27EE49990011A725 /* Info.plist */, + ); + path = OneSignalNotificationServiceExtension; + sourceTree = ""; + }; + FAB5CD57887CDC6FE536B147 /* Queries */ = { + isa = PBXGroup; + children = ( + 3D3112654E5722A2F862D3FC /* ConvertQuery.graphql.swift */, + 48DE72B77CD2D89B75BB934D /* FavoriteTokenCardQuery.graphql.swift */, + E21B5EF6BC4582DD2BB0DA13 /* FeedTransactionListQuery.graphql.swift */, + 2240891823DAE7BF8BB88FF4 /* HomeScreenTokensQuery.graphql.swift */, + E3AF9944E853669C2BC9B5AB /* MultiplePortfolioBalancesQuery.graphql.swift */, + 42013D777BAA40652AB28985 /* NftsQuery.graphql.swift */, + 0EA8FE9029A1E7C4EAD7F547 /* NftsTabQuery.graphql.swift */, + 8604CAA8F7E44D704A6F4AAF /* PortfolioBalancesQuery.graphql.swift */, + BB3CBF221D5D2E3C036A1BA0 /* SelectWalletScreenQuery.graphql.swift */, + E7ED83FFCB410FCCA2C6B97B /* TokenDetailsScreenQuery.graphql.swift */, + EDA02DA46906E4CDF827104A /* TokenPriceHistoryQuery.graphql.swift */, + 8C66DD1D03A824EB6A231DCE /* TokenProjectDescriptionQuery.graphql.swift */, + E1B3A0C152B683215291E3BD /* TokenProjectsQuery.graphql.swift */, + 16F706375DFAFB58F8440CF9 /* TokenQuery.graphql.swift */, + 5442480B3C91EDC515728606 /* TokensQuery.graphql.swift */, + C050FFEC067334CF05C17EA0 /* TopTokensQuery.graphql.swift */, + 3AE6A0CAEDE1DC1D2FA14DEA /* TransactionHistoryUpdaterQuery.graphql.swift */, + C27225607FACF638581E25B5 /* TransactionListQuery.graphql.swift */, + C0C956C520AAB54BCE8C5CCC /* WidgetTokensQuery.graphql.swift */, + ); + name = Queries; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXHeadersBuildPhase section */ + 072E23812A44D5BC006AD6C9 /* Headers */ = { + isa = PBXHeadersBuildPhase; + buildActionMask = 2147483647; + files = ( + 072E23962A44D5BD006AD6C9 /* WidgetsCore.h in Headers */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXHeadersBuildPhase section */ + +/* Begin PBXNativeTarget section */ + 00E356ED1AD99517003FC87E /* LuxTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 00E357021AD99517003FC87E /* Build configuration list for PBXNativeTarget "LuxTests" */; + buildPhases = ( + 100379B32AFE3ED61809176D /* [CP] Check Pods Manifest.lock */, + 6236BA814A452C299F46F817 /* [Expo] Configure project */, + 00E356EA1AD99517003FC87E /* Sources */, + 00E356EB1AD99517003FC87E /* Frameworks */, + 00E356EC1AD99517003FC87E /* Resources */, + F5C7F44CBF58F052A43EB4AA /* [CP] Embed Pods Frameworks */, + 24AD8CEA045C82D94D2A96F3 /* [CP] Copy Pods Resources */, + ); + buildRules = ( + ); + dependencies = ( + 00E356F51AD99517003FC87E /* PBXTargetDependency */, + ); + name = LuxTests; + productName = LuxTests; + productReference = 00E356EE1AD99517003FC87E /* LuxTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 072E23852A44D5BC006AD6C9 /* WidgetsCore */ = { + isa = PBXNativeTarget; + buildConfigurationList = 072E23A42A44D5BD006AD6C9 /* Build configuration list for PBXNativeTarget "WidgetsCore" */; + buildPhases = ( + 420594480338AF2E35636F6B /* [CP] Check Pods Manifest.lock */, + 204FD9232D84400E00736C13 /* [GraphQL] Apollo Generate Swift */, + 204FD9222D843CEE00736C13 /* Copy Env Vars to Swift */, + 072E23812A44D5BC006AD6C9 /* Headers */, + 072E23822A44D5BC006AD6C9 /* Sources */, + 072E23832A44D5BC006AD6C9 /* Frameworks */, + 072E23842A44D5BC006AD6C9 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = WidgetsCore; + productName = LuxWidgetsCore; + productReference = 072E23862A44D5BC006AD6C9 /* WidgetsCore.framework */; + productType = "com.apple.product-type.framework"; + }; + 072E238C2A44D5BD006AD6C9 /* WidgetsCoreTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 072E23A52A44D5BD006AD6C9 /* Build configuration list for PBXNativeTarget "WidgetsCoreTests" */; + buildPhases = ( + F655443D83433C1BB79AB354 /* [CP] Check Pods Manifest.lock */, + 072E23892A44D5BD006AD6C9 /* Sources */, + 072E238A2A44D5BD006AD6C9 /* Frameworks */, + 072E238B2A44D5BD006AD6C9 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 072E23902A44D5BD006AD6C9 /* PBXTargetDependency */, + 072E23922A44D5BD006AD6C9 /* PBXTargetDependency */, + ); + name = WidgetsCoreTests; + productName = LuxWidgetsCoreTests; + productReference = 072E238D2A44D5BD006AD6C9 /* WidgetsCoreTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 072F6C1E2A44A32E00DA720A /* Widgets */ = { + isa = PBXNativeTarget; + buildConfigurationList = 072F6C362A44A32F00DA720A /* Build configuration list for PBXNativeTarget "Widgets" */; + buildPhases = ( + 7856E1172A714318AA224CFE /* [CP] Check Pods Manifest.lock */, + 072F6C1B2A44A32E00DA720A /* Sources */, + 072F6C1C2A44A32E00DA720A /* Frameworks */, + 072F6C1D2A44A32E00DA720A /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 0703EE092A57355400AED1DA /* PBXTargetDependency */, + ); + name = Widgets; + productName = LuxWidgetExtension; + productReference = 072F6C1F2A44A32E00DA720A /* Widgets.appex */; + productType = "com.apple.product-type.app-extension"; + }; + 078E79442A55EB3300F59CF2 /* WidgetIntentExtension */ = { + isa = PBXNativeTarget; + buildConfigurationList = 078E794F2A55EB3400F59CF2 /* Build configuration list for PBXNativeTarget "WidgetIntentExtension" */; + buildPhases = ( + 39755FBAA6CFA5CF80E716DB /* [CP] Check Pods Manifest.lock */, + 078E79412A55EB3300F59CF2 /* Sources */, + 078E79422A55EB3300F59CF2 /* Frameworks */, + 078E79432A55EB3300F59CF2 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 9F7898062A819AA2004D5A98 /* PBXTargetDependency */, + ); + name = WidgetIntentExtension; + productName = LuxWidgetsIntentExtension; + productReference = 078E79452A55EB3300F59CF2 /* WidgetIntentExtension.appex */; + productType = "com.apple.product-type.app-extension"; + }; + 13B07F861A680F5B00A75B9A /* Lux */ = { + isa = PBXNativeTarget; + buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "Lux" */; + buildPhases = ( + 683FB1CFA5FEF24043F65606 /* [CP] Check Pods Manifest.lock */, + FD10A7F022414F080027D42C /* Start Packager */, + 5930ECD30D77D41A3180451F /* [Expo] Configure project */, + 13B07F871A680F5B00A75B9A /* Sources */, + FD54D51B296C780A007A37E9 /* Copy configuration-specific GoogleServices-Info.plist */, + 13B07F8C1A680F5B00A75B9A /* Frameworks */, + 13B07F8E1A680F5B00A75B9A /* Resources */, + 00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */, + AC2794462C51756600F9AF68 /* Bundle and upload JS source maps for Datadog */, + F35AFD4627EE49990011A725 /* Embed App Extensions */, + 9F7898182A819D62004D5A98 /* Embed Frameworks */, + 163678CCBB906C7B12421609 /* [CP] Embed Pods Frameworks */, + 0487071ABBC71F28EF79F4AA /* [CP] Copy Pods Resources */, + 868B6279F959D6931E7A870E /* [CP-User] [RNFB] Core Configuration */, + ); + buildRules = ( + ); + dependencies = ( + F35AFD4127EE49990011A725 /* PBXTargetDependency */, + 072F6C302A44A32F00DA720A /* PBXTargetDependency */, + 078E794D2A55EB3300F59CF2 /* PBXTargetDependency */, + 9F7898172A819D62004D5A98 /* PBXTargetDependency */, + ); + name = Lux; + productName = Lux; + productReference = 13B07F961A680F5B00A75B9A /* Lux.app */; + productType = "com.apple.product-type.application"; + }; + F35AFD3A27EE49990011A725 /* OneSignalNotificationServiceExtension */ = { + isa = PBXNativeTarget; + buildConfigurationList = F35AFD4327EE49990011A725 /* Build configuration list for PBXNativeTarget "OneSignalNotificationServiceExtension" */; + buildPhases = ( + 49ACF6EF62101F01F694FBF6 /* [CP] Check Pods Manifest.lock */, + 204FD9212D843C9500736C13 /* Copy Env Vars to Swift */, + F35AFD3727EE49990011A725 /* Sources */, + F35AFD3827EE49990011A725 /* Frameworks */, + F35AFD3927EE49990011A725 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = OneSignalNotificationServiceExtension; + productName = OneSignalNotificationServiceExtension; + productReference = F35AFD3B27EE49990011A725 /* OneSignalNotificationServiceExtension.appex */; + productType = "com.apple.product-type.app-extension"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 83CBB9F71A601CBA00E9B192 /* Project object */ = { + isa = PBXProject; + attributes = { + LastSwiftUpdateCheck = 1420; + LastUpgradeCheck = 1210; + TargetAttributes = { + 00E356ED1AD99517003FC87E = { + CreatedOnToolsVersion = 6.2; + TestTargetID = 13B07F861A680F5B00A75B9A; + }; + 072E23852A44D5BC006AD6C9 = { + CreatedOnToolsVersion = 14.2; + LastSwiftMigration = 1420; + }; + 072E238C2A44D5BD006AD6C9 = { + CreatedOnToolsVersion = 14.2; + TestTargetID = 13B07F861A680F5B00A75B9A; + }; + 072F6C1E2A44A32E00DA720A = { + CreatedOnToolsVersion = 14.2; + }; + 078E79442A55EB3300F59CF2 = { + CreatedOnToolsVersion = 14.2; + }; + 13B07F861A680F5B00A75B9A = { + LastSwiftMigration = 1310; + }; + F35AFD3A27EE49990011A725 = { + CreatedOnToolsVersion = 13.2.1; + }; + }; + }; + buildConfigurationList = 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "Lux" */; + compatibilityVersion = "Xcode 12.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 83CBB9F61A601CBA00E9B192; + productRefGroup = 83CBBA001A601CBA00E9B192 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 13B07F861A680F5B00A75B9A /* Lux */, + 00E356ED1AD99517003FC87E /* LuxTests */, + F35AFD3A27EE49990011A725 /* OneSignalNotificationServiceExtension */, + 072F6C1E2A44A32E00DA720A /* Widgets */, + 072E23852A44D5BC006AD6C9 /* WidgetsCore */, + 072E238C2A44D5BD006AD6C9 /* WidgetsCoreTests */, + 078E79442A55EB3300F59CF2 /* WidgetIntentExtension */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 00E356EC1AD99517003FC87E /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 072E23842A44D5BC006AD6C9 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 072E238B2A44D5BD006AD6C9 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 072F6C1D2A44A32E00DA720A /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 072F6C2B2A44A32F00DA720A /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 078E79432A55EB3300F59CF2 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 13B07F8E1A680F5B00A75B9A /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + FD7304CE28A364FC0085BDEA /* Colors.xcassets in Resources */, + A32F9FBD272343C9002CFCDB /* GoogleService-Info.plist in Resources */, + 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */, + 77CF6065C8A24FE48204A2C1 /* SplashScreen.storyboard in Resources */, + AC0EE0982BD826E700BCCF07 /* PrivacyInfo.xcprivacy in Resources */, + D3B63ACA9B0C42F68080B080 /* InputMono-Regular.ttf in Resources */, + AC2EF4032C914B1600EEEFDB /* fonts in Resources */, + A3551F2CAC134AD49D40927F /* Basel-Grotesk-Book.otf in Resources */, + B193AD315CF844A3BDC3D11D /* Basel-Grotesk-Medium.otf in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + F35AFD3927EE49990011A725 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + FD54D51D296C79A4007A37E9 /* GoogleServiceInfo in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Bundle React Native code and images"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "if [[ -f \"$PODS_ROOT/../.xcode.env\" ]]; then\n source \"$PODS_ROOT/../.xcode.env\"\nfi\nif [[ -f \"$PODS_ROOT/../.xcode.env.local\" ]]; then\n source \"$PODS_ROOT/../.xcode.env.local\"\nfi\n\n# The project root by default is one level up from the ios directory\nexport PROJECT_ROOT=\"$PROJECT_DIR\"/..\n\nif [[ \"$CONFIGURATION\" = *Debug* ]]; then\n export SKIP_BUNDLING=1\nfi\nif [[ -z \"$ENTRY_FILE\" ]]; then\n # Set the entry JS file using the bundler's entry resolution.\n export ENTRY_FILE=\"$(\"$NODE_BINARY\" -e \"require('expo/scripts/resolveAppEntry')\" \"$PROJECT_ROOT\" ios absolute | tail -n 1)\"\nfi\n\nif [[ -z \"$CLI_PATH\" ]]; then\n # Use Expo CLI\n export CLI_PATH=\"$(\"$NODE_BINARY\" --print \"require.resolve('@expo/cli')\")\"\nfi\nif [[ -z \"$BUNDLE_COMMAND\" ]]; then\n # Default Expo CLI command for bundling\n export BUNDLE_COMMAND=\"export:embed\"\nfi\n\n`\"$NODE_BINARY\" --print \"require('path').dirname(require.resolve('react-native/package.json')) + '/scripts/react-native-xcode.sh'\"`\n"; + }; + 0487071ABBC71F28EF79F4AA /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Lux/Pods-Lux-resources-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Copy Pods Resources"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Lux/Pods-Lux-resources-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Lux/Pods-Lux-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; + 100379B32AFE3ED61809176D /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Lux-LuxTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 163678CCBB906C7B12421609 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Lux/Pods-Lux-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Lux/Pods-Lux-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Lux/Pods-Lux-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + 204FD9212D843C9500736C13 /* Copy Env Vars to Swift */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Copy Env Vars to Swift"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(SRCROOT)/OneSignalNotificationServiceExtension/Env.swift", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "cd ..\npython3 scripts/copy_env_vars_to_swift.py\necho \"OneSignalNotificationServiceExtension envs\"\nls -al ios/OneSignalNotificationServiceExtension\necho \"WidgetsCore envs\"\nls -al ios/WidgetsCore\n"; + }; + 204FD9222D843CEE00736C13 /* Copy Env Vars to Swift */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Copy Env Vars to Swift"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(SRCROOT)/WidgetsCore/Env.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Fragments/HomeScreenTokenParts.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Fragments/TokenBalanceMainParts.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Fragments/TokenBalanceParts.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Fragments/TokenBalanceQuantityParts.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Fragments/TokenBasicInfoParts.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Fragments/TokenBasicProjectParts.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Fragments/TokenFeeDataParts.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Fragments/TokenMarketParts.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Fragments/TokenParts.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Fragments/TokenProjectMarketsParts.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Fragments/TokenProjectUrlsParts.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Fragments/TokenProtectionInfoParts.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Fragments/TopTokenParts.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/MobileSchema.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Operations/Queries/ConvertQuery.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Operations/Queries/ExploreSearchQuery.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Operations/Queries/FavoriteTokenCardQuery.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Operations/Queries/FeedTransactionListQuery.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Operations/Queries/HomeScreenTokensQuery.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Operations/Queries/MultiplePortfolioBalancesQuery.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Operations/Queries/NFTItemScreenQuery.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Operations/Queries/NftCollectionScreenQuery.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Operations/Queries/NftsQuery.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Operations/Queries/NftsTabQuery.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Operations/Queries/PortfolioBalancesQuery.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Operations/Queries/SelectWalletScreenQuery.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Operations/Queries/TokenDetailsScreenQuery.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Operations/Queries/TokenPriceHistoryQuery.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Operations/Queries/TokenProjectDescriptionQuery.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Operations/Queries/TokenProjectsQuery.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Operations/Queries/TokenQuery.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Operations/Queries/TokensQuery.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Operations/Queries/TopTokensQuery.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Operations/Queries/TransactionHistoryUpdaterQuery.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Operations/Queries/TransactionListQuery.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Operations/Queries/WidgetTokensQuery.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Enums/Chain.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Enums/Currency.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Enums/HistoryDuration.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Enums/NftActivityType.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Enums/NftMarketplace.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Enums/NftStandard.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Enums/ProtectionAttackType.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Enums/ProtectionResult.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Enums/SafetyLevel.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Enums/SwapOrderStatus.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Enums/SwapOrderType.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Enums/TokenSortableField.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Enums/TokenStandard.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Enums/TransactionDirection.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Enums/TransactionStatus.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Enums/TransactionType.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/InputObjects/ContractInput.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/InputObjects/NftActivityFilterInput.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/InputObjects/NftAssetTraitInput.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/InputObjects/NftAssetsFilterInput.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/InputObjects/NftBalanceAssetInput.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/InputObjects/NftBalancesFilterInput.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/InputObjects/OnRampTransactionsAuth.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/InputObjects/PortfolioValueModifier.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Interfaces/IAmount.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Interfaces/IContract.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Objects/Amount.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Objects/AmountChange.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Objects/ApplicationContract.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Objects/AssetActivity.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Objects/BlockaidFees.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Objects/BridgedWithdrawalInfo.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Objects/DescriptionTranslations.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Objects/Dimensions.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Objects/FeeData.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Objects/Image.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Objects/NetworkFee.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Objects/NftActivity.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Objects/NftActivityConnection.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Objects/NftActivityEdge.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Objects/NftApproval.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Objects/NftApproveForAll.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Objects/NftAsset.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Objects/NftAssetConnection.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Objects/NftAssetEdge.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Objects/NftAssetTrait.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Objects/NftBalance.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Objects/NftBalanceConnection.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Objects/NftBalanceEdge.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Objects/NftCollection.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Objects/NftCollectionConnection.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Objects/NftCollectionEdge.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Objects/NftCollectionMarket.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Objects/NftContract.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Objects/NftOrder.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Objects/NftOrderConnection.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Objects/NftOrderEdge.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Objects/NftProfile.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Objects/NftTransfer.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Objects/OnRampServiceProvider.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Objects/OnRampTransactionDetails.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Objects/OnRampTransfer.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Objects/OffRampTransactionDetails.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Objects/OffRampTransfer.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Objects/PageInfo.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Objects/Portfolio.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Objects/ProtectionInfo.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Objects/Query.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Objects/SwapOrderDetails.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Objects/TimestampedAmount.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Objects/Token.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Objects/TokenApproval.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Objects/TokenBalance.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Objects/TokenMarket.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Objects/TokenProject.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Objects/TokenProjectMarket.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Objects/TokenTransfer.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Objects/TransactionDetails.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/SchemaConfiguration.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/SchemaMetadata.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Unions/ActivityDetails.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Unions/AssetChange.graphql.swift", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "cd ..\npython3 scripts/copy_env_vars_to_swift.py\n"; + }; + 204FD9232D84400E00736C13 /* [GraphQL] Apollo Generate Swift */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "[GraphQL] Apollo Generate Swift"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "# Remove all files inside MobileSchema except for README.md\nfind ./WidgetsCore/MobileSchema -mindepth 1 -maxdepth 1 ! -name 'README.md' -exec rm -rf {} +\n\"${PODS_ROOT}/Apollo/apollo-ios-cli\" generate\n"; + }; + 24AD8CEA045C82D94D2A96F3 /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Lux-LuxTests/Pods-Lux-LuxTests-resources-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Copy Pods Resources"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Lux-LuxTests/Pods-Lux-LuxTests-resources-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Lux-LuxTests/Pods-Lux-LuxTests-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; + 39755FBAA6CFA5CF80E716DB /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-WidgetIntentExtension-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 420594480338AF2E35636F6B /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-WidgetsCore-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 49ACF6EF62101F01F694FBF6 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-OneSignalNotificationServiceExtension-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 5930ECD30D77D41A3180451F /* [Expo] Configure project */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "[Expo] Configure project"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "# This script configures Expo modules and generates the modules provider file.\nbash -l -c \"./Pods/Target\\ Support\\ Files/Pods-Lux/expo-configure-project.sh\"\n"; + }; + 6236BA814A452C299F46F817 /* [Expo] Configure project */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "[Expo] Configure project"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "# This script configures Expo modules and generates the modules provider file.\nbash -l -c \"./Pods/Target\\ Support\\ Files/Pods-Lux-LuxTests/expo-configure-project.sh\"\n"; + }; + 683FB1CFA5FEF24043F65606 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Lux-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 7856E1172A714318AA224CFE /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Widgets-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 868B6279F959D6931E7A870E /* [CP-User] [RNFB] Core Configuration */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "$(BUILT_PRODUCTS_DIR)/$(INFOPLIST_PATH)", + ); + name = "[CP-User] [RNFB] Core Configuration"; + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "#!/usr/bin/env bash\n#\n# Copyright (c) 2016-present Invertase Limited & Contributors\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this library except in compliance with the License.\n# You may obtain a copy of the License at\n#\n# http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\n##########################################################################\n##########################################################################\n#\n# NOTE THAT IF YOU CHANGE THIS FILE YOU MUST RUN pod install AFTERWARDS\n#\n# This file is installed as an Xcode build script in the project file\n# by cocoapods, and you will not see your changes until you pod install\n#\n##########################################################################\n##########################################################################\n\nset -e\n\n_MAX_LOOKUPS=2;\n_SEARCH_RESULT=''\n_RN_ROOT_EXISTS=''\n_CURRENT_LOOKUPS=1\n_JSON_ROOT=\"'react-native'\"\n_JSON_FILE_NAME='firebase.json'\n_JSON_OUTPUT_BASE64='e30=' # { }\n_CURRENT_SEARCH_DIR=${PROJECT_DIR}\n_PLIST_BUDDY=/usr/libexec/PlistBuddy\n_TARGET_PLIST=\"${BUILT_PRODUCTS_DIR}/${INFOPLIST_PATH}\"\n_DSYM_PLIST=\"${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/Info.plist\"\n\n# plist arrays\n_PLIST_ENTRY_KEYS=()\n_PLIST_ENTRY_TYPES=()\n_PLIST_ENTRY_VALUES=()\n\nfunction setPlistValue {\n echo \"info: setting plist entry '$1' of type '$2' in file '$4'\"\n ${_PLIST_BUDDY} -c \"Add :$1 $2 '$3'\" $4 || echo \"info: '$1' already exists\"\n}\n\nfunction getFirebaseJsonKeyValue () {\n if [[ ${_RN_ROOT_EXISTS} ]]; then\n ruby -Ku -e \"require 'rubygems';require 'json'; output=JSON.parse('$1'); puts output[$_JSON_ROOT]['$2']\"\n else\n echo \"\"\n fi;\n}\n\nfunction jsonBoolToYesNo () {\n if [[ $1 == \"false\" ]]; then\n echo \"NO\"\n elif [[ $1 == \"true\" ]]; then\n echo \"YES\"\n else echo \"NO\"\n fi\n}\n\necho \"info: -> RNFB build script started\"\necho \"info: 1) Locating ${_JSON_FILE_NAME} file:\"\n\nif [[ -z ${_CURRENT_SEARCH_DIR} ]]; then\n _CURRENT_SEARCH_DIR=$(pwd)\nfi;\n\nwhile true; do\n _CURRENT_SEARCH_DIR=$(dirname \"$_CURRENT_SEARCH_DIR\")\n if [[ \"$_CURRENT_SEARCH_DIR\" == \"/\" ]] || [[ ${_CURRENT_LOOKUPS} -gt ${_MAX_LOOKUPS} ]]; then break; fi;\n echo \"info: ($_CURRENT_LOOKUPS of $_MAX_LOOKUPS) Searching in '$_CURRENT_SEARCH_DIR' for a ${_JSON_FILE_NAME} file.\"\n _SEARCH_RESULT=$(find \"$_CURRENT_SEARCH_DIR\" -maxdepth 2 -name ${_JSON_FILE_NAME} -print | /usr/bin/head -n 1)\n if [[ ${_SEARCH_RESULT} ]]; then\n echo \"info: ${_JSON_FILE_NAME} found at $_SEARCH_RESULT\"\n break;\n fi;\n _CURRENT_LOOKUPS=$((_CURRENT_LOOKUPS+1))\ndone\n\nif [[ ${_SEARCH_RESULT} ]]; then\n _JSON_OUTPUT_RAW=$(cat \"${_SEARCH_RESULT}\")\n _RN_ROOT_EXISTS=$(ruby -Ku -e \"require 'rubygems';require 'json'; output=JSON.parse('$_JSON_OUTPUT_RAW'); puts output[$_JSON_ROOT]\" || echo '')\n\n if [[ ${_RN_ROOT_EXISTS} ]]; then\n if ! python3 --version >/dev/null 2>&1; then echo \"python3 not found, firebase.json file processing error.\" && exit 1; fi\n _JSON_OUTPUT_BASE64=$(python3 -c 'import json,sys,base64;print(base64.b64encode(bytes(json.dumps(json.loads(open('\"'${_SEARCH_RESULT}'\"', '\"'rb'\"').read())['${_JSON_ROOT}']), '\"'utf-8'\"')).decode())' || echo \"e30=\")\n fi\n\n _PLIST_ENTRY_KEYS+=(\"firebase_json_raw\")\n _PLIST_ENTRY_TYPES+=(\"string\")\n _PLIST_ENTRY_VALUES+=(\"$_JSON_OUTPUT_BASE64\")\n\n # config.app_data_collection_default_enabled\n _APP_DATA_COLLECTION_ENABLED=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"app_data_collection_default_enabled\")\n if [[ $_APP_DATA_COLLECTION_ENABLED ]]; then\n _PLIST_ENTRY_KEYS+=(\"FirebaseDataCollectionDefaultEnabled\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_APP_DATA_COLLECTION_ENABLED\")\")\n fi\n\n # config.analytics_auto_collection_enabled\n _ANALYTICS_AUTO_COLLECTION=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"analytics_auto_collection_enabled\")\n if [[ $_ANALYTICS_AUTO_COLLECTION ]]; then\n _PLIST_ENTRY_KEYS+=(\"FIREBASE_ANALYTICS_COLLECTION_ENABLED\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_AUTO_COLLECTION\")\")\n fi\n\n # config.analytics_collection_deactivated\n _ANALYTICS_DEACTIVATED=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"analytics_collection_deactivated\")\n if [[ $_ANALYTICS_DEACTIVATED ]]; then\n _PLIST_ENTRY_KEYS+=(\"FIREBASE_ANALYTICS_COLLECTION_DEACTIVATED\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_DEACTIVATED\")\")\n fi\n\n # config.analytics_idfv_collection_enabled\n _ANALYTICS_IDFV_COLLECTION=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"analytics_idfv_collection_enabled\")\n if [[ $_ANALYTICS_IDFV_COLLECTION ]]; then\n _PLIST_ENTRY_KEYS+=(\"GOOGLE_ANALYTICS_IDFV_COLLECTION_ENABLED\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_IDFV_COLLECTION\")\")\n fi\n\n # config.analytics_default_allow_analytics_storage\n _ANALYTICS_STORAGE=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"analytics_default_allow_analytics_storage\")\n if [[ $_ANALYTICS_STORAGE ]]; then\n _PLIST_ENTRY_KEYS+=(\"GOOGLE_ANALYTICS_DEFAULT_ALLOW_ANALYTICS_STORAGE\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_STORAGE\")\")\n fi\n\n # config.analytics_default_allow_ad_storage\n _ANALYTICS_AD_STORAGE=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"analytics_default_allow_ad_storage\")\n if [[ $_ANALYTICS_AD_STORAGE ]]; then\n _PLIST_ENTRY_KEYS+=(\"GOOGLE_ANALYTICS_DEFAULT_ALLOW_AD_STORAGE\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_AD_STORAGE\")\")\n fi\n\n # config.analytics_default_allow_ad_user_data\n _ANALYTICS_AD_USER_DATA=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"analytics_default_allow_ad_user_data\")\n if [[ $_ANALYTICS_AD_USER_DATA ]]; then\n _PLIST_ENTRY_KEYS+=(\"GOOGLE_ANALYTICS_DEFAULT_ALLOW_AD_USER_DATA\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_AD_USER_DATA\")\")\n fi\n\n # config.analytics_default_allow_ad_personalization_signals\n _ANALYTICS_PERSONALIZATION=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"analytics_default_allow_ad_personalization_signals\")\n if [[ $_ANALYTICS_PERSONALIZATION ]]; then\n _PLIST_ENTRY_KEYS+=(\"GOOGLE_ANALYTICS_DEFAULT_ALLOW_AD_PERSONALIZATION_SIGNALS\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_PERSONALIZATION\")\")\n fi\n\n # config.analytics_registration_with_ad_network_enabled\n _ANALYTICS_REGISTRATION_WITH_AD_NETWORK=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"google_analytics_registration_with_ad_network_enabled\")\n if [[ $_ANALYTICS_REGISTRATION_WITH_AD_NETWORK ]]; then\n _PLIST_ENTRY_KEYS+=(\"GOOGLE_ANALYTICS_REGISTRATION_WITH_AD_NETWORK_ENABLED\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_REGISTRATION_WITH_AD_NETWORK\")\")\n fi\n\n # config.google_analytics_automatic_screen_reporting_enabled\n _ANALYTICS_AUTO_SCREEN_REPORTING=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"google_analytics_automatic_screen_reporting_enabled\")\n if [[ $_ANALYTICS_AUTO_SCREEN_REPORTING ]]; then\n _PLIST_ENTRY_KEYS+=(\"FirebaseAutomaticScreenReportingEnabled\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_AUTO_SCREEN_REPORTING\")\")\n fi\n\n # config.perf_auto_collection_enabled\n _PERF_AUTO_COLLECTION=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"perf_auto_collection_enabled\")\n if [[ $_PERF_AUTO_COLLECTION ]]; then\n _PLIST_ENTRY_KEYS+=(\"firebase_performance_collection_enabled\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_PERF_AUTO_COLLECTION\")\")\n fi\n\n # config.perf_collection_deactivated\n _PERF_DEACTIVATED=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"perf_collection_deactivated\")\n if [[ $_PERF_DEACTIVATED ]]; then\n _PLIST_ENTRY_KEYS+=(\"firebase_performance_collection_deactivated\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_PERF_DEACTIVATED\")\")\n fi\n\n # config.messaging_auto_init_enabled\n _MESSAGING_AUTO_INIT=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"messaging_auto_init_enabled\")\n if [[ $_MESSAGING_AUTO_INIT ]]; then\n _PLIST_ENTRY_KEYS+=(\"FirebaseMessagingAutoInitEnabled\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_MESSAGING_AUTO_INIT\")\")\n fi\n\n # config.in_app_messaging_auto_colllection_enabled\n _FIAM_AUTO_INIT=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"in_app_messaging_auto_collection_enabled\")\n if [[ $_FIAM_AUTO_INIT ]]; then\n _PLIST_ENTRY_KEYS+=(\"FirebaseInAppMessagingAutomaticDataCollectionEnabled\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_FIAM_AUTO_INIT\")\")\n fi\n\n # config.app_check_token_auto_refresh\n _APP_CHECK_TOKEN_AUTO_REFRESH=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"app_check_token_auto_refresh\")\n if [[ $_APP_CHECK_TOKEN_AUTO_REFRESH ]]; then\n _PLIST_ENTRY_KEYS+=(\"FirebaseAppCheckTokenAutoRefreshEnabled\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_APP_CHECK_TOKEN_AUTO_REFRESH\")\")\n fi\n\n # config.crashlytics_disable_auto_disabler - undocumented for now - mainly for debugging, document if becomes useful\n _CRASHLYTICS_AUTO_DISABLE_ENABLED=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"crashlytics_disable_auto_disabler\")\n if [[ $_CRASHLYTICS_AUTO_DISABLE_ENABLED == \"true\" ]]; then\n echo \"Disabled Crashlytics auto disabler.\" # do nothing\n else\n _PLIST_ENTRY_KEYS+=(\"FirebaseCrashlyticsCollectionEnabled\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"NO\")\n fi\nelse\n _PLIST_ENTRY_KEYS+=(\"firebase_json_raw\")\n _PLIST_ENTRY_TYPES+=(\"string\")\n _PLIST_ENTRY_VALUES+=(\"$_JSON_OUTPUT_BASE64\")\n echo \"warning: A firebase.json file was not found, whilst this file is optional it is recommended to include it to configure firebase services in React Native Firebase.\"\nfi;\n\necho \"info: 2) Injecting Info.plist entries: \"\n\n# Log out the keys we're adding\nfor i in \"${!_PLIST_ENTRY_KEYS[@]}\"; do\n echo \" -> $i) ${_PLIST_ENTRY_KEYS[$i]}\" \"${_PLIST_ENTRY_TYPES[$i]}\" \"${_PLIST_ENTRY_VALUES[$i]}\"\ndone\n\nfor plist in \"${_TARGET_PLIST}\" \"${_DSYM_PLIST}\" ; do\n if [[ -f \"${plist}\" ]]; then\n\n # paths with spaces break the call to setPlistValue. temporarily modify\n # the shell internal field separator variable (IFS), which normally\n # includes spaces, to consist only of line breaks\n oldifs=$IFS\n IFS=\"\n\"\n\n for i in \"${!_PLIST_ENTRY_KEYS[@]}\"; do\n setPlistValue \"${_PLIST_ENTRY_KEYS[$i]}\" \"${_PLIST_ENTRY_TYPES[$i]}\" \"${_PLIST_ENTRY_VALUES[$i]}\" \"${plist}\"\n done\n\n # restore the original internal field separator value\n IFS=$oldifs\n else\n echo \"warning: A Info.plist build output file was not found (${plist})\"\n fi\ndone\n\necho \"info: <- RNFB build script finished\"\n"; + }; + AC2794462C51756600F9AF68 /* Bundle and upload JS source maps for Datadog */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Bundle and upload JS source maps for Datadog"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "set -e\n\nWITH_ENVIRONMENT=\"../../../node_modules/react-native/scripts/xcode/with-environment.sh\"\nREACT_NATIVE_XCODE=\"./sourcemaps-datadog.sh\"\n\nexport SOURCEMAP_FILE=$DERIVED_FILE_DIR/main.jsbundle.map\n\nif [[ -n \"$DATADOG_API_KEY\" && \"$SKIP_DATADOG_UPLOAD\" != \"true\" ]]; then\n echo \"warning: Starting Datadog Uploads\"\n echo \"warning: Build Configuration: $CONFIGURATION\"\n echo \"warning: Product Name: $PRODUCT_NAME\"\n echo \"warning: Source Map File: $SOURCEMAP_FILE\"\n echo \"warning: dSYM Path: $DWARF_DSYM_FOLDER_PATH\"\n echo \"\"\n\n # JS source maps\n echo \"warning: Uploading JS source maps...\"\n \n /bin/sh -c \"$WITH_ENVIRONMENT $REACT_NATIVE_XCODE\"\n\n echo \"warning: \"\n\n # iOS dSYM\n echo \"warning: Uploading iOS dSYMs...\"\n echo \"warning: dSYM command: datadog-ci dsyms upload $DWARF_DSYM_FOLDER_PATH\"\n\n DSYM_LOG=$(mktemp)\n if ../../../node_modules/.bin/datadog-ci dsyms upload $DWARF_DSYM_FOLDER_PATH 2>&1 | tee \"$DSYM_LOG\"; then\n echo \"warning: dSYM upload completed successfully\"\n rm -f \"$DSYM_LOG\"\n else\n DSYM_EXIT_CODE=$?\n echo \"warning: \"\n echo \"warning: dSYM Upload Failed\"\n echo \"warning: Exit Code: $DSYM_EXIT_CODE\"\n echo \"warning: \"\n echo \"warning:Full Error Output:\"\n echo \"warning: ---\"\n echo \"warning: $(cat \"$DSYM_LOG\")\"\n echo \"warning: ---\"\n echo \"warning: \"\n echo \"warning: Debug Information:\"\n echo \"warning: - datadog-ci version: $(../../../node_modules/.bin/datadog-ci version 2>&1 || echo 'Failed to get version')\"\n echo \"warning: - dSYM folder exists: $([ -d \\\"$DWARF_DSYM_FOLDER_PATH\\\" ] && echo 'Yes' || echo 'No')\"\n echo \"warning: - dSYM contents: $(ls -la \\\"$DWARF_DSYM_FOLDER_PATH\\\" 2>&1 || echo 'Failed to list')\"\n echo \"warning: - DATADOG_API_KEY set: $([ -n \\\"$DATADOG_API_KEY\\\" ] && echo 'Yes (${#DATADOG_API_KEY} chars)' || echo 'No')\"\n echo \"warning: - Working directory: $(pwd)\"\n echo \"warning: \"\n echo \"warning: This is non-critical. Build will continue.\"\n rm -f \"$DSYM_LOG\"\n fi\n\n echo \"warning: \"\n echo \"warning: Datadog upload phase completed (build continues regardless of upload status)\"\nelse\n echo \"warning: Skipping Datadog upload (DATADOG_API_KEY not set or SKIP_DATADOG_UPLOAD=true)\"\nfi\n\n# Always exit 0 to not fail the build\nexit 0\n"; + }; + F5C7F44CBF58F052A43EB4AA /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Lux-LuxTests/Pods-Lux-LuxTests-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Lux-LuxTests/Pods-Lux-LuxTests-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Lux-LuxTests/Pods-Lux-LuxTests-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + F655443D83433C1BB79AB354 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-WidgetsCoreTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + FD10A7F022414F080027D42C /* Start Packager */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Start Packager"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "if [[ -f \"$PODS_ROOT/../.xcode.env\" ]]; then\n source \"$PODS_ROOT/../.xcode.env\"\nfi\nif [[ -f \"$PODS_ROOT/../.xcode.env.local\" ]]; then\n source \"$PODS_ROOT/../.xcode.env.local\"\nfi\n\nexport RCT_METRO_PORT=\"${RCT_METRO_PORT:=8081}\"\necho \"export RCT_METRO_PORT=${RCT_METRO_PORT}\" > `$NODE_BINARY --print \"require('path').dirname(require.resolve('react-native/package.json')) + '/scripts/.packager.env'\"`\nif [ -z \"${RCT_NO_LAUNCH_PACKAGER+xxx}\" ] ; then\n if nc -w 5 -z localhost ${RCT_METRO_PORT} ; then\n if ! curl -s \"http://localhost:${RCT_METRO_PORT}/status\" | grep -q \"packager-status:running\" ; then\n echo \"Port ${RCT_METRO_PORT} already in use, packager is either not running or not running correctly\"\n exit 2\n fi\n else\n open `$NODE_BINARY --print \"require('path').dirname(require.resolve('expo/package.json')) + '/scripts/launchPackager.command'\"` || echo \"Can't start packager automatically\"\n fi\nfi\n"; + showEnvVarsInLog = 0; + }; + FD54D51B296C780A007A37E9 /* Copy configuration-specific GoogleServices-Info.plist */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Copy configuration-specific GoogleServices-Info.plist"; + outputFileListPaths = ( + ); + outputPaths = ( + "", + "$(SRCROOT)/GoogleService-Info.plist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "# Type a script or drag a script file from your workspace to insert its path.\n\nif [ $CONFIGURATION == \"Release\" ]\nthen\n cp \"${SRCROOT}/GoogleServiceInfo/GoogleService-Info-Prod.plist\" \"${SRCROOT}/GoogleService-Info.plist\"\nelif [ $CONFIGURATION == 'Debug' ]\nthen\n cp \"${SRCROOT}/GoogleServiceInfo/GoogleService-Info-Dev.plist\" \"${SRCROOT}/GoogleService-Info.plist\"\nelse\n cp \"${SRCROOT}/GoogleServiceInfo/GoogleService-Info-$CONFIGURATION.plist\" \"${SRCROOT}/GoogleService-Info.plist\"\nfi\n"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 00E356EA1AD99517003FC87E /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 00E356F31AD99517003FC87E /* LuxTests.m in Sources */, + 6C8EFC2D2891B99100FBD8EB /* EncryptionHelperTests.swift in Sources */, + 8385A47D3C765B841F450090 /* ExpoModulesProvider.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 072E23822A44D5BC006AD6C9 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 074322342A83E3CA00F8518D /* SchemaConfiguration.swift in Sources */, + 0703EE032A5734A600AED1DA /* UserDefaults.swift in Sources */, + 074322402A841BBD00F8518D /* Constants.swift in Sources */, + 0703EE052A57351800AED1DA /* Logging.swift in Sources */, + 0767E03B2A65D2550042ADA2 /* Styling.swift in Sources */, + 07F0C28F2A5F3E2E00D5353E /* Env.swift in Sources */, + 07F136422A5763480067004F /* Network.swift in Sources */, + 0767E0382A65C8330042ADA2 /* Colors.swift in Sources */, + 07F5CF752A7020FD00C648A5 /* Format.swift in Sources */, + 0783F7B42A619E7C009ED617 /* UIComponents.swift in Sources */, + 07F5CF712A6AD97D00C648A5 /* Chart.swift in Sources */, + 074143402A588F5800A157D3 /* Structs.swift in Sources */, + 07F136402A575EC00067004F /* DataQueries.swift in Sources */, + CFA408C9A92DB1F808BA57CD /* MobileSchema.graphql.swift in Sources */, + 1E01B5593B54A53D822972C1 /* HomeScreenTokenParts.graphql.swift in Sources */, + 333C6D9C267BB7783F9CA56E /* TokenBalanceMainParts.graphql.swift in Sources */, + 4AA5D71F770AA5E8A5AB8D6A /* TokenBalanceParts.graphql.swift in Sources */, + 4ECB63464100262E5A163345 /* TokenBalanceQuantityParts.graphql.swift in Sources */, + 90B93D7970186C8FC83F9292 /* TokenBasicInfoParts.graphql.swift in Sources */, + 74D0262298BD1BE5363444B5 /* TokenBasicProjectParts.graphql.swift in Sources */, + 6FAEE2539C8AFB99445F29BE /* TokenFeeDataParts.graphql.swift in Sources */, + 3B27BBB18E152F19B68D6FAB /* TokenMarketParts.graphql.swift in Sources */, + 7154698A2773F05CAD639211 /* TokenParts.graphql.swift in Sources */, + 23FC1CDFE7B5E25CEDF338F2 /* TokenProjectMarketsParts.graphql.swift in Sources */, + D0AC45D21734567F720877AD /* TokenProjectUrlsParts.graphql.swift in Sources */, + 722E7F2ECE654320C2452CC8 /* TokenProtectionInfoParts.graphql.swift in Sources */, + CA4994AA5EF5F36F42117ECA /* TopTokenParts.graphql.swift in Sources */, + D6B69EFB74ACEF485C289266 /* ConvertQuery.graphql.swift in Sources */, + B83B63F900E565F5C3F11589 /* FavoriteTokenCardQuery.graphql.swift in Sources */, + 0781032B76A7F58B5776207B /* FeedTransactionListQuery.graphql.swift in Sources */, + 890E98060D98F2F5617D1914 /* HomeScreenTokensQuery.graphql.swift in Sources */, + 462433873C56042BDAA3D8E1 /* MultiplePortfolioBalancesQuery.graphql.swift in Sources */, + B2692B7521F4E7767DECE974 /* NftsQuery.graphql.swift in Sources */, + 12A7E560842C954098B3A558 /* NftsTabQuery.graphql.swift in Sources */, + 29BC0E8E68D0365EB77456AF /* PortfolioBalancesQuery.graphql.swift in Sources */, + 63E84504F6D89EFBF97F4ACF /* SelectWalletScreenQuery.graphql.swift in Sources */, + 554AB3FB0D09B2DBC870E044 /* TokenDetailsScreenQuery.graphql.swift in Sources */, + F36073C9BDFF611771D04683 /* TokenPriceHistoryQuery.graphql.swift in Sources */, + 72567EB83861EBC04FAA0941 /* TokenProjectDescriptionQuery.graphql.swift in Sources */, + 19AB4E8D176A5656BBB2D797 /* TokenProjectsQuery.graphql.swift in Sources */, + 038FC04C9385A04F3EA5EBF4 /* TokenQuery.graphql.swift in Sources */, + F3E5BB84916B1F496A0C740D /* TokensQuery.graphql.swift in Sources */, + 6B8E2BD6B9A12FF05F826BDB /* TopTokensQuery.graphql.swift in Sources */, + 50F87796A463D0224875F0F4 /* TransactionHistoryUpdaterQuery.graphql.swift in Sources */, + 5FED383DD705AEFE8B7DA8DC /* TransactionListQuery.graphql.swift in Sources */, + 99ED3ACCFE04709CFA7FD490 /* WidgetTokensQuery.graphql.swift in Sources */, + CBFF07DDFB9C638D6E383290 /* SchemaConfiguration.swift in Sources */, + 5A9BA87096096C8BDC5FF210 /* SchemaMetadata.graphql.swift in Sources */, + F6EA6445BF4E0DEEDD076C7A /* Chain.graphql.swift in Sources */, + 57D4FACDC13E20AD220D7AEC /* Currency.graphql.swift in Sources */, + 6602483D8F6C6481FB8128E9 /* HistoryDuration.graphql.swift in Sources */, + B8ADB9BF8BB2D4E456D80B23 /* NftStandard.graphql.swift in Sources */, + 914DFEC13BB1853D25D23E34 /* ProtectionAttackType.graphql.swift in Sources */, + E774B10E7FB502BE97D3895B /* ProtectionResult.graphql.swift in Sources */, + E6CC238CB9AF565DE89087B7 /* SafetyLevel.graphql.swift in Sources */, + F7B8B2FAEB30B3343CFF6F29 /* SwapOrderStatus.graphql.swift in Sources */, + 340A73A44EC57C9376FF24E9 /* SwapOrderType.graphql.swift in Sources */, + 5F14564B8F6814A85EF53C46 /* TokenSortableField.graphql.swift in Sources */, + 4D846E92BAEED7A4F5B72919 /* TokenStandard.graphql.swift in Sources */, + F5FD688A33BCADBF601A2D7F /* TransactionDirection.graphql.swift in Sources */, + B009D229E81544EA0F47C6DD /* TransactionStatus.graphql.swift in Sources */, + D86DB22D27B00DA71F968324 /* TransactionType.graphql.swift in Sources */, + B4F84A94618C5A8D4F12007E /* ContractInput.graphql.swift in Sources */, + 121CD8F92A5E382AD1A84AA9 /* NftBalanceAssetInput.graphql.swift in Sources */, + 2FF904EA65F2A7B960547609 /* NftBalancesFilterInput.graphql.swift in Sources */, + F8E54B79B2742008CF1C0AA9 /* OnRampTransactionsAuth.graphql.swift in Sources */, + A3EEE7EE3CC93DDBA3CE6A5C /* PortfolioValueModifier.graphql.swift in Sources */, + D1642682124F702EE2454A64 /* IAmount.graphql.swift in Sources */, + 6E2E4593B2C40DBBA7C861BF /* IContract.graphql.swift in Sources */, + FAB057109F187E5375D137FD /* Amount.graphql.swift in Sources */, + 530FBF9EB2190828460BD496 /* AmountChange.graphql.swift in Sources */, + D420E10F9BA8C789530E70F4 /* ApplicationContract.graphql.swift in Sources */, + 95AA27A2056B51265EE643F1 /* AssetActivity.graphql.swift in Sources */, + DEBB37600A7C5C9A273DA38E /* BlockaidFees.graphql.swift in Sources */, + C403639465231038D196D7B5 /* BridgedWithdrawalInfo.graphql.swift in Sources */, + D179B805D7A77EA127F41F13 /* DescriptionTranslations.graphql.swift in Sources */, + 939256080FE6141E53B49E90 /* Dimensions.graphql.swift in Sources */, + 2DE0A41ECCCE673BAE68F715 /* FeeData.graphql.swift in Sources */, + 213BE982C7E2CCE304B88A6E /* Image.graphql.swift in Sources */, + 75FA3F2F18047B759472AEA8 /* NetworkFee.graphql.swift in Sources */, + 8989D182DEC9682661D588F3 /* NftApproval.graphql.swift in Sources */, + ECB6546D9AA307163172BEA3 /* NftApproveForAll.graphql.swift in Sources */, + 1A4145B8CC5F5F5B9297B9D6 /* NftAsset.graphql.swift in Sources */, + 6682BC9B8E38430BE82FADDA /* NftBalance.graphql.swift in Sources */, + 1F9BD005762B8BFA302E1B0C /* NftBalanceConnection.graphql.swift in Sources */, + 2C7ED9DF09914F82D85DE7C9 /* NftBalanceEdge.graphql.swift in Sources */, + 72272D14F0DB24D05F1162FE /* NftCollection.graphql.swift in Sources */, + ADD898CA4B87F6E7C990E268 /* NftCollectionMarket.graphql.swift in Sources */, + 5CC5D4B276CC1E3BA1906120 /* NftContract.graphql.swift in Sources */, + F784F1CA9FCEB5FFE4C1DD62 /* NftProfile.graphql.swift in Sources */, + 1B9BE681A85AE55F18054F37 /* NftTransfer.graphql.swift in Sources */, + A0ACC9C3ABF174616E0CBCA4 /* OffRampTransactionDetails.graphql.swift in Sources */, + 1FB2F652907A72BD28CF6DD4 /* OffRampTransfer.graphql.swift in Sources */, + DFBC904E6C0B818152912819 /* OnRampServiceProvider.graphql.swift in Sources */, + 8D90B4306573344A1FFC4832 /* OnRampTransactionDetails.graphql.swift in Sources */, + 100A336AFEC40D106F257664 /* OnRampTransfer.graphql.swift in Sources */, + 77206ACF669BD9DAAC096227 /* PageInfo.graphql.swift in Sources */, + BD07375602C71B48961CD5A0 /* Portfolio.graphql.swift in Sources */, + 9F1AE0C43E80AE592CA4AD7E /* ProtectionInfo.graphql.swift in Sources */, + 44B612C45F986F8ACB66D366 /* Query.graphql.swift in Sources */, + 0DF7B81A4727A8CA063CD5B5 /* SwapOrderDetails.graphql.swift in Sources */, + 7B49698C356931577828B41E /* TimestampedAmount.graphql.swift in Sources */, + AEA0B1AC57BB6F11F5861BCE /* Token.graphql.swift in Sources */, + 5F581541EFD85236EF98D3F3 /* TokenApproval.graphql.swift in Sources */, + AF467A9F1C200706537B24E5 /* TokenBalance.graphql.swift in Sources */, + 37A47FF4EEEB8E9D839D5DD6 /* TokenMarket.graphql.swift in Sources */, + EEEE88236C7EBC4B67BBE858 /* TokenProject.graphql.swift in Sources */, + B5EF58A67FC42D684B96C0F0 /* TokenProjectMarket.graphql.swift in Sources */, + 498F01286C660E8241774216 /* TokenTransfer.graphql.swift in Sources */, + 0901AAD2DCDD615889B6680A /* TransactionDetails.graphql.swift in Sources */, + 8C19BAD465EA9DFEB20EFB24 /* ActivityDetails.graphql.swift in Sources */, + F7CA47B62C91F3B0DA4106C0 /* AssetChange.graphql.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 072E23892A44D5BD006AD6C9 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 074086FA2A703B76006E3053 /* FormatTests.swift in Sources */, + 072E23952A44D5BD006AD6C9 /* WidgetsCoreTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 072F6C1B2A44A32E00DA720A /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 072F6C262A44A32E00DA720A /* WidgetsBundle.swift in Sources */, + 072F6C282A44A32E00DA720A /* TokenPriceWidget.swift in Sources */, + 072F6C2D2A44A32F00DA720A /* TokenPriceWidget.intentdefinition in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 078E79412A55EB3300F59CF2 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 07378F492A5C83ED00D26D3E /* IntentHandler.swift in Sources */, + 0741433E2A588CCC00A157D3 /* TokenPriceWidget.intentdefinition in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 13B07F871A680F5B00A75B9A /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 8EA8AB3E2AB7ED3C004E7EF3 /* SeedPhraseInputManager.swift in Sources */, + 8EA8AB412AB7ED76004E7EF3 /* AlertTriangleIcon.swift in Sources */, + 8E89C3B22AB8AAA400C84DE5 /* MnemonicTextField.swift in Sources */, + FD7304D028A3650A0085BDEA /* Colors.swift in Sources */, + 8E89C3AF2AB8AAA400C84DE5 /* MnemonicDisplayView.swift in Sources */, + 45FFF7E12E8C2E6900362570 /* SilentPushEventEmitter.swift in Sources */, + 6BC7D0802B5FF02400617C95 /* EncryptionUtils.swift in Sources */, + 03C788232C10E7390011E5DC /* ActionButtons.swift in Sources */, + 8EA8AB3B2AB7ED3C004E7EF3 /* SeedPhraseInputManager.m in Sources */, + 03D2F3182C218D390030D987 /* RelativeOffsetView.swift in Sources */, + 45FFF7DF2E8C2A8100362570 /* SilentPushEventEmitter.m in Sources */, + 6CA91BDB2A95223C00C4063E /* RNEthersRS.swift in Sources */, + 8EA8AB3C2AB7ED3C004E7EF3 /* SeedPhraseInputViewModel.swift in Sources */, + 072F6C2E2A44A32F00DA720A /* TokenPriceWidget.intentdefinition in Sources */, + 8E89C3B12AB8AAA400C84DE5 /* MnemonicConfirmationWordBankView.swift in Sources */, + 07B0676D2A7D6EC8001DD9B9 /* RNWidgets.m in Sources */, + 8EBFB1552ABA6AA6006B32A8 /* PasteIcon.swift in Sources */, + 6BC7D07F2B5FF02400617C95 /* ScantasticEncryption.swift in Sources */, + 07B0676C2A7D6EC8001DD9B9 /* RNWidgets.swift in Sources */, + 8E89C3AE2AB8AAA400C84DE5 /* MnemonicConfirmationView.swift in Sources */, + 8B2A92172EB3E78E00990413 /* AppDelegate.swift in Sources */, + 5B4398EC2DD3B22C00F6BE08 /* PrivateKeyDisplayManager.m in Sources */, + 5B4398ED2DD3B22C00F6BE08 /* PrivateKeyDisplayManager.swift in Sources */, + 5B4398EE2DD3B22C00F6BE08 /* PrivateKeyDisplayView.swift in Sources */, + 8ED0562C2AA78E2C009BD5A2 /* ScrollFadeExtensions.swift in Sources */, + 037C5AAA2C04970B00B1D808 /* CopyIcon.swift in Sources */, + 6CA91BE22A95226200C4063E /* EncryptionHelper.swift in Sources */, + 649A7A782D9AE70B00B53589 /* KeychainUtils.swift in Sources */, + 649A7A792D9AE70B00B53589 /* KeychainConstants.swift in Sources */, + 9FCEBF002A95A8E00079EDDB /* RNWalletConnect.m in Sources */, + 6CA91BE32A95226200C4063E /* RNCloudStorageBackupsManager.swift in Sources */, + 9FCEBF042A95A99C0079EDDB /* RCTThemeModule.m in Sources */, + 9FCEBF012A95A8E00079EDDB /* RNWalletConnect.swift in Sources */, + 6CA91BDC2A95223C00C4063E /* RnEthersRS.m in Sources */, + 6CA91BE12A95226200C4063E /* RNCloudStorageBackupsManager.m in Sources */, + 6BC7D07E2B5FF02400617C95 /* ScantasticEncryption.m in Sources */, + A3F0A5B1272B1DFA00895B25 /* KeychainSwiftDistrib.swift in Sources */, + 0DB282272CDADB260014CF77 /* EmbeddedWallet.swift in Sources */, + 8E89C3B42AB8AAA400C84DE5 /* MnemonicConfirmationManager.m in Sources */, + 1440B371A1C9A42F3E91DAAE /* ExpoModulesProvider.swift in Sources */, + 8E89C3B32AB8AAA400C84DE5 /* MnemonicDisplayManager.m in Sources */, + 0DB282262CDADB260014CF77 /* EmbeddedWallet.m in Sources */, + 5B4CEC5F2DD65DD4009F082B /* CopyIconOutline.swift in Sources */, + 8EA8AB3D2AB7ED3C004E7EF3 /* SeedPhraseInputView.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + F35AFD3727EE49990011A725 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + F35AFD3E27EE49990011A725 /* NotificationService.swift in Sources */, + 5E5E0A632D380F5800E166AA /* Env.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 00E356F51AD99517003FC87E /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 13B07F861A680F5B00A75B9A /* Lux */; + targetProxy = 00E356F41AD99517003FC87E /* PBXContainerItemProxy */; + }; + 0703EE092A57355400AED1DA /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 072E23852A44D5BC006AD6C9 /* WidgetsCore */; + targetProxy = 0703EE082A57355400AED1DA /* PBXContainerItemProxy */; + }; + 072E23902A44D5BD006AD6C9 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 072E23852A44D5BC006AD6C9 /* WidgetsCore */; + targetProxy = 072E238F2A44D5BD006AD6C9 /* PBXContainerItemProxy */; + }; + 072E23922A44D5BD006AD6C9 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 13B07F861A680F5B00A75B9A /* Lux */; + targetProxy = 072E23912A44D5BD006AD6C9 /* PBXContainerItemProxy */; + }; + 072F6C302A44A32F00DA720A /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 072F6C1E2A44A32E00DA720A /* Widgets */; + targetProxy = 072F6C2F2A44A32F00DA720A /* PBXContainerItemProxy */; + }; + 078E794D2A55EB3300F59CF2 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 078E79442A55EB3300F59CF2 /* WidgetIntentExtension */; + targetProxy = 078E794C2A55EB3300F59CF2 /* PBXContainerItemProxy */; + }; + 9F7898062A819AA2004D5A98 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 072E23852A44D5BC006AD6C9 /* WidgetsCore */; + targetProxy = 9F7898052A819AA2004D5A98 /* PBXContainerItemProxy */; + }; + 9F7898172A819D62004D5A98 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 072E23852A44D5BC006AD6C9 /* WidgetsCore */; + targetProxy = 9F7898162A819D62004D5A98 /* PBXContainerItemProxy */; + }; + F35AFD4127EE49990011A725 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = F35AFD3A27EE49990011A725 /* OneSignalNotificationServiceExtension */; + targetProxy = F35AFD4027EE49990011A725 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + 00E356F61AD99517003FC87E /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 6F3DC921A65D749C0852B10C /* Pods-Lux-LuxTests.debug.xcconfig */; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = "$(inherited)"; + BUNDLE_LOADER = "$(TEST_HOST)"; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + INFOPLIST_FILE = LuxTests/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + OTHER_LDFLAGS = ( + "-ObjC", + "-lc++", + "$(inherited)", + ); + OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG"; + PRODUCT_BUNDLE_IDENTIFIER = "org.reactjs.native.example.$(PRODUCT_NAME:rfc1034identifier)"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Lux.app/Lux"; + }; + name = Debug; + }; + 00E356F71AD99517003FC87E /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = F1A3F4DDD7E40DA9E4BBAAD1 /* Pods-Lux-LuxTests.release.xcconfig */; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = "$(inherited)"; + BUNDLE_LOADER = "$(TEST_HOST)"; + COPY_PHASE_STRIP = NO; + INFOPLIST_FILE = LuxTests/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + OTHER_LDFLAGS = ( + "-ObjC", + "-lc++", + "$(inherited)", + ); + OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; + PRODUCT_BUNDLE_IDENTIFIER = "org.reactjs.native.example.$(PRODUCT_NAME:rfc1034identifier)"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Lux.app/Lux"; + }; + name = Release; + }; + 072E239C2A44D5BD006AD6C9 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = B0DA4D39B1A6D74A1D05B99F /* Pods-WidgetsCore.debug.xcconfig */; + buildSettings = { + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = dwarf; + DEFINES_MODULE = YES; + DEVELOPMENT_TEAM = JH3UHGZD75; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = i386; + GCC_C_LANGUAGE_STANDARD = gnu11; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MARKETING_VERSION = 1.69; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG"; + PRODUCT_BUNDLE_IDENTIFIER = schemes.WidgetsCore; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_INSTALL_OBJC_HEADER = YES; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + VERSIONING_SYSTEM = "apple-generic"; + VERSION_INFO_PREFIX = ""; + }; + name = Debug; + }; + 072E239D2A44D5BD006AD6C9 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 3E279F675B02CBC50D3B57D5 /* Pods-WidgetsCore.release.xcconfig */; + buildSettings = { + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_IDENTITY = "iPhone Distribution"; + CODE_SIGN_STYLE = Manual; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEFINES_MODULE = YES; + DEVELOPMENT_TEAM = JH3UHGZD75; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = i386; + GCC_C_LANGUAGE_STANDARD = gnu11; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MARKETING_VERSION = 1.69; + MTL_FAST_MATH = YES; + OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; + PRODUCT_BUNDLE_IDENTIFIER = schemes.WidgetsCore; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_INSTALL_OBJC_HEADER = YES; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + VERSIONING_SYSTEM = "apple-generic"; + VERSION_INFO_PREFIX = ""; + }; + name = Release; + }; + 072E239E2A44D5BD006AD6C9 /* Dev */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = BCB2A43E5FB0D7B69CA02312 /* Pods-WidgetsCore.dev.xcconfig */; + buildSettings = { + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_IDENTITY = "iPhone Distribution"; + CODE_SIGN_STYLE = Manual; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEFINES_MODULE = YES; + DEVELOPMENT_TEAM = JH3UHGZD75; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = i386; + GCC_C_LANGUAGE_STANDARD = gnu11; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MARKETING_VERSION = 1.69; + MTL_FAST_MATH = YES; + OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; + PRODUCT_BUNDLE_IDENTIFIER = schemes.WidgetsCore; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_INSTALL_OBJC_HEADER = YES; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + VERSIONING_SYSTEM = "apple-generic"; + VERSION_INFO_PREFIX = ""; + }; + name = Dev; + }; + 072E239F2A44D5BD006AD6C9 /* Beta */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 0B7E5D62E11408EB5F0F5A80 /* Pods-WidgetsCore.beta.xcconfig */; + buildSettings = { + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_IDENTITY = "iPhone Distribution"; + CODE_SIGN_STYLE = Manual; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEFINES_MODULE = YES; + DEVELOPMENT_TEAM = JH3UHGZD75; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = i386; + GCC_C_LANGUAGE_STANDARD = gnu11; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MARKETING_VERSION = 1.69; + MTL_FAST_MATH = YES; + OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; + PRODUCT_BUNDLE_IDENTIFIER = schemes.WidgetsCore; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_INSTALL_OBJC_HEADER = YES; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + VERSIONING_SYSTEM = "apple-generic"; + VERSION_INFO_PREFIX = ""; + }; + name = Beta; + }; + 072E23A02A44D5BD006AD6C9 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 3D8FCE4CD401350CA74DCC89 /* Pods-WidgetsCoreTests.debug.xcconfig */; + buildSettings = { + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = JH3UHGZD75; + GCC_C_LANGUAGE_STANDARD = gnu11; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + MARKETING_VERSION = 1.69; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG"; + PRODUCT_BUNDLE_IDENTIFIER = schemes.WidgetsCoreTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Lux.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Lux"; + }; + name = Debug; + }; + 072E23A12A44D5BD006AD6C9 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = C89238E3ED9F3AC98876B573 /* Pods-WidgetsCoreTests.release.xcconfig */; + buildSettings = { + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = JH3UHGZD75; + GCC_C_LANGUAGE_STANDARD = gnu11; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + MARKETING_VERSION = 1.69; + MTL_FAST_MATH = YES; + OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; + PRODUCT_BUNDLE_IDENTIFIER = schemes.WidgetsCoreTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Lux.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Lux"; + }; + name = Release; + }; + 072E23A22A44D5BD006AD6C9 /* Dev */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 065A981F892F7A06A900FCD5 /* Pods-WidgetsCoreTests.dev.xcconfig */; + buildSettings = { + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = JH3UHGZD75; + GCC_C_LANGUAGE_STANDARD = gnu11; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + MARKETING_VERSION = 1.69; + MTL_FAST_MATH = YES; + OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; + PRODUCT_BUNDLE_IDENTIFIER = schemes.WidgetsCoreTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Lux.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Lux"; + }; + name = Dev; + }; + 072E23A32A44D5BD006AD6C9 /* Beta */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 4C445DB9798210862C34D0E0 /* Pods-WidgetsCoreTests.beta.xcconfig */; + buildSettings = { + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = JH3UHGZD75; + GCC_C_LANGUAGE_STANDARD = gnu11; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + MARKETING_VERSION = 1.69; + MTL_FAST_MATH = YES; + OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; + PRODUCT_BUNDLE_IDENTIFIER = schemes.WidgetsCoreTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Lux.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Lux"; + }; + name = Beta; + }; + 072F6C322A44A32F00DA720A /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = D79B717BEAEA7857469D770A /* Pods-Widgets.debug.xcconfig */; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = Widgets/Widgets.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = JH3UHGZD75; + GCC_C_LANGUAGE_STANDARD = gnu11; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Widgets/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = Widgets; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.69; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG"; + PRODUCT_BUNDLE_IDENTIFIER = com.lux.mobile.dev.widgets; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Debug; + }; + 072F6C332A44A32F00DA720A /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 82C9871585F60F92D079FB95 /* Pods-Widgets.release.xcconfig */; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = Widgets/Widgets.entitlements; + CODE_SIGN_IDENTITY = "iPhone Distribution"; + CODE_SIGN_STYLE = Manual; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = JH3UHGZD75; + GCC_C_LANGUAGE_STANDARD = gnu11; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Widgets/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = Widgets; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.69; + MTL_FAST_MATH = YES; + OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; + PRODUCT_BUNDLE_IDENTIFIER = com.lux.mobile.widgets; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = "match AppStore com.lux.mobile.widgets"; + SKIP_INSTALL = YES; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Release; + }; + 072F6C342A44A32F00DA720A /* Dev */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 08EBF075A4482F701892270B /* Pods-Widgets.dev.xcconfig */; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = Widgets/Widgets.entitlements; + CODE_SIGN_IDENTITY = "iPhone Distribution"; + CODE_SIGN_STYLE = Manual; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = JH3UHGZD75; + GCC_C_LANGUAGE_STANDARD = gnu11; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Widgets/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = Widgets; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.69; + MTL_FAST_MATH = YES; + OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; + PRODUCT_BUNDLE_IDENTIFIER = com.lux.mobile.dev.widgets; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = "match AppStore com.lux.mobile.dev.widgets"; + SKIP_INSTALL = YES; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Dev; + }; + 072F6C352A44A32F00DA720A /* Beta */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 0C19DE44A750FB17647FF2B6 /* Pods-Widgets.beta.xcconfig */; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = Widgets/Widgets.entitlements; + CODE_SIGN_IDENTITY = "iPhone Distribution"; + CODE_SIGN_STYLE = Manual; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = JH3UHGZD75; + GCC_C_LANGUAGE_STANDARD = gnu11; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Widgets/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = Widgets; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.69; + MTL_FAST_MATH = YES; + OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; + PRODUCT_BUNDLE_IDENTIFIER = com.lux.mobile.beta.widgets; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = "match AppStore com.lux.mobile.beta.widgets"; + SKIP_INSTALL = YES; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Beta; + }; + 078E79502A55EB3400F59CF2 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 8719E5872CC41AB64503E903 /* Pods-WidgetIntentExtension.debug.xcconfig */; + buildSettings = { + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = WidgetIntentExtension/WidgetIntentExtension.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = JH3UHGZD75; + GCC_C_LANGUAGE_STANDARD = gnu11; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = WidgetIntentExtension/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = WidgetIntentExtension; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.69; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG"; + PRODUCT_BUNDLE_IDENTIFIER = com.lux.mobile.dev.WidgetIntentExtension; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Debug; + }; + 078E79512A55EB3400F59CF2 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 08C60D53AB82A6D0D31D0F78 /* Pods-WidgetIntentExtension.release.xcconfig */; + buildSettings = { + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = WidgetIntentExtension/WidgetIntentExtension.entitlements; + CODE_SIGN_IDENTITY = "iPhone Distribution"; + CODE_SIGN_STYLE = Manual; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = JH3UHGZD75; + GCC_C_LANGUAGE_STANDARD = gnu11; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = WidgetIntentExtension/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = WidgetIntentExtension; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.69; + MTL_FAST_MATH = YES; + OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; + PRODUCT_BUNDLE_IDENTIFIER = com.lux.mobile.WidgetIntentExtension; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = "match AppStore com.lux.mobile.WidgetIntentExtension"; + SKIP_INSTALL = YES; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Release; + }; + 078E79522A55EB3400F59CF2 /* Dev */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 1CC6ADAADCA38FDAEB181E86 /* Pods-WidgetIntentExtension.dev.xcconfig */; + buildSettings = { + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = WidgetIntentExtension/WidgetIntentExtension.entitlements; + CODE_SIGN_IDENTITY = "iPhone Distribution"; + CODE_SIGN_STYLE = Manual; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = JH3UHGZD75; + GCC_C_LANGUAGE_STANDARD = gnu11; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = WidgetIntentExtension/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = WidgetIntentExtension; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.69; + MTL_FAST_MATH = YES; + OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; + PRODUCT_BUNDLE_IDENTIFIER = com.lux.mobile.dev.WidgetIntentExtension; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = "match AppStore com.lux.mobile.dev.WidgetIntentExtension"; + SKIP_INSTALL = YES; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Dev; + }; + 078E79532A55EB3400F59CF2 /* Beta */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7A7637BBC9B3A68E0338D96E /* Pods-WidgetIntentExtension.beta.xcconfig */; + buildSettings = { + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = WidgetIntentExtension/WidgetIntentExtension.entitlements; + CODE_SIGN_IDENTITY = "iPhone Distribution"; + CODE_SIGN_STYLE = Manual; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = JH3UHGZD75; + GCC_C_LANGUAGE_STANDARD = gnu11; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = WidgetIntentExtension/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = WidgetIntentExtension; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.69; + MTL_FAST_MATH = YES; + OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; + PRODUCT_BUNDLE_IDENTIFIER = com.lux.mobile.beta.WidgetIntentExtension; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = "match AppStore com.lux.mobile.beta.WidgetIntentExtension"; + SKIP_INSTALL = YES; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Beta; + }; + 13B07F941A680F5B00A75B9A /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = A7C9F415D0E128A43003E071 /* Pods-Lux.debug.xcconfig */; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = "$(inherited)"; + ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon$(BUNDLE_ID_SUFFIX)"; + ASSETCATALOG_COMPILER_LAUNCHIMAGE_NAME = ""; + BUNDLE_ID_SUFFIX = .dev; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Lux/Lux.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = JH3UHGZD75; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Lux/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.69; + OTHER_LDFLAGS = ( + "$(inherited)", + "-ObjC", + "-lc++", + ); + OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG -Xcc -fmodule-map-file=/Users/z/work/lux/exchange/apps/mobile/ios/Pods/Argon2Swift/Sources/Modules/module.modulemap"; + PRODUCT_BUNDLE_IDENTIFIER = com.lux.mobile.dev; + PRODUCT_NAME = Lux; + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OBJC_BRIDGING_HEADER = "Lux/RNEthersRs/RNEthersRS-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 13B07F951A680F5B00A75B9A /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 178644A78AB62609EFDB66B3 /* Pods-Lux.release.xcconfig */; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = "$(inherited)"; + ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon$(BUNDLE_ID_SUFFIX)"; + ASSETCATALOG_COMPILER_LAUNCHIMAGE_NAME = ""; + BUNDLE_ID_SUFFIX = ""; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Lux/Lux.entitlements; + CODE_SIGN_IDENTITY = "iPhone Distribution"; + CODE_SIGN_STYLE = Manual; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = JH3UHGZD75; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Lux/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.69; + OTHER_LDFLAGS = ( + "$(inherited)", + "-ObjC", + "-lc++", + ); + OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE -Xcc -fmodule-map-file=/Users/z/work/lux/exchange/apps/mobile/ios/Pods/Argon2Swift/Sources/Modules/module.modulemap"; + PRODUCT_BUNDLE_IDENTIFIER = com.lux.mobile; + PRODUCT_NAME = Lux; + PROVISIONING_PROFILE_SPECIFIER = "match AppStore com.lux.mobile"; + SWIFT_OBJC_BRIDGING_HEADER = "Lux/RNEthersRs/RNEthersRS-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; + 83CBBA201A601CBA00E9B192 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; + CLANG_CXX_LANGUAGE_STANDARD = "c++20"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEVELOPMENT_TEAM = CGCYLJG7GA; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = i386; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_SYMBOLS_PRIVATE_EXTERN = NO; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + HEADER_SEARCH_PATHS = ( + "$(inherited)", + "${PODS_CONFIGURATION_BUILD_DIR}/ReactCommon/ReactCommon.framework/Headers", + "${PODS_CONFIGURATION_BUILD_DIR}/ReactCommon/ReactCommon.framework/Headers/react/nativemodule/core", + "${PODS_CONFIGURATION_BUILD_DIR}/ReactCommon-Samples/ReactCommon_Samples.framework/Headers", + "${PODS_CONFIGURATION_BUILD_DIR}/ReactCommon-Samples/ReactCommon_Samples.framework/Headers/platform/ios", + "${PODS_CONFIGURATION_BUILD_DIR}/React-Fabric/React_Fabric.framework/Headers/react/renderer/components/view/platform/cxx", + "${PODS_CONFIGURATION_BUILD_DIR}/React-NativeModulesApple/React_NativeModulesApple.framework/Headers", + "${PODS_CONFIGURATION_BUILD_DIR}/React-graphics/React_graphics.framework/Headers", + "${PODS_CONFIGURATION_BUILD_DIR}/React-graphics/React_graphics.framework/Headers/react/renderer/graphics/platform/ios", + ); + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + LD_RUNPATH_SEARCH_PATHS = ( + /usr/lib/swift, + "$(inherited)", + ); + LIBRARY_SEARCH_PATHS = ( + "$(SDKROOT)/usr/lib/swift", + "\"$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)\"", + "\"$(inherited)\"", + ); + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + OTHER_CFLAGS = "$(inherited)"; + OTHER_CPLUSPLUSFLAGS = "$(inherited)"; + OTHER_LDFLAGS = "$(inherited)"; + REACT_NATIVE_PATH = "${PODS_ROOT}/../../../../node_modules/react-native"; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG"; + SWIFT_VERSION = 5.0; + USE_HERMES = true; + }; + name = Debug; + }; + 83CBBA211A601CBA00E9B192 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; + CLANG_CXX_LANGUAGE_STANDARD = "c++20"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = YES; + DEVELOPMENT_TEAM = CGCYLJG7GA; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = i386; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + HEADER_SEARCH_PATHS = ( + "$(inherited)", + "${PODS_CONFIGURATION_BUILD_DIR}/ReactCommon/ReactCommon.framework/Headers", + "${PODS_CONFIGURATION_BUILD_DIR}/ReactCommon/ReactCommon.framework/Headers/react/nativemodule/core", + "${PODS_CONFIGURATION_BUILD_DIR}/ReactCommon-Samples/ReactCommon_Samples.framework/Headers", + "${PODS_CONFIGURATION_BUILD_DIR}/ReactCommon-Samples/ReactCommon_Samples.framework/Headers/platform/ios", + "${PODS_CONFIGURATION_BUILD_DIR}/React-Fabric/React_Fabric.framework/Headers/react/renderer/components/view/platform/cxx", + "${PODS_CONFIGURATION_BUILD_DIR}/React-NativeModulesApple/React_NativeModulesApple.framework/Headers", + "${PODS_CONFIGURATION_BUILD_DIR}/React-graphics/React_graphics.framework/Headers", + "${PODS_CONFIGURATION_BUILD_DIR}/React-graphics/React_graphics.framework/Headers/react/renderer/graphics/platform/ios", + ); + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + LD_RUNPATH_SEARCH_PATHS = ( + /usr/lib/swift, + "$(inherited)", + ); + LIBRARY_SEARCH_PATHS = ( + "$(SDKROOT)/usr/lib/swift", + "\"$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)\"", + "\"$(inherited)\"", + ); + MTL_ENABLE_DEBUG_INFO = NO; + OTHER_CFLAGS = "$(inherited)"; + OTHER_CPLUSPLUSFLAGS = "$(inherited)"; + OTHER_LDFLAGS = "$(inherited)"; + REACT_NATIVE_PATH = "${PODS_ROOT}/../../../../node_modules/react-native"; + SDKROOT = iphoneos; + SWIFT_VERSION = 5.0; + USE_HERMES = true; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + F35AFD4427EE49990011A725 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 3A2186B1FF7FB85663D96EA9 /* Pods-OneSignalNotificationServiceExtension.debug.xcconfig */; + buildSettings = { + BUNDLE_ID_SUFFIX = .dev; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = OneSignalNotificationServiceExtension/OneSignalNotificationServiceExtension.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = JH3UHGZD75; + GCC_C_LANGUAGE_STANDARD = gnu11; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = OneSignalNotificationServiceExtension/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = OneSignalNotificationServiceExtension; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.69; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG"; + PRODUCT_BUNDLE_IDENTIFIER = com.lux.mobile.dev.OneSignalNotificationServiceExtension; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SKIP_INSTALL = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Debug; + }; + F35AFD4527EE49990011A725 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 1193B3A845BC3BE8CAA00D01 /* Pods-OneSignalNotificationServiceExtension.release.xcconfig */; + buildSettings = { + BUNDLE_ID_SUFFIX = ""; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = OneSignalNotificationServiceExtension/OneSignalNotificationServiceExtension.entitlements; + CODE_SIGN_IDENTITY = "iPhone Distribution"; + CODE_SIGN_STYLE = Manual; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = JH3UHGZD75; + GCC_C_LANGUAGE_STANDARD = gnu11; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = OneSignalNotificationServiceExtension/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = OneSignalNotificationServiceExtension; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.69; + MTL_FAST_MATH = YES; + OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; + PRODUCT_BUNDLE_IDENTIFIER = com.lux.mobile.OneSignalNotificationServiceExtension; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = "match AppStore com.lux.mobile.OneSignalNotificationServiceExtension"; + SKIP_INSTALL = YES; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Release; + }; + FDB6FD3C294D3A6E00C7B822 /* Beta */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; + CLANG_CXX_LANGUAGE_STANDARD = "c++20"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = YES; + DEVELOPMENT_TEAM = CGCYLJG7GA; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = i386; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + HEADER_SEARCH_PATHS = ( + "$(inherited)", + "${PODS_CONFIGURATION_BUILD_DIR}/ReactCommon/ReactCommon.framework/Headers", + "${PODS_CONFIGURATION_BUILD_DIR}/ReactCommon/ReactCommon.framework/Headers/react/nativemodule/core", + "${PODS_CONFIGURATION_BUILD_DIR}/ReactCommon-Samples/ReactCommon_Samples.framework/Headers", + "${PODS_CONFIGURATION_BUILD_DIR}/ReactCommon-Samples/ReactCommon_Samples.framework/Headers/platform/ios", + "${PODS_CONFIGURATION_BUILD_DIR}/React-Fabric/React_Fabric.framework/Headers/react/renderer/components/view/platform/cxx", + "${PODS_CONFIGURATION_BUILD_DIR}/React-NativeModulesApple/React_NativeModulesApple.framework/Headers", + "${PODS_CONFIGURATION_BUILD_DIR}/React-graphics/React_graphics.framework/Headers", + "${PODS_CONFIGURATION_BUILD_DIR}/React-graphics/React_graphics.framework/Headers/react/renderer/graphics/platform/ios", + ); + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + LD_RUNPATH_SEARCH_PATHS = ( + /usr/lib/swift, + "$(inherited)", + ); + LIBRARY_SEARCH_PATHS = ( + "$(SDKROOT)/usr/lib/swift", + "\"$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)\"", + "\"$(inherited)\"", + ); + MTL_ENABLE_DEBUG_INFO = NO; + OTHER_CFLAGS = "$(inherited)"; + OTHER_CPLUSPLUSFLAGS = "$(inherited)"; + OTHER_LDFLAGS = "$(inherited)"; + REACT_NATIVE_PATH = "${PODS_ROOT}/../../../../node_modules/react-native"; + SDKROOT = iphoneos; + SWIFT_VERSION = 5.0; + USE_HERMES = true; + VALIDATE_PRODUCT = YES; + }; + name = Beta; + }; + FDB6FD3D294D3A6E00C7B822 /* Beta */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 62CEA9F2D5176D20A6402A3E /* Pods-Lux.beta.xcconfig */; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = "$(inherited)"; + ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon$(BUNDLE_ID_SUFFIX)"; + ASSETCATALOG_COMPILER_LAUNCHIMAGE_NAME = ""; + BUNDLE_ID_SUFFIX = .beta; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Lux/Lux.entitlements; + CODE_SIGN_IDENTITY = "iPhone Distribution"; + CODE_SIGN_STYLE = Manual; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = JH3UHGZD75; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Lux/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.69; + OTHER_LDFLAGS = ( + "$(inherited)", + "-ObjC", + "-lc++", + ); + OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE -Xcc -fmodule-map-file=/Users/z/work/lux/exchange/apps/mobile/ios/Pods/Argon2Swift/Sources/Modules/module.modulemap"; + PRODUCT_BUNDLE_IDENTIFIER = com.lux.mobile.beta; + PRODUCT_NAME = Lux; + PROVISIONING_PROFILE_SPECIFIER = "match AppStore com.lux.mobile.beta"; + SWIFT_OBJC_BRIDGING_HEADER = "Lux/RNEthersRs/RNEthersRS-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Beta; + }; + FDB6FD3E294D3A6E00C7B822 /* Beta */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 4781CD4CDD95B5792B793F75 /* Pods-Lux-LuxTests.beta.xcconfig */; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = "$(inherited)"; + BUNDLE_LOADER = "$(TEST_HOST)"; + COPY_PHASE_STRIP = NO; + INFOPLIST_FILE = LuxTests/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + OTHER_LDFLAGS = ( + "-ObjC", + "-lc++", + "$(inherited)", + ); + OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; + PRODUCT_BUNDLE_IDENTIFIER = "org.reactjs.native.example.$(PRODUCT_NAME:rfc1034identifier)"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Lux.app/Lux"; + }; + name = Beta; + }; + FDB6FD3F294D3A6E00C7B822 /* Beta */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = F56CC08FBB20FAC0DF6B93DA /* Pods-OneSignalNotificationServiceExtension.beta.xcconfig */; + buildSettings = { + BUNDLE_ID_SUFFIX = .beta; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = OneSignalNotificationServiceExtension/OneSignalNotificationServiceExtension.entitlements; + CODE_SIGN_IDENTITY = "iPhone Distribution"; + CODE_SIGN_STYLE = Manual; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = JH3UHGZD75; + GCC_C_LANGUAGE_STANDARD = gnu11; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = OneSignalNotificationServiceExtension/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = OneSignalNotificationServiceExtension; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.69; + MTL_FAST_MATH = YES; + OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; + PRODUCT_BUNDLE_IDENTIFIER = com.lux.mobile.beta.OneSignalNotificationServiceExtension; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = "match AppStore com.lux.mobile.beta.OneSignalNotificationServiceExtension"; + SKIP_INSTALL = YES; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Beta; + }; + FDB6FD40294D3A8200C7B822 /* Dev */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; + CLANG_CXX_LANGUAGE_STANDARD = "c++20"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = YES; + DEVELOPMENT_TEAM = CGCYLJG7GA; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = i386; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + HEADER_SEARCH_PATHS = ( + "$(inherited)", + "${PODS_CONFIGURATION_BUILD_DIR}/ReactCommon/ReactCommon.framework/Headers", + "${PODS_CONFIGURATION_BUILD_DIR}/ReactCommon/ReactCommon.framework/Headers/react/nativemodule/core", + "${PODS_CONFIGURATION_BUILD_DIR}/ReactCommon-Samples/ReactCommon_Samples.framework/Headers", + "${PODS_CONFIGURATION_BUILD_DIR}/ReactCommon-Samples/ReactCommon_Samples.framework/Headers/platform/ios", + "${PODS_CONFIGURATION_BUILD_DIR}/React-Fabric/React_Fabric.framework/Headers/react/renderer/components/view/platform/cxx", + "${PODS_CONFIGURATION_BUILD_DIR}/React-NativeModulesApple/React_NativeModulesApple.framework/Headers", + "${PODS_CONFIGURATION_BUILD_DIR}/React-graphics/React_graphics.framework/Headers", + "${PODS_CONFIGURATION_BUILD_DIR}/React-graphics/React_graphics.framework/Headers/react/renderer/graphics/platform/ios", + ); + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + LD_RUNPATH_SEARCH_PATHS = ( + /usr/lib/swift, + "$(inherited)", + ); + LIBRARY_SEARCH_PATHS = ( + "$(SDKROOT)/usr/lib/swift", + "\"$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)\"", + "\"$(inherited)\"", + ); + MTL_ENABLE_DEBUG_INFO = NO; + OTHER_CFLAGS = "$(inherited)"; + OTHER_CPLUSPLUSFLAGS = "$(inherited)"; + OTHER_LDFLAGS = "$(inherited)"; + REACT_NATIVE_PATH = "${PODS_ROOT}/../../../../node_modules/react-native"; + SDKROOT = iphoneos; + SWIFT_VERSION = 5.0; + USE_HERMES = true; + VALIDATE_PRODUCT = YES; + }; + name = Dev; + }; + FDB6FD41294D3A8200C7B822 /* Dev */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 56FE9C9AF785221B7E3F4C04 /* Pods-Lux.dev.xcconfig */; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = "$(inherited)"; + ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon$(BUNDLE_ID_SUFFIX)"; + ASSETCATALOG_COMPILER_LAUNCHIMAGE_NAME = ""; + BUNDLE_ID_SUFFIX = .dev; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Lux/Lux.entitlements; + CODE_SIGN_IDENTITY = "iPhone Distribution"; + CODE_SIGN_STYLE = Manual; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = JH3UHGZD75; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Lux/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.69; + OTHER_LDFLAGS = ( + "$(inherited)", + "-ObjC", + "-lc++", + ); + OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE -Xcc -fmodule-map-file=/Users/z/work/lux/exchange/apps/mobile/ios/Pods/Argon2Swift/Sources/Modules/module.modulemap"; + PRODUCT_BUNDLE_IDENTIFIER = com.lux.mobile.dev; + PRODUCT_NAME = Lux; + PROVISIONING_PROFILE_SPECIFIER = "match AppStore com.lux.mobile.dev"; + SWIFT_OBJC_BRIDGING_HEADER = "Lux/RNEthersRs/RNEthersRS-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Dev; + }; + FDB6FD42294D3A8200C7B822 /* Dev */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 0712B3629C74D1F958DF35FB /* Pods-Lux-LuxTests.dev.xcconfig */; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = "$(inherited)"; + BUNDLE_LOADER = "$(TEST_HOST)"; + COPY_PHASE_STRIP = NO; + INFOPLIST_FILE = LuxTests/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + OTHER_LDFLAGS = ( + "-ObjC", + "-lc++", + "$(inherited)", + ); + OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; + PRODUCT_BUNDLE_IDENTIFIER = "org.reactjs.native.example.$(PRODUCT_NAME:rfc1034identifier)"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Lux.app/Lux"; + }; + name = Dev; + }; + FDB6FD43294D3A8200C7B822 /* Dev */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 6F7814C6D40D9C348EA1F1C7 /* Pods-OneSignalNotificationServiceExtension.dev.xcconfig */; + buildSettings = { + BUNDLE_ID_SUFFIX = .dev; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = OneSignalNotificationServiceExtension/OneSignalNotificationServiceExtension.entitlements; + CODE_SIGN_IDENTITY = "iPhone Distribution"; + CODE_SIGN_STYLE = Manual; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = JH3UHGZD75; + GCC_C_LANGUAGE_STANDARD = gnu11; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = OneSignalNotificationServiceExtension/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = OneSignalNotificationServiceExtension; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.69; + MTL_FAST_MATH = YES; + OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; + PRODUCT_BUNDLE_IDENTIFIER = com.lux.mobile.dev.OneSignalNotificationServiceExtension; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = "match AppStore com.lux.mobile.dev.OneSignalNotificationServiceExtension"; + SKIP_INSTALL = YES; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Dev; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 00E357021AD99517003FC87E /* Build configuration list for PBXNativeTarget "LuxTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 00E356F61AD99517003FC87E /* Debug */, + 00E356F71AD99517003FC87E /* Release */, + FDB6FD42294D3A8200C7B822 /* Dev */, + FDB6FD3E294D3A6E00C7B822 /* Beta */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 072E23A42A44D5BD006AD6C9 /* Build configuration list for PBXNativeTarget "WidgetsCore" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 072E239C2A44D5BD006AD6C9 /* Debug */, + 072E239D2A44D5BD006AD6C9 /* Release */, + 072E239E2A44D5BD006AD6C9 /* Dev */, + 072E239F2A44D5BD006AD6C9 /* Beta */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 072E23A52A44D5BD006AD6C9 /* Build configuration list for PBXNativeTarget "WidgetsCoreTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 072E23A02A44D5BD006AD6C9 /* Debug */, + 072E23A12A44D5BD006AD6C9 /* Release */, + 072E23A22A44D5BD006AD6C9 /* Dev */, + 072E23A32A44D5BD006AD6C9 /* Beta */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 072F6C362A44A32F00DA720A /* Build configuration list for PBXNativeTarget "Widgets" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 072F6C322A44A32F00DA720A /* Debug */, + 072F6C332A44A32F00DA720A /* Release */, + 072F6C342A44A32F00DA720A /* Dev */, + 072F6C352A44A32F00DA720A /* Beta */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 078E794F2A55EB3400F59CF2 /* Build configuration list for PBXNativeTarget "WidgetIntentExtension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 078E79502A55EB3400F59CF2 /* Debug */, + 078E79512A55EB3400F59CF2 /* Release */, + 078E79522A55EB3400F59CF2 /* Dev */, + 078E79532A55EB3400F59CF2 /* Beta */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "Lux" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 13B07F941A680F5B00A75B9A /* Debug */, + 13B07F951A680F5B00A75B9A /* Release */, + FDB6FD41294D3A8200C7B822 /* Dev */, + FDB6FD3D294D3A6E00C7B822 /* Beta */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "Lux" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 83CBBA201A601CBA00E9B192 /* Debug */, + 83CBBA211A601CBA00E9B192 /* Release */, + FDB6FD40294D3A8200C7B822 /* Dev */, + FDB6FD3C294D3A6E00C7B822 /* Beta */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + F35AFD4327EE49990011A725 /* Build configuration list for PBXNativeTarget "OneSignalNotificationServiceExtension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + F35AFD4427EE49990011A725 /* Debug */, + F35AFD4527EE49990011A725 /* Release */, + FDB6FD43294D3A8200C7B822 /* Dev */, + FDB6FD3F294D3A6E00C7B822 /* Beta */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 83CBB9F71A601CBA00E9B192 /* Project object */; +} diff --git a/apps/mobile/ios/Lux.xcodeproj/xcshareddata/xcschemes/Lux.xcscheme b/apps/mobile/ios/Lux.xcodeproj/xcshareddata/xcschemes/Lux.xcscheme new file mode 100644 index 00000000..ce49e67d --- /dev/null +++ b/apps/mobile/ios/Lux.xcodeproj/xcshareddata/xcschemes/Lux.xcscheme @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/mobile/ios/Lux.xcworkspace/contents.xcworkspacedata b/apps/mobile/ios/Lux.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..6c8c80ef --- /dev/null +++ b/apps/mobile/ios/Lux.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/apps/mobile/ios/Lux.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/apps/mobile/ios/Lux.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000..18d98100 --- /dev/null +++ b/apps/mobile/ios/Lux.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/apps/mobile/ios/Lux.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/apps/mobile/ios/Lux.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 00000000..f9b0d7c5 --- /dev/null +++ b/apps/mobile/ios/Lux.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/apps/mobile/ios/Lux/AppDelegate.swift b/apps/mobile/ios/Lux/AppDelegate.swift new file mode 100644 index 00000000..d52b2b51 --- /dev/null +++ b/apps/mobile/ios/Lux/AppDelegate.swift @@ -0,0 +1,163 @@ +import UIKit +import Expo +import ExpoModulesCore +import React +import ReactAppDependencyProvider +import Firebase +import ReactNativePerformance +import RNBootSplash +import UserNotifications + +@main +class AppDelegate: ExpoAppDelegate { + + static let hasLaunchedOnceKey = "HasLaunchedOnce" + + var window: UIWindow? + var reactNativeDelegate: ExpoReactNativeFactoryDelegate? + var reactNativeFactory: ExpoReactNativeFactory? + + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil + ) -> Bool { + print("🚀 AppDelegate: Starting initialization") + + // Must be first line in startup routine + ReactNativePerformance.onAppStarted() + print("📊 ReactNativePerformance started") + + // Handle keychain cleanup on first launch + handleKeychainCleanup() + print("🔐 Keychain cleanup completed") + + // Configure Firebase + FirebaseApp.configure() + print("🔥 Firebase configured") + + // Handle OneSignal deep linking + var newLaunchOptions = launchOptions ?? [:] + if let remoteNotif = launchOptions?[UIApplication.LaunchOptionsKey.remoteNotification] as? [String: Any], + let custom = remoteNotif["custom"] as? [String: Any], + let initialURL = custom["u"] as? String, + launchOptions?[UIApplication.LaunchOptionsKey.url] == nil { + newLaunchOptions[UIApplication.LaunchOptionsKey.url] = URL(string: initialURL) + print("🔗 OneSignal deep link processed") + } + + // Set up Expo React Native factory + let delegate = ReactNativeDelegate() + let factory = ExpoReactNativeFactory(delegate: delegate) + delegate.dependencyProvider = RCTAppDependencyProvider() + + reactNativeDelegate = delegate + reactNativeFactory = factory + bindReactNativeFactory(factory) + + window = UIWindow(frame: UIScreen.main.bounds) + factory.startReactNative( + withModuleName: "Lux", + in: window, + launchOptions: newLaunchOptions + ) + + let result = super.application(application, didFinishLaunchingWithOptions: newLaunchOptions) + + print("🏁 AppDelegate initialization complete") + return result + } + + // MARK: - Keychain Cleanup + private func handleKeychainCleanup() { + let defaults = UserDefaults.standard + let isFirstRun = !defaults.bool(forKey: AppDelegate.hasLaunchedOnceKey) + let canClearKeychainOnReinstall = KeychainUtils.getCanClearKeychainOnReinstall() + + if canClearKeychainOnReinstall && isFirstRun { + KeychainUtils.clearKeychain() + } + + if !canClearKeychainOnReinstall || isFirstRun { + defaults.set(true, forKey: AppDelegate.hasLaunchedOnceKey) + KeychainUtils.setCanClearKeychainOnReinstall() + } + } + + // MARK: - Deep Linking + override func application( + _ app: UIApplication, + open url: URL, + options: [UIApplication.OpenURLOptionsKey: Any] = [:] + ) -> Bool { + return super.application(app, open: url, options: options) || RCTLinkingManager.application(app, open: url, options: options) + } + + // Universal Links + override func application( + _ application: UIApplication, + continue userActivity: NSUserActivity, + restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void + ) -> Bool { + let result = RCTLinkingManager.application(application, continue: userActivity, restorationHandler: restorationHandler) + return super.application(application, continue: userActivity, restorationHandler: restorationHandler) || result + } + + // MARK: - Push Notifications + override func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { + // Handle device token registration + // OneSignal and other services will handle this via swizzling + } + + override func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { + // Handle registration failure + print("Failed to register for remote notifications: \(error)") + } + + override func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable : Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { + if let aps = userInfo["aps"] as? [String: Any] { + let contentAvailable = aps["content-available"] ?? aps["content_available"] + + if let contentNumber = contentAvailable as? NSNumber, contentNumber.intValue == 1 { + // Convert obj-c payload to SilentPushEventEmitter + let payload = userInfo.reduce(into: [String: Any]()) { result, entry in + if let key = entry.key as? String { + result[key] = entry.value + } + } + + SilentPushEventEmitter.emitEvent(with: payload) + } + } + completionHandler(.noData) + } + + // MARK: - Security + @objc(application:shouldAllowExtensionPointIdentifier:) + func application(_ application: UIApplication, shouldAllowExtensionPointIdentifier extensionPointIdentifier: UIApplication.ExtensionPointIdentifier) -> Bool { + // Disable 3rd party keyboards + if extensionPointIdentifier == .keyboard { + return false + } + return true + } +} + +class ReactNativeDelegate: ExpoReactNativeFactoryDelegate { + override func sourceURL(for bridge: RCTBridge) -> URL? { + bridge.bundleURL ?? bundleURL() + } + + override func bundleURL() -> URL? { + #if DEBUG + return RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: ".expo/.virtual-metro-entry") + #else + return Bundle.main.url(forResource: "main", withExtension: "jsbundle") + #endif + } + + // Override customize to initialize RNBootSplash BEFORE the window becomes visible + public override func customize(_ rootView: UIView) { + super.customize(rootView) + RNBootSplash.initWithStoryboard("SplashScreen", rootView: rootView) + } +} diff --git a/apps/mobile/ios/Lux/Colors.swift b/apps/mobile/ios/Lux/Colors.swift new file mode 100644 index 00000000..e899b950 --- /dev/null +++ b/apps/mobile/ios/Lux/Colors.swift @@ -0,0 +1,21 @@ +// +// Colors.swift +// Lux +// +// Created by Thomas Thachil on 8/8/22. +// + +import SwiftUI + +struct Colors { + static let surface1 = Color("surface1") + static let surface2 = Color("surface2") + static let surface3 = Color("surface3") + static let neutral1 = Color("neutral1") + static let neutral2 = Color("neutral2") + static let neutral3 = Color("neutral3") + static let accent1 = Color("accent1") + static let statusCritical = Color("statusCritical") + static let statusSuccess = Color("statusSuccess") + static let onboardingBlue = Color("onboardingBlue") +} diff --git a/apps/mobile/ios/Lux/Colors.xcassets/Contents.json b/apps/mobile/ios/Lux/Colors.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/apps/mobile/ios/Lux/Colors.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/mobile/ios/Lux/Colors.xcassets/accent1.colorset/Contents.json b/apps/mobile/ios/Lux/Colors.xcassets/accent1.colorset/Contents.json new file mode 100644 index 00000000..72e5d6d4 --- /dev/null +++ b/apps/mobile/ios/Lux/Colors.xcassets/accent1.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "255", + "green" : "114", + "red" : "252" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "255", + "green" : "114", + "red" : "252" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/mobile/ios/Lux/Colors.xcassets/neutral1.colorset/Contents.json b/apps/mobile/ios/Lux/Colors.xcassets/neutral1.colorset/Contents.json new file mode 100644 index 00000000..f2ef8800 --- /dev/null +++ b/apps/mobile/ios/Lux/Colors.xcassets/neutral1.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "34", + "green" : "34", + "red" : "34" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "255", + "green" : "255", + "red" : "255" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/mobile/ios/Lux/Colors.xcassets/neutral2.colorset/Contents.json b/apps/mobile/ios/Lux/Colors.xcassets/neutral2.colorset/Contents.json new file mode 100644 index 00000000..36e475a9 --- /dev/null +++ b/apps/mobile/ios/Lux/Colors.xcassets/neutral2.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x7D", + "green" : "0x7D", + "red" : "0x7D" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x9B", + "green" : "0x9B", + "red" : "0x9B" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/mobile/ios/Lux/Colors.xcassets/neutral3.colorset/Contents.json b/apps/mobile/ios/Lux/Colors.xcassets/neutral3.colorset/Contents.json new file mode 100644 index 00000000..7529f063 --- /dev/null +++ b/apps/mobile/ios/Lux/Colors.xcassets/neutral3.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "206", + "green" : "206", + "red" : "206" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "94", + "green" : "94", + "red" : "94" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/mobile/ios/Lux/Colors.xcassets/onboardingBlue.colorset/Contents.json b/apps/mobile/ios/Lux/Colors.xcassets/onboardingBlue.colorset/Contents.json new file mode 100644 index 00000000..c282d29c --- /dev/null +++ b/apps/mobile/ios/Lux/Colors.xcassets/onboardingBlue.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "251", + "green" : "130", + "red" : "76" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/mobile/ios/Lux/Colors.xcassets/statusCritical.colorset/Contents.json b/apps/mobile/ios/Lux/Colors.xcassets/statusCritical.colorset/Contents.json new file mode 100644 index 00000000..d226ce27 --- /dev/null +++ b/apps/mobile/ios/Lux/Colors.xcassets/statusCritical.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "82", + "green" : "95", + "red" : "255" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "82", + "green" : "95", + "red" : "255" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/mobile/ios/Lux/Colors.xcassets/statusSuccess.colorset/Contents.json b/apps/mobile/ios/Lux/Colors.xcassets/statusSuccess.colorset/Contents.json new file mode 100644 index 00000000..a2260c63 --- /dev/null +++ b/apps/mobile/ios/Lux/Colors.xcassets/statusSuccess.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x6B", + "green" : "0xB6", + "red" : "0x40" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x6B", + "green" : "0xB6", + "red" : "0x40" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/mobile/ios/Lux/Colors.xcassets/surface1.colorset/Contents.json b/apps/mobile/ios/Lux/Colors.xcassets/surface1.colorset/Contents.json new file mode 100644 index 00000000..a72cd250 --- /dev/null +++ b/apps/mobile/ios/Lux/Colors.xcassets/surface1.colorset/Contents.json @@ -0,0 +1,56 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFF", + "green" : "0xFF", + "red" : "0xFF" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "light" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFF", + "green" : "0xFF", + "red" : "0xFF" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x13", + "green" : "0x13", + "red" : "0x13" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/mobile/ios/Lux/Colors.xcassets/surface2.colorset/Contents.json b/apps/mobile/ios/Lux/Colors.xcassets/surface2.colorset/Contents.json new file mode 100644 index 00000000..6c88e846 --- /dev/null +++ b/apps/mobile/ios/Lux/Colors.xcassets/surface2.colorset/Contents.json @@ -0,0 +1,56 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xF9", + "green" : "0xF9", + "red" : "0xF9" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "light" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xF9", + "green" : "0xF9", + "red" : "0xF9" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x1B", + "green" : "0x1B", + "red" : "0x1B" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/mobile/ios/Lux/Colors.xcassets/surface3.colorset/Contents.json b/apps/mobile/ios/Lux/Colors.xcassets/surface3.colorset/Contents.json new file mode 100644 index 00000000..34aff131 --- /dev/null +++ b/apps/mobile/ios/Lux/Colors.xcassets/surface3.colorset/Contents.json @@ -0,0 +1,56 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.050", + "blue" : "0x22", + "green" : "0x22", + "red" : "0x22" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "light" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.050", + "blue" : "0x22", + "green" : "0x22", + "red" : "0x22" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.120", + "blue" : "0xFF", + "green" : "0xFF", + "red" : "0xFF" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/mobile/ios/Lux/Icons/AlertTriangleIcon.swift b/apps/mobile/ios/Lux/Icons/AlertTriangleIcon.swift new file mode 100644 index 00000000..b4e92ba8 --- /dev/null +++ b/apps/mobile/ios/Lux/Icons/AlertTriangleIcon.swift @@ -0,0 +1,40 @@ +// +// AlertTriangleIcon.swift +// Lux +// +// Created by Gary Ye on 9/16/23. +// + +import SwiftUI + +struct AlertTriangleIcon: Shape { + func path(in rect: CGRect) -> Path { + var path = Path() + let width = rect.size.width + let height = rect.size.height + path.move(to: CGPoint(x: 0.90031*width, y: 0.71468*height)) + path.addLine(to: CGPoint(x: 0.62502*width, y: 0.19983*height)) + path.addCurve(to: CGPoint(x: 0.37502*width, y: 0.19983*height), control1: CGPoint(x: 0.57168*width, y: 0.10008*height), control2: CGPoint(x: 0.42839*width, y: 0.10008*height)) + path.addLine(to: CGPoint(x: 0.09973*width, y: 0.71468*height)) + path.addCurve(to: CGPoint(x: 0.22119*width, y: 0.91667*height), control1: CGPoint(x: 0.05081*width, y: 0.80617*height), control2: CGPoint(x: 0.11723*width, y: 0.91667*height)) + path.addLine(to: CGPoint(x: 0.77885*width, y: 0.91667*height)) + path.addCurve(to: CGPoint(x: 0.90031*width, y: 0.71468*height), control1: CGPoint(x: 0.88276*width, y: 0.91667*height), control2: CGPoint(x: 0.94923*width, y: 0.80613*height)) + path.closeSubpath() + path.move(to: CGPoint(x: 0.46877*width, y: 0.41667*height)) + path.addCurve(to: CGPoint(x: 0.50002*width, y: 0.38542*height), control1: CGPoint(x: 0.46877*width, y: 0.39942*height), control2: CGPoint(x: 0.48277*width, y: 0.38542*height)) + path.addCurve(to: CGPoint(x: 0.53127*width, y: 0.41667*height), control1: CGPoint(x: 0.51727*width, y: 0.38542*height), control2: CGPoint(x: 0.53127*width, y: 0.39942*height)) + path.addLine(to: CGPoint(x: 0.53127*width, y: 0.58334*height)) + path.addCurve(to: CGPoint(x: 0.50002*width, y: 0.61459*height), control1: CGPoint(x: 0.53127*width, y: 0.60059*height), control2: CGPoint(x: 0.51727*width, y: 0.61459*height)) + path.addCurve(to: CGPoint(x: 0.46877*width, y: 0.58334*height), control1: CGPoint(x: 0.48277*width, y: 0.61459*height), control2: CGPoint(x: 0.46877*width, y: 0.60059*height)) + path.addLine(to: CGPoint(x: 0.46877*width, y: 0.41667*height)) + path.closeSubpath() + path.move(to: CGPoint(x: 0.50085*width, y: 0.75*height)) + path.addCurve(to: CGPoint(x: 0.45897*width, y: 0.70834*height), control1: CGPoint(x: 0.47785*width, y: 0.75*height), control2: CGPoint(x: 0.45897*width, y: 0.73134*height)) + path.addCurve(to: CGPoint(x: 0.50043*width, y: 0.66667*height), control1: CGPoint(x: 0.45897*width, y: 0.68534*height), control2: CGPoint(x: 0.47743*width, y: 0.66667*height)) + path.addLine(to: CGPoint(x: 0.50085*width, y: 0.66667*height)) + path.addCurve(to: CGPoint(x: 0.54252*width, y: 0.70834*height), control1: CGPoint(x: 0.52389*width, y: 0.66667*height), control2: CGPoint(x: 0.54252*width, y: 0.68534*height)) + path.addCurve(to: CGPoint(x: 0.50085*width, y: 0.75*height), control1: CGPoint(x: 0.54252*width, y: 0.73134*height), control2: CGPoint(x: 0.52385*width, y: 0.75*height)) + path.closeSubpath() + return path + } +} diff --git a/apps/mobile/ios/Lux/Icons/CopyIcon.swift b/apps/mobile/ios/Lux/Icons/CopyIcon.swift new file mode 100644 index 00000000..dbd1ed37 --- /dev/null +++ b/apps/mobile/ios/Lux/Icons/CopyIcon.swift @@ -0,0 +1,42 @@ +// +// CopyIcon.swift +// Lux +// +// Created by Mateusz Łopaciński on 27/05/2024. +// + +import SwiftUI + +struct CopyIcon: Shape { + func path(in rect: CGRect) -> Path { + var path = Path() + let width = rect.size.width + let height = rect.size.height + + path.move(to: CGPoint(x: 0.875 * width, y: 0.40104167 * height)) + path.addLine(to: CGPoint(x: 0.875 * width, y: 0.765625 * height)) + path.addCurve(to: CGPoint(x: 0.765625 * width, y: 0.875 * height), control1: CGPoint(x: 0.875 * width, y: 0.828125 * height), control2: CGPoint(x: 0.828125 * width, y: 0.875 * height)) + path.addLine(to: CGPoint(x: 0.40104167 * width, y: 0.875 * height)) + path.addCurve(to: CGPoint(x: 0.29166667 * width, y: 0.765625 * height), control1: CGPoint(x: 0.33854167 * width, y: 0.875 * height), control2: CGPoint(x: 0.29166667 * width, y: 0.828125 * height)) + path.addLine(to: CGPoint(x: 0.29166667 * width, y: 0.40104167 * height)) + path.addCurve(to: CGPoint(x: 0.40104167 * width, y: 0.29166667 * height), control1: CGPoint(x: 0.29166667 * width, y: 0.33854167 * height), control2: CGPoint(x: 0.33854167 * width, y: 0.29166667 * height)) + path.addLine(to: CGPoint(x: 0.765625 * width, y: 0.29166667 * height)) + path.addCurve(to: CGPoint(x: 0.875 * width, y: 0.40104167 * height), control1: CGPoint(x: 0.828125 * width, y: 0.29166667 * height), control2: CGPoint(x: 0.875 * width, y: 0.33854167 * height)) + path.closeSubpath() + + path.move(to: CGPoint(x: 0.65625 * width, y: 0.125 * height)) + path.addCurve(to: CGPoint(x: 0.625 * width, y: 0.09375 * height), control1: CGPoint(x: 0.65625 * width, y: 0.109375 * height), control2: CGPoint(x: 0.640625 * width, y: 0.09375 * height)) + path.addLine(to: CGPoint(x: 0.234375 * width, y: 0.09375 * height)) + path.addCurve(to: CGPoint(x: 0.09375 * width, y: 0.234375 * height), control1: CGPoint(x: 0.14583333 * width, y: 0.09375 * height), control2: CGPoint(x: 0.09375 * width, y: 0.14583333 * height)) + path.addLine(to: CGPoint(x: 0.09375 * width, y: 0.625 * height)) + path.addCurve(to: CGPoint(x: 0.125 * width, y: 0.65625 * height), control1: CGPoint(x: 0.09375 * width, y: 0.640625 * height), control2: CGPoint(x: 0.109375 * width, y: 0.65625 * height)) + path.addCurve(to: CGPoint(x: 0.15625 * width, y: 0.625 * height), control1: CGPoint(x: 0.140625 * width, y: 0.65625 * height), control2: CGPoint(x: 0.15625 * width, y: 0.640625 * height)) + path.addLine(to: CGPoint(x: 0.15625 * width, y: 0.234375 * height)) + path.addCurve(to: CGPoint(x: 0.234375 * width, y: 0.15625 * height), control1: CGPoint(x: 0.15625 * width, y: 0.19270833 * height), control2: CGPoint(x: 0.19270833 * width, y: 0.15625 * height)) + path.addLine(to: CGPoint(x: 0.625 * width, y: 0.15625 * height)) + path.addCurve(to: CGPoint(x: 0.65625 * width, y: 0.125 * height), control1: CGPoint(x: 0.640625 * width, y: 0.15625 * height), control2: CGPoint(x: 0.65625 * width, y: 0.140625 * height)) + path.closeSubpath() + + return path + } +} diff --git a/apps/mobile/ios/Lux/Icons/CopyIconOutline.swift b/apps/mobile/ios/Lux/Icons/CopyIconOutline.swift new file mode 100644 index 00000000..35683d29 --- /dev/null +++ b/apps/mobile/ios/Lux/Icons/CopyIconOutline.swift @@ -0,0 +1,54 @@ +// +// CopyIcon.swift +// Lux +// +// Created by Mateusz Łopaciński on 27/05/2024. +// + +import SwiftUI + +struct CopyIconOutline: Shape { + func path(in rect: CGRect) -> Path { + var path = Path() + let width = rect.size.width + let height = rect.size.height + path.move(to: CGPoint(x: 0.75298*width, y: 0.26042*height)) + path.addLine(to: CGPoint(x: 0.40575*width, y: 0.26042*height)) + path.addCurve(to: CGPoint(x: 0.27183*width, y: 0.40104*height), control1: CGPoint(x: 0.31937*width, y: 0.26042*height), control2: CGPoint(x: 0.27183*width, y: 0.31033*height)) + path.addLine(to: CGPoint(x: 0.27183*width, y: 0.76563*height)) + path.addCurve(to: CGPoint(x: 0.40575*width, y: 0.90625*height), control1: CGPoint(x: 0.27183*width, y: 0.85633*height), control2: CGPoint(x: 0.31937*width, y: 0.90625*height)) + path.addLine(to: CGPoint(x: 0.75298*width, y: 0.90625*height)) + path.addCurve(to: CGPoint(x: 0.8869*width, y: 0.76563*height), control1: CGPoint(x: 0.83937*width, y: 0.90625*height), control2: CGPoint(x: 0.8869*width, y: 0.85633*height)) + path.addLine(to: CGPoint(x: 0.8869*width, y: 0.40104*height)) + path.addCurve(to: CGPoint(x: 0.75298*width, y: 0.26042*height), control1: CGPoint(x: 0.8869*width, y: 0.31033*height), control2: CGPoint(x: 0.83937*width, y: 0.26042*height)) + path.closeSubpath() + path.move(to: CGPoint(x: 0.82738*width, y: 0.76563*height)) + path.addCurve(to: CGPoint(x: 0.75298*width, y: 0.84375*height), control1: CGPoint(x: 0.82738*width, y: 0.82112*height), control2: CGPoint(x: 0.80583*width, y: 0.84375*height)) + path.addLine(to: CGPoint(x: 0.40575*width, y: 0.84375*height)) + path.addCurve(to: CGPoint(x: 0.33135*width, y: 0.76563*height), control1: CGPoint(x: 0.3529*width, y: 0.84375*height), control2: CGPoint(x: 0.33135*width, y: 0.82112*height)) + path.addLine(to: CGPoint(x: 0.33135*width, y: 0.40104*height)) + path.addCurve(to: CGPoint(x: 0.40575*width, y: 0.32292*height), control1: CGPoint(x: 0.33135*width, y: 0.34554*height), control2: CGPoint(x: 0.3529*width, y: 0.32292*height)) + path.addLine(to: CGPoint(x: 0.75298*width, y: 0.32292*height)) + path.addCurve(to: CGPoint(x: 0.82738*width, y: 0.40104*height), control1: CGPoint(x: 0.80583*width, y: 0.32292*height), control2: CGPoint(x: 0.82738*width, y: 0.34554*height)) + path.addLine(to: CGPoint(x: 0.82738*width, y: 0.76563*height)) + path.closeSubpath() + path.move(to: CGPoint(x: 0.17262*width, y: 0.23417*height)) + path.addLine(to: CGPoint(x: 0.17262*width, y: 0.59916*height)) + path.addCurve(to: CGPoint(x: 0.1981*width, y: 0.66546*height), control1: CGPoint(x: 0.17262*width, y: 0.64909*height), control2: CGPoint(x: 0.19179*width, y: 0.66137*height)) + path.addCurve(to: CGPoint(x: 0.20793*width, y: 0.70842*height), control1: CGPoint(x: 0.21215*width, y: 0.67446*height), control2: CGPoint(x: 0.2165*width, y: 0.69371*height)) + path.addCurve(to: CGPoint(x: 0.1825*width, y: 0.72333*height), control1: CGPoint(x: 0.2023*width, y: 0.71804*height), control2: CGPoint(x: 0.19254*width, y: 0.72333*height)) + path.addCurve(to: CGPoint(x: 0.16698*width, y: 0.71875*height), control1: CGPoint(x: 0.17722*width, y: 0.72333*height), control2: CGPoint(x: 0.17182*width, y: 0.72184*height)) + path.addCurve(to: CGPoint(x: 0.1131*width, y: 0.59916*height), control1: CGPoint(x: 0.13123*width, y: 0.69575*height), control2: CGPoint(x: 0.1131*width, y: 0.65554*height)) + path.addLine(to: CGPoint(x: 0.1131*width, y: 0.23417*height)) + path.addCurve(to: CGPoint(x: 0.24683*width, y: 0.09375*height), control1: CGPoint(x: 0.1131*width, y: 0.14492*height), control2: CGPoint(x: 0.16187*width, y: 0.09375*height)) + path.addLine(to: CGPoint(x: 0.59444*width, y: 0.09375*height)) + path.addCurve(to: CGPoint(x: 0.70833*width, y: 0.15033*height), control1: CGPoint(x: 0.6613*width, y: 0.09375*height), control2: CGPoint(x: 0.69325*width, y: 0.12454*height)) + path.addCurve(to: CGPoint(x: 0.69849*width, y: 0.19329*height), control1: CGPoint(x: 0.7169*width, y: 0.16504*height), control2: CGPoint(x: 0.7125*width, y: 0.18429*height)) + path.addCurve(to: CGPoint(x: 0.65758*width, y: 0.18296*height), control1: CGPoint(x: 0.68444*width, y: 0.20233*height), control2: CGPoint(x: 0.66619*width, y: 0.19767*height)) + path.addCurve(to: CGPoint(x: 0.59444*width, y: 0.15621*height), control1: CGPoint(x: 0.65373*width, y: 0.17633*height), control2: CGPoint(x: 0.64198*width, y: 0.15621*height)) + path.addLine(to: CGPoint(x: 0.24683*width, y: 0.15621*height)) + path.addCurve(to: CGPoint(x: 0.17262*width, y: 0.23417*height), control1: CGPoint(x: 0.19413*width, y: 0.15625*height), control2: CGPoint(x: 0.17262*width, y: 0.17883*height)) + path.closeSubpath() + return path + } +} diff --git a/apps/mobile/ios/Lux/Icons/PasteIcon.swift b/apps/mobile/ios/Lux/Icons/PasteIcon.swift new file mode 100644 index 00000000..62088ae8 --- /dev/null +++ b/apps/mobile/ios/Lux/Icons/PasteIcon.swift @@ -0,0 +1,39 @@ +// +// PasteIcon.swift +// Lux +// +// Created by Gary Ye on 9/19/23. +// + +import SwiftUI + +struct PasteIcon: Shape { + func path(in rect: CGRect) -> Path { + var path = Path() + let width = rect.size.width + let height = rect.size.height + path.move(to: CGPoint(x: 0.875*width, y: 0.26542*height)) + path.addLine(to: CGPoint(x: 0.875*width, y: 0.53541*height)) + path.addCurve(to: CGPoint(x: 0.87208*width, y: 0.5625*height), control1: CGPoint(x: 0.875*width, y: 0.54458*height), control2: CGPoint(x: 0.87416*width, y: 0.55375*height)) + path.addLine(to: CGPoint(x: 0.70333*width, y: 0.5625*height)) + path.addCurve(to: CGPoint(x: 0.5625*width, y: 0.70334*height), control1: CGPoint(x: 0.62541*width, y: 0.5625*height), control2: CGPoint(x: 0.5625*width, y: 0.62542*height)) + path.addLine(to: CGPoint(x: 0.5625*width, y: 0.87208*height)) + path.addCurve(to: CGPoint(x: 0.53542*width, y: 0.875*height), control1: CGPoint(x: 0.55375*width, y: 0.87417*height), control2: CGPoint(x: 0.54458*width, y: 0.875*height)) + path.addLine(to: CGPoint(x: 0.26583*width, y: 0.875*height)) + path.addCurve(to: CGPoint(x: 0.125*width, y: 0.73416*height), control1: CGPoint(x: 0.17166*width, y: 0.875*height), control2: CGPoint(x: 0.125*width, y: 0.82791*height)) + path.addLine(to: CGPoint(x: 0.125*width, y: 0.26542*height)) + path.addCurve(to: CGPoint(x: 0.26583*width, y: 0.125*height), control1: CGPoint(x: 0.125*width, y: 0.17167*height), control2: CGPoint(x: 0.17166*width, y: 0.125*height)) + path.addLine(to: CGPoint(x: 0.73417*width, y: 0.125*height)) + path.addCurve(to: CGPoint(x: 0.875*width, y: 0.26542*height), control1: CGPoint(x: 0.82834*width, y: 0.125*height), control2: CGPoint(x: 0.875*width, y: 0.17167*height)) + path.closeSubpath() + path.move(to: CGPoint(x: 0.625*width, y: 0.70334*height)) + path.addLine(to: CGPoint(x: 0.625*width, y: 0.8425*height)) + path.addCurve(to: CGPoint(x: 0.635*width, y: 0.83375*height), control1: CGPoint(x: 0.62875*width, y: 0.84*height), control2: CGPoint(x: 0.63167*width, y: 0.83709*height)) + path.addLine(to: CGPoint(x: 0.83375*width, y: 0.635*height)) + path.addCurve(to: CGPoint(x: 0.8425*width, y: 0.625*height), control1: CGPoint(x: 0.83709*width, y: 0.63167*height), control2: CGPoint(x: 0.84*width, y: 0.62875*height)) + path.addLine(to: CGPoint(x: 0.70333*width, y: 0.625*height)) + path.addCurve(to: CGPoint(x: 0.625*width, y: 0.70334*height), control1: CGPoint(x: 0.65999*width, y: 0.625*height), control2: CGPoint(x: 0.625*width, y: 0.66*height)) + path.closeSubpath() + return path + } +} diff --git a/apps/mobile/ios/Lux/Images.xcassets/AppIcon.appiconset/100.png b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.appiconset/100.png new file mode 100644 index 00000000..10403655 Binary files /dev/null and b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.appiconset/100.png differ diff --git a/apps/mobile/ios/Lux/Images.xcassets/AppIcon.appiconset/1024.png b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.appiconset/1024.png new file mode 100644 index 00000000..d987b91c Binary files /dev/null and b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.appiconset/1024.png differ diff --git a/apps/mobile/ios/Lux/Images.xcassets/AppIcon.appiconset/114.png b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.appiconset/114.png new file mode 100644 index 00000000..5fdd346e Binary files /dev/null and b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.appiconset/114.png differ diff --git a/apps/mobile/ios/Lux/Images.xcassets/AppIcon.appiconset/120.png b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.appiconset/120.png new file mode 100644 index 00000000..48741050 Binary files /dev/null and b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.appiconset/120.png differ diff --git a/apps/mobile/ios/Lux/Images.xcassets/AppIcon.appiconset/144.png b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.appiconset/144.png new file mode 100644 index 00000000..37350c25 Binary files /dev/null and b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.appiconset/144.png differ diff --git a/apps/mobile/ios/Lux/Images.xcassets/AppIcon.appiconset/152.png b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.appiconset/152.png new file mode 100644 index 00000000..899b7aa1 Binary files /dev/null and b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.appiconset/152.png differ diff --git a/apps/mobile/ios/Lux/Images.xcassets/AppIcon.appiconset/167.png b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.appiconset/167.png new file mode 100644 index 00000000..89e654ec Binary files /dev/null and b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.appiconset/167.png differ diff --git a/apps/mobile/ios/Lux/Images.xcassets/AppIcon.appiconset/180.png b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.appiconset/180.png new file mode 100644 index 00000000..1d6e86b9 Binary files /dev/null and b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.appiconset/180.png differ diff --git a/apps/mobile/ios/Lux/Images.xcassets/AppIcon.appiconset/20.png b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.appiconset/20.png new file mode 100644 index 00000000..9387b630 Binary files /dev/null and b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.appiconset/20.png differ diff --git a/apps/mobile/ios/Lux/Images.xcassets/AppIcon.appiconset/29.png b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.appiconset/29.png new file mode 100644 index 00000000..09c3406e Binary files /dev/null and b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.appiconset/29.png differ diff --git a/apps/mobile/ios/Lux/Images.xcassets/AppIcon.appiconset/40.png b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.appiconset/40.png new file mode 100644 index 00000000..df7ef4a7 Binary files /dev/null and b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.appiconset/40.png differ diff --git a/apps/mobile/ios/Lux/Images.xcassets/AppIcon.appiconset/50.png b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.appiconset/50.png new file mode 100644 index 00000000..24e537af Binary files /dev/null and b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.appiconset/50.png differ diff --git a/apps/mobile/ios/Lux/Images.xcassets/AppIcon.appiconset/57.png b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.appiconset/57.png new file mode 100644 index 00000000..79db5b19 Binary files /dev/null and b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.appiconset/57.png differ diff --git a/apps/mobile/ios/Lux/Images.xcassets/AppIcon.appiconset/58.png b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.appiconset/58.png new file mode 100644 index 00000000..985fc3d0 Binary files /dev/null and b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.appiconset/58.png differ diff --git a/apps/mobile/ios/Lux/Images.xcassets/AppIcon.appiconset/60.png b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.appiconset/60.png new file mode 100644 index 00000000..67dccebe Binary files /dev/null and b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.appiconset/60.png differ diff --git a/apps/mobile/ios/Lux/Images.xcassets/AppIcon.appiconset/72.png b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.appiconset/72.png new file mode 100644 index 00000000..85ff2e3d Binary files /dev/null and b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.appiconset/72.png differ diff --git a/apps/mobile/ios/Lux/Images.xcassets/AppIcon.appiconset/76.png b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.appiconset/76.png new file mode 100644 index 00000000..5f91ad13 Binary files /dev/null and b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.appiconset/76.png differ diff --git a/apps/mobile/ios/Lux/Images.xcassets/AppIcon.appiconset/80.png b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.appiconset/80.png new file mode 100644 index 00000000..b052ae9c Binary files /dev/null and b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.appiconset/80.png differ diff --git a/apps/mobile/ios/Lux/Images.xcassets/AppIcon.appiconset/87.png b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.appiconset/87.png new file mode 100644 index 00000000..7f93dd8f Binary files /dev/null and b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.appiconset/87.png differ diff --git a/apps/mobile/ios/Lux/Images.xcassets/AppIcon.appiconset/Contents.json b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..4fdf8826 --- /dev/null +++ b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,158 @@ +{ + "images" : [ + { + "filename" : "40.png", + "idiom" : "iphone", + "scale" : "2x", + "size" : "20x20" + }, + { + "filename" : "60.png", + "idiom" : "iphone", + "scale" : "3x", + "size" : "20x20" + }, + { + "filename" : "29.png", + "idiom" : "iphone", + "scale" : "1x", + "size" : "29x29" + }, + { + "filename" : "58.png", + "idiom" : "iphone", + "scale" : "2x", + "size" : "29x29" + }, + { + "filename" : "87.png", + "idiom" : "iphone", + "scale" : "3x", + "size" : "29x29" + }, + { + "filename" : "80.png", + "idiom" : "iphone", + "scale" : "2x", + "size" : "40x40" + }, + { + "filename" : "120.png", + "idiom" : "iphone", + "scale" : "3x", + "size" : "40x40" + }, + { + "filename" : "57.png", + "idiom" : "iphone", + "scale" : "1x", + "size" : "57x57" + }, + { + "filename" : "114.png", + "idiom" : "iphone", + "scale" : "2x", + "size" : "57x57" + }, + { + "filename" : "120.png", + "idiom" : "iphone", + "scale" : "2x", + "size" : "60x60" + }, + { + "filename" : "180.png", + "idiom" : "iphone", + "scale" : "3x", + "size" : "60x60" + }, + { + "filename" : "20.png", + "idiom" : "ipad", + "scale" : "1x", + "size" : "20x20" + }, + { + "filename" : "40.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "20x20" + }, + { + "filename" : "29.png", + "idiom" : "ipad", + "scale" : "1x", + "size" : "29x29" + }, + { + "filename" : "58.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "29x29" + }, + { + "filename" : "40.png", + "idiom" : "ipad", + "scale" : "1x", + "size" : "40x40" + }, + { + "filename" : "80.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "40x40" + }, + { + "filename" : "50.png", + "idiom" : "ipad", + "scale" : "1x", + "size" : "50x50" + }, + { + "filename" : "100.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "50x50" + }, + { + "filename" : "72.png", + "idiom" : "ipad", + "scale" : "1x", + "size" : "72x72" + }, + { + "filename" : "144.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "72x72" + }, + { + "filename" : "76.png", + "idiom" : "ipad", + "scale" : "1x", + "size" : "76x76" + }, + { + "filename" : "152.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "76x76" + }, + { + "filename" : "167.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "83.5x83.5" + }, + { + "filename" : "1024.png", + "idiom" : "ios-marketing", + "scale" : "1x", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/mobile/ios/Lux/Images.xcassets/AppIcon.beta.appiconset/100.png b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.beta.appiconset/100.png new file mode 100644 index 00000000..577dd0dd Binary files /dev/null and b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.beta.appiconset/100.png differ diff --git a/apps/mobile/ios/Lux/Images.xcassets/AppIcon.beta.appiconset/1024.png b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.beta.appiconset/1024.png new file mode 100644 index 00000000..6cfe74dc Binary files /dev/null and b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.beta.appiconset/1024.png differ diff --git a/apps/mobile/ios/Lux/Images.xcassets/AppIcon.beta.appiconset/114.png b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.beta.appiconset/114.png new file mode 100644 index 00000000..a3780ff9 Binary files /dev/null and b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.beta.appiconset/114.png differ diff --git a/apps/mobile/ios/Lux/Images.xcassets/AppIcon.beta.appiconset/120.png b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.beta.appiconset/120.png new file mode 100644 index 00000000..fec5d6b5 Binary files /dev/null and b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.beta.appiconset/120.png differ diff --git a/apps/mobile/ios/Lux/Images.xcassets/AppIcon.beta.appiconset/128.png b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.beta.appiconset/128.png new file mode 100644 index 00000000..7101e408 Binary files /dev/null and b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.beta.appiconset/128.png differ diff --git a/apps/mobile/ios/Lux/Images.xcassets/AppIcon.beta.appiconset/144.png b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.beta.appiconset/144.png new file mode 100644 index 00000000..637553f9 Binary files /dev/null and b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.beta.appiconset/144.png differ diff --git a/apps/mobile/ios/Lux/Images.xcassets/AppIcon.beta.appiconset/152.png b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.beta.appiconset/152.png new file mode 100644 index 00000000..98ed1a76 Binary files /dev/null and b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.beta.appiconset/152.png differ diff --git a/apps/mobile/ios/Lux/Images.xcassets/AppIcon.beta.appiconset/16.png b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.beta.appiconset/16.png new file mode 100644 index 00000000..f3c91141 Binary files /dev/null and b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.beta.appiconset/16.png differ diff --git a/apps/mobile/ios/Lux/Images.xcassets/AppIcon.beta.appiconset/167.png b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.beta.appiconset/167.png new file mode 100644 index 00000000..42ce7da3 Binary files /dev/null and b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.beta.appiconset/167.png differ diff --git a/apps/mobile/ios/Lux/Images.xcassets/AppIcon.beta.appiconset/172.png b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.beta.appiconset/172.png new file mode 100644 index 00000000..eeb1674b Binary files /dev/null and b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.beta.appiconset/172.png differ diff --git a/apps/mobile/ios/Lux/Images.xcassets/AppIcon.beta.appiconset/180.png b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.beta.appiconset/180.png new file mode 100644 index 00000000..9277f46a Binary files /dev/null and b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.beta.appiconset/180.png differ diff --git a/apps/mobile/ios/Lux/Images.xcassets/AppIcon.beta.appiconset/196.png b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.beta.appiconset/196.png new file mode 100644 index 00000000..dcd539b9 Binary files /dev/null and b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.beta.appiconset/196.png differ diff --git a/apps/mobile/ios/Lux/Images.xcassets/AppIcon.beta.appiconset/20.png b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.beta.appiconset/20.png new file mode 100644 index 00000000..2cce0e3b Binary files /dev/null and b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.beta.appiconset/20.png differ diff --git a/apps/mobile/ios/Lux/Images.xcassets/AppIcon.beta.appiconset/216.png b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.beta.appiconset/216.png new file mode 100644 index 00000000..c61cb4a9 Binary files /dev/null and b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.beta.appiconset/216.png differ diff --git a/apps/mobile/ios/Lux/Images.xcassets/AppIcon.beta.appiconset/256.png b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.beta.appiconset/256.png new file mode 100644 index 00000000..06c7972b Binary files /dev/null and b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.beta.appiconset/256.png differ diff --git a/apps/mobile/ios/Lux/Images.xcassets/AppIcon.beta.appiconset/29.png b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.beta.appiconset/29.png new file mode 100644 index 00000000..79a85b6d Binary files /dev/null and b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.beta.appiconset/29.png differ diff --git a/apps/mobile/ios/Lux/Images.xcassets/AppIcon.beta.appiconset/32.png b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.beta.appiconset/32.png new file mode 100644 index 00000000..ccd6ea8a Binary files /dev/null and b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.beta.appiconset/32.png differ diff --git a/apps/mobile/ios/Lux/Images.xcassets/AppIcon.beta.appiconset/40.png b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.beta.appiconset/40.png new file mode 100644 index 00000000..94082ee4 Binary files /dev/null and b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.beta.appiconset/40.png differ diff --git a/apps/mobile/ios/Lux/Images.xcassets/AppIcon.beta.appiconset/48.png b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.beta.appiconset/48.png new file mode 100644 index 00000000..dceaeeb2 Binary files /dev/null and b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.beta.appiconset/48.png differ diff --git a/apps/mobile/ios/Lux/Images.xcassets/AppIcon.beta.appiconset/50.png b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.beta.appiconset/50.png new file mode 100644 index 00000000..bd818bac Binary files /dev/null and b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.beta.appiconset/50.png differ diff --git a/apps/mobile/ios/Lux/Images.xcassets/AppIcon.beta.appiconset/512.png b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.beta.appiconset/512.png new file mode 100644 index 00000000..0e16ead2 Binary files /dev/null and b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.beta.appiconset/512.png differ diff --git a/apps/mobile/ios/Lux/Images.xcassets/AppIcon.beta.appiconset/55.png b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.beta.appiconset/55.png new file mode 100644 index 00000000..14f16400 Binary files /dev/null and b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.beta.appiconset/55.png differ diff --git a/apps/mobile/ios/Lux/Images.xcassets/AppIcon.beta.appiconset/57.png b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.beta.appiconset/57.png new file mode 100644 index 00000000..2e6c9f53 Binary files /dev/null and b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.beta.appiconset/57.png differ diff --git a/apps/mobile/ios/Lux/Images.xcassets/AppIcon.beta.appiconset/58.png b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.beta.appiconset/58.png new file mode 100644 index 00000000..39f4e99b Binary files /dev/null and b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.beta.appiconset/58.png differ diff --git a/apps/mobile/ios/Lux/Images.xcassets/AppIcon.beta.appiconset/60.png b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.beta.appiconset/60.png new file mode 100644 index 00000000..e0bef9db Binary files /dev/null and b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.beta.appiconset/60.png differ diff --git a/apps/mobile/ios/Lux/Images.xcassets/AppIcon.beta.appiconset/64.png b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.beta.appiconset/64.png new file mode 100644 index 00000000..dd3bea35 Binary files /dev/null and b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.beta.appiconset/64.png differ diff --git a/apps/mobile/ios/Lux/Images.xcassets/AppIcon.beta.appiconset/66.png b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.beta.appiconset/66.png new file mode 100644 index 00000000..4fde9c4e Binary files /dev/null and b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.beta.appiconset/66.png differ diff --git a/apps/mobile/ios/Lux/Images.xcassets/AppIcon.beta.appiconset/72.png b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.beta.appiconset/72.png new file mode 100644 index 00000000..7d75238f Binary files /dev/null and b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.beta.appiconset/72.png differ diff --git a/apps/mobile/ios/Lux/Images.xcassets/AppIcon.beta.appiconset/76.png b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.beta.appiconset/76.png new file mode 100644 index 00000000..e6fc227a Binary files /dev/null and b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.beta.appiconset/76.png differ diff --git a/apps/mobile/ios/Lux/Images.xcassets/AppIcon.beta.appiconset/80.png b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.beta.appiconset/80.png new file mode 100644 index 00000000..85f3210d Binary files /dev/null and b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.beta.appiconset/80.png differ diff --git a/apps/mobile/ios/Lux/Images.xcassets/AppIcon.beta.appiconset/87.png b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.beta.appiconset/87.png new file mode 100644 index 00000000..cf77b56b Binary files /dev/null and b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.beta.appiconset/87.png differ diff --git a/apps/mobile/ios/Lux/Images.xcassets/AppIcon.beta.appiconset/88.png b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.beta.appiconset/88.png new file mode 100644 index 00000000..895250b7 Binary files /dev/null and b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.beta.appiconset/88.png differ diff --git a/apps/mobile/ios/Lux/Images.xcassets/AppIcon.beta.appiconset/92.png b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.beta.appiconset/92.png new file mode 100644 index 00000000..18f29816 Binary files /dev/null and b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.beta.appiconset/92.png differ diff --git a/apps/mobile/ios/Lux/Images.xcassets/AppIcon.beta.appiconset/Contents.json b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.beta.appiconset/Contents.json new file mode 100644 index 00000000..8e70699a --- /dev/null +++ b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.beta.appiconset/Contents.json @@ -0,0 +1,346 @@ +{ + "images" : [ + { + "filename" : "40.png", + "idiom" : "iphone", + "scale" : "2x", + "size" : "20x20" + }, + { + "filename" : "60.png", + "idiom" : "iphone", + "scale" : "3x", + "size" : "20x20" + }, + { + "filename" : "29.png", + "idiom" : "iphone", + "scale" : "1x", + "size" : "29x29" + }, + { + "filename" : "58.png", + "idiom" : "iphone", + "scale" : "2x", + "size" : "29x29" + }, + { + "filename" : "87.png", + "idiom" : "iphone", + "scale" : "3x", + "size" : "29x29" + }, + { + "filename" : "80.png", + "idiom" : "iphone", + "scale" : "2x", + "size" : "40x40" + }, + { + "filename" : "120.png", + "idiom" : "iphone", + "scale" : "3x", + "size" : "40x40" + }, + { + "filename" : "57.png", + "idiom" : "iphone", + "scale" : "1x", + "size" : "57x57" + }, + { + "filename" : "114.png", + "idiom" : "iphone", + "scale" : "2x", + "size" : "57x57" + }, + { + "filename" : "120.png", + "idiom" : "iphone", + "scale" : "2x", + "size" : "60x60" + }, + { + "filename" : "180.png", + "idiom" : "iphone", + "scale" : "3x", + "size" : "60x60" + }, + { + "filename" : "20.png", + "idiom" : "ipad", + "scale" : "1x", + "size" : "20x20" + }, + { + "filename" : "40.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "20x20" + }, + { + "filename" : "29.png", + "idiom" : "ipad", + "scale" : "1x", + "size" : "29x29" + }, + { + "filename" : "58.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "29x29" + }, + { + "filename" : "40.png", + "idiom" : "ipad", + "scale" : "1x", + "size" : "40x40" + }, + { + "filename" : "80.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "40x40" + }, + { + "filename" : "50.png", + "idiom" : "ipad", + "scale" : "1x", + "size" : "50x50" + }, + { + "filename" : "100.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "50x50" + }, + { + "filename" : "72.png", + "idiom" : "ipad", + "scale" : "1x", + "size" : "72x72" + }, + { + "filename" : "144.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "72x72" + }, + { + "filename" : "76.png", + "idiom" : "ipad", + "scale" : "1x", + "size" : "76x76" + }, + { + "filename" : "152.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "76x76" + }, + { + "filename" : "167.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "83.5x83.5" + }, + { + "filename" : "1024.png", + "idiom" : "ios-marketing", + "scale" : "1x", + "size" : "1024x1024" + }, + { + "filename" : "16.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "16x16" + }, + { + "filename" : "32.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "16x16" + }, + { + "filename" : "32.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "32x32" + }, + { + "filename" : "64.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "32x32" + }, + { + "filename" : "128.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "128x128" + }, + { + "filename" : "256.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "128x128" + }, + { + "filename" : "256.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "256x256" + }, + { + "filename" : "512.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "256x256" + }, + { + "filename" : "512.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "512x512" + }, + { + "filename" : "1024.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "512x512" + }, + { + "filename" : "48.png", + "idiom" : "watch", + "role" : "notificationCenter", + "scale" : "2x", + "size" : "24x24", + "subtype" : "38mm" + }, + { + "filename" : "55.png", + "idiom" : "watch", + "role" : "notificationCenter", + "scale" : "2x", + "size" : "27.5x27.5", + "subtype" : "42mm" + }, + { + "filename" : "58.png", + "idiom" : "watch", + "role" : "companionSettings", + "scale" : "2x", + "size" : "29x29" + }, + { + "filename" : "87.png", + "idiom" : "watch", + "role" : "companionSettings", + "scale" : "3x", + "size" : "29x29" + }, + { + "filename" : "66.png", + "idiom" : "watch", + "role" : "notificationCenter", + "scale" : "2x", + "size" : "33x33", + "subtype" : "45mm" + }, + { + "filename" : "80.png", + "idiom" : "watch", + "role" : "appLauncher", + "scale" : "2x", + "size" : "40x40", + "subtype" : "38mm" + }, + { + "filename" : "88.png", + "idiom" : "watch", + "role" : "appLauncher", + "scale" : "2x", + "size" : "44x44", + "subtype" : "40mm" + }, + { + "filename" : "92.png", + "idiom" : "watch", + "role" : "appLauncher", + "scale" : "2x", + "size" : "46x46", + "subtype" : "41mm" + }, + { + "filename" : "100.png", + "idiom" : "watch", + "role" : "appLauncher", + "scale" : "2x", + "size" : "50x50", + "subtype" : "44mm" + }, + { + "idiom" : "watch", + "role" : "appLauncher", + "scale" : "2x", + "size" : "51x51", + "subtype" : "45mm" + }, + { + "idiom" : "watch", + "role" : "appLauncher", + "scale" : "2x", + "size" : "54x54", + "subtype" : "49mm" + }, + { + "filename" : "172.png", + "idiom" : "watch", + "role" : "quickLook", + "scale" : "2x", + "size" : "86x86", + "subtype" : "38mm" + }, + { + "filename" : "196.png", + "idiom" : "watch", + "role" : "quickLook", + "scale" : "2x", + "size" : "98x98", + "subtype" : "42mm" + }, + { + "filename" : "216.png", + "idiom" : "watch", + "role" : "quickLook", + "scale" : "2x", + "size" : "108x108", + "subtype" : "44mm" + }, + { + "idiom" : "watch", + "role" : "quickLook", + "scale" : "2x", + "size" : "117x117", + "subtype" : "45mm" + }, + { + "idiom" : "watch", + "role" : "quickLook", + "scale" : "2x", + "size" : "129x129", + "subtype" : "49mm" + }, + { + "filename" : "1024.png", + "idiom" : "watch-marketing", + "scale" : "1x", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/mobile/ios/Lux/Images.xcassets/AppIcon.dev.appiconset/100.png b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.dev.appiconset/100.png new file mode 100644 index 00000000..5ff03063 Binary files /dev/null and b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.dev.appiconset/100.png differ diff --git a/apps/mobile/ios/Lux/Images.xcassets/AppIcon.dev.appiconset/1024.png b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.dev.appiconset/1024.png new file mode 100644 index 00000000..566c96ce Binary files /dev/null and b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.dev.appiconset/1024.png differ diff --git a/apps/mobile/ios/Lux/Images.xcassets/AppIcon.dev.appiconset/114.png b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.dev.appiconset/114.png new file mode 100644 index 00000000..670f7bfa Binary files /dev/null and b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.dev.appiconset/114.png differ diff --git a/apps/mobile/ios/Lux/Images.xcassets/AppIcon.dev.appiconset/120.png b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.dev.appiconset/120.png new file mode 100644 index 00000000..bf30e76f Binary files /dev/null and b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.dev.appiconset/120.png differ diff --git a/apps/mobile/ios/Lux/Images.xcassets/AppIcon.dev.appiconset/128.png b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.dev.appiconset/128.png new file mode 100644 index 00000000..af395d67 Binary files /dev/null and b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.dev.appiconset/128.png differ diff --git a/apps/mobile/ios/Lux/Images.xcassets/AppIcon.dev.appiconset/144.png b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.dev.appiconset/144.png new file mode 100644 index 00000000..0994327a Binary files /dev/null and b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.dev.appiconset/144.png differ diff --git a/apps/mobile/ios/Lux/Images.xcassets/AppIcon.dev.appiconset/152.png b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.dev.appiconset/152.png new file mode 100644 index 00000000..555e1678 Binary files /dev/null and b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.dev.appiconset/152.png differ diff --git a/apps/mobile/ios/Lux/Images.xcassets/AppIcon.dev.appiconset/16.png b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.dev.appiconset/16.png new file mode 100644 index 00000000..ffbb91f0 Binary files /dev/null and b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.dev.appiconset/16.png differ diff --git a/apps/mobile/ios/Lux/Images.xcassets/AppIcon.dev.appiconset/167.png b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.dev.appiconset/167.png new file mode 100644 index 00000000..b7198638 Binary files /dev/null and b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.dev.appiconset/167.png differ diff --git a/apps/mobile/ios/Lux/Images.xcassets/AppIcon.dev.appiconset/172.png b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.dev.appiconset/172.png new file mode 100644 index 00000000..e06d0e24 Binary files /dev/null and b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.dev.appiconset/172.png differ diff --git a/apps/mobile/ios/Lux/Images.xcassets/AppIcon.dev.appiconset/180.png b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.dev.appiconset/180.png new file mode 100644 index 00000000..92017c48 Binary files /dev/null and b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.dev.appiconset/180.png differ diff --git a/apps/mobile/ios/Lux/Images.xcassets/AppIcon.dev.appiconset/196.png b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.dev.appiconset/196.png new file mode 100644 index 00000000..3a3746a6 Binary files /dev/null and b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.dev.appiconset/196.png differ diff --git a/apps/mobile/ios/Lux/Images.xcassets/AppIcon.dev.appiconset/20.png b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.dev.appiconset/20.png new file mode 100644 index 00000000..7939c341 Binary files /dev/null and b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.dev.appiconset/20.png differ diff --git a/apps/mobile/ios/Lux/Images.xcassets/AppIcon.dev.appiconset/216.png b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.dev.appiconset/216.png new file mode 100644 index 00000000..8f7f70d8 Binary files /dev/null and b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.dev.appiconset/216.png differ diff --git a/apps/mobile/ios/Lux/Images.xcassets/AppIcon.dev.appiconset/256.png b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.dev.appiconset/256.png new file mode 100644 index 00000000..2bbfb7b2 Binary files /dev/null and b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.dev.appiconset/256.png differ diff --git a/apps/mobile/ios/Lux/Images.xcassets/AppIcon.dev.appiconset/29.png b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.dev.appiconset/29.png new file mode 100644 index 00000000..87b92135 Binary files /dev/null and b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.dev.appiconset/29.png differ diff --git a/apps/mobile/ios/Lux/Images.xcassets/AppIcon.dev.appiconset/32.png b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.dev.appiconset/32.png new file mode 100644 index 00000000..0f7ebb7a Binary files /dev/null and b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.dev.appiconset/32.png differ diff --git a/apps/mobile/ios/Lux/Images.xcassets/AppIcon.dev.appiconset/40.png b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.dev.appiconset/40.png new file mode 100644 index 00000000..50945cf1 Binary files /dev/null and b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.dev.appiconset/40.png differ diff --git a/apps/mobile/ios/Lux/Images.xcassets/AppIcon.dev.appiconset/48.png b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.dev.appiconset/48.png new file mode 100644 index 00000000..3ba857f7 Binary files /dev/null and b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.dev.appiconset/48.png differ diff --git a/apps/mobile/ios/Lux/Images.xcassets/AppIcon.dev.appiconset/50.png b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.dev.appiconset/50.png new file mode 100644 index 00000000..94aeb0d6 Binary files /dev/null and b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.dev.appiconset/50.png differ diff --git a/apps/mobile/ios/Lux/Images.xcassets/AppIcon.dev.appiconset/512.png b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.dev.appiconset/512.png new file mode 100644 index 00000000..3fc06cf1 Binary files /dev/null and b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.dev.appiconset/512.png differ diff --git a/apps/mobile/ios/Lux/Images.xcassets/AppIcon.dev.appiconset/55.png b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.dev.appiconset/55.png new file mode 100644 index 00000000..e3bc66d7 Binary files /dev/null and b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.dev.appiconset/55.png differ diff --git a/apps/mobile/ios/Lux/Images.xcassets/AppIcon.dev.appiconset/57.png b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.dev.appiconset/57.png new file mode 100644 index 00000000..3c161e42 Binary files /dev/null and b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.dev.appiconset/57.png differ diff --git a/apps/mobile/ios/Lux/Images.xcassets/AppIcon.dev.appiconset/58.png b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.dev.appiconset/58.png new file mode 100644 index 00000000..e13f32bd Binary files /dev/null and b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.dev.appiconset/58.png differ diff --git a/apps/mobile/ios/Lux/Images.xcassets/AppIcon.dev.appiconset/60.png b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.dev.appiconset/60.png new file mode 100644 index 00000000..449a9931 Binary files /dev/null and b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.dev.appiconset/60.png differ diff --git a/apps/mobile/ios/Lux/Images.xcassets/AppIcon.dev.appiconset/64.png b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.dev.appiconset/64.png new file mode 100644 index 00000000..42562523 Binary files /dev/null and b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.dev.appiconset/64.png differ diff --git a/apps/mobile/ios/Lux/Images.xcassets/AppIcon.dev.appiconset/66.png b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.dev.appiconset/66.png new file mode 100644 index 00000000..a4bba166 Binary files /dev/null and b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.dev.appiconset/66.png differ diff --git a/apps/mobile/ios/Lux/Images.xcassets/AppIcon.dev.appiconset/72.png b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.dev.appiconset/72.png new file mode 100644 index 00000000..79c1ed26 Binary files /dev/null and b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.dev.appiconset/72.png differ diff --git a/apps/mobile/ios/Lux/Images.xcassets/AppIcon.dev.appiconset/76.png b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.dev.appiconset/76.png new file mode 100644 index 00000000..405cef0b Binary files /dev/null and b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.dev.appiconset/76.png differ diff --git a/apps/mobile/ios/Lux/Images.xcassets/AppIcon.dev.appiconset/80.png b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.dev.appiconset/80.png new file mode 100644 index 00000000..2247ca8b Binary files /dev/null and b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.dev.appiconset/80.png differ diff --git a/apps/mobile/ios/Lux/Images.xcassets/AppIcon.dev.appiconset/87.png b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.dev.appiconset/87.png new file mode 100644 index 00000000..e6eb123f Binary files /dev/null and b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.dev.appiconset/87.png differ diff --git a/apps/mobile/ios/Lux/Images.xcassets/AppIcon.dev.appiconset/88.png b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.dev.appiconset/88.png new file mode 100644 index 00000000..2ec33234 Binary files /dev/null and b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.dev.appiconset/88.png differ diff --git a/apps/mobile/ios/Lux/Images.xcassets/AppIcon.dev.appiconset/92.png b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.dev.appiconset/92.png new file mode 100644 index 00000000..80b08ccd Binary files /dev/null and b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.dev.appiconset/92.png differ diff --git a/apps/mobile/ios/Lux/Images.xcassets/AppIcon.dev.appiconset/Contents.json b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.dev.appiconset/Contents.json new file mode 100644 index 00000000..8e70699a --- /dev/null +++ b/apps/mobile/ios/Lux/Images.xcassets/AppIcon.dev.appiconset/Contents.json @@ -0,0 +1,346 @@ +{ + "images" : [ + { + "filename" : "40.png", + "idiom" : "iphone", + "scale" : "2x", + "size" : "20x20" + }, + { + "filename" : "60.png", + "idiom" : "iphone", + "scale" : "3x", + "size" : "20x20" + }, + { + "filename" : "29.png", + "idiom" : "iphone", + "scale" : "1x", + "size" : "29x29" + }, + { + "filename" : "58.png", + "idiom" : "iphone", + "scale" : "2x", + "size" : "29x29" + }, + { + "filename" : "87.png", + "idiom" : "iphone", + "scale" : "3x", + "size" : "29x29" + }, + { + "filename" : "80.png", + "idiom" : "iphone", + "scale" : "2x", + "size" : "40x40" + }, + { + "filename" : "120.png", + "idiom" : "iphone", + "scale" : "3x", + "size" : "40x40" + }, + { + "filename" : "57.png", + "idiom" : "iphone", + "scale" : "1x", + "size" : "57x57" + }, + { + "filename" : "114.png", + "idiom" : "iphone", + "scale" : "2x", + "size" : "57x57" + }, + { + "filename" : "120.png", + "idiom" : "iphone", + "scale" : "2x", + "size" : "60x60" + }, + { + "filename" : "180.png", + "idiom" : "iphone", + "scale" : "3x", + "size" : "60x60" + }, + { + "filename" : "20.png", + "idiom" : "ipad", + "scale" : "1x", + "size" : "20x20" + }, + { + "filename" : "40.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "20x20" + }, + { + "filename" : "29.png", + "idiom" : "ipad", + "scale" : "1x", + "size" : "29x29" + }, + { + "filename" : "58.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "29x29" + }, + { + "filename" : "40.png", + "idiom" : "ipad", + "scale" : "1x", + "size" : "40x40" + }, + { + "filename" : "80.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "40x40" + }, + { + "filename" : "50.png", + "idiom" : "ipad", + "scale" : "1x", + "size" : "50x50" + }, + { + "filename" : "100.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "50x50" + }, + { + "filename" : "72.png", + "idiom" : "ipad", + "scale" : "1x", + "size" : "72x72" + }, + { + "filename" : "144.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "72x72" + }, + { + "filename" : "76.png", + "idiom" : "ipad", + "scale" : "1x", + "size" : "76x76" + }, + { + "filename" : "152.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "76x76" + }, + { + "filename" : "167.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "83.5x83.5" + }, + { + "filename" : "1024.png", + "idiom" : "ios-marketing", + "scale" : "1x", + "size" : "1024x1024" + }, + { + "filename" : "16.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "16x16" + }, + { + "filename" : "32.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "16x16" + }, + { + "filename" : "32.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "32x32" + }, + { + "filename" : "64.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "32x32" + }, + { + "filename" : "128.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "128x128" + }, + { + "filename" : "256.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "128x128" + }, + { + "filename" : "256.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "256x256" + }, + { + "filename" : "512.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "256x256" + }, + { + "filename" : "512.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "512x512" + }, + { + "filename" : "1024.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "512x512" + }, + { + "filename" : "48.png", + "idiom" : "watch", + "role" : "notificationCenter", + "scale" : "2x", + "size" : "24x24", + "subtype" : "38mm" + }, + { + "filename" : "55.png", + "idiom" : "watch", + "role" : "notificationCenter", + "scale" : "2x", + "size" : "27.5x27.5", + "subtype" : "42mm" + }, + { + "filename" : "58.png", + "idiom" : "watch", + "role" : "companionSettings", + "scale" : "2x", + "size" : "29x29" + }, + { + "filename" : "87.png", + "idiom" : "watch", + "role" : "companionSettings", + "scale" : "3x", + "size" : "29x29" + }, + { + "filename" : "66.png", + "idiom" : "watch", + "role" : "notificationCenter", + "scale" : "2x", + "size" : "33x33", + "subtype" : "45mm" + }, + { + "filename" : "80.png", + "idiom" : "watch", + "role" : "appLauncher", + "scale" : "2x", + "size" : "40x40", + "subtype" : "38mm" + }, + { + "filename" : "88.png", + "idiom" : "watch", + "role" : "appLauncher", + "scale" : "2x", + "size" : "44x44", + "subtype" : "40mm" + }, + { + "filename" : "92.png", + "idiom" : "watch", + "role" : "appLauncher", + "scale" : "2x", + "size" : "46x46", + "subtype" : "41mm" + }, + { + "filename" : "100.png", + "idiom" : "watch", + "role" : "appLauncher", + "scale" : "2x", + "size" : "50x50", + "subtype" : "44mm" + }, + { + "idiom" : "watch", + "role" : "appLauncher", + "scale" : "2x", + "size" : "51x51", + "subtype" : "45mm" + }, + { + "idiom" : "watch", + "role" : "appLauncher", + "scale" : "2x", + "size" : "54x54", + "subtype" : "49mm" + }, + { + "filename" : "172.png", + "idiom" : "watch", + "role" : "quickLook", + "scale" : "2x", + "size" : "86x86", + "subtype" : "38mm" + }, + { + "filename" : "196.png", + "idiom" : "watch", + "role" : "quickLook", + "scale" : "2x", + "size" : "98x98", + "subtype" : "42mm" + }, + { + "filename" : "216.png", + "idiom" : "watch", + "role" : "quickLook", + "scale" : "2x", + "size" : "108x108", + "subtype" : "44mm" + }, + { + "idiom" : "watch", + "role" : "quickLook", + "scale" : "2x", + "size" : "117x117", + "subtype" : "45mm" + }, + { + "idiom" : "watch", + "role" : "quickLook", + "scale" : "2x", + "size" : "129x129", + "subtype" : "49mm" + }, + { + "filename" : "1024.png", + "idiom" : "watch-marketing", + "scale" : "1x", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/mobile/ios/Lux/Images.xcassets/Contents.json b/apps/mobile/ios/Lux/Images.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/apps/mobile/ios/Lux/Images.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/mobile/ios/Lux/Images.xcassets/SplashLogo.imageset/Contents.json b/apps/mobile/ios/Lux/Images.xcassets/SplashLogo.imageset/Contents.json new file mode 100644 index 00000000..5c4b9312 --- /dev/null +++ b/apps/mobile/ios/Lux/Images.xcassets/SplashLogo.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "SplashLogo 1.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "SplashLogo@2x 1.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "SplashLogo@3x 1.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/mobile/ios/Lux/Images.xcassets/SplashLogo.imageset/SplashLogo 1.png b/apps/mobile/ios/Lux/Images.xcassets/SplashLogo.imageset/SplashLogo 1.png new file mode 100644 index 00000000..d0d440ee Binary files /dev/null and b/apps/mobile/ios/Lux/Images.xcassets/SplashLogo.imageset/SplashLogo 1.png differ diff --git a/apps/mobile/ios/Lux/Images.xcassets/SplashLogo.imageset/SplashLogo@2x 1.png b/apps/mobile/ios/Lux/Images.xcassets/SplashLogo.imageset/SplashLogo@2x 1.png new file mode 100644 index 00000000..f747b8f1 Binary files /dev/null and b/apps/mobile/ios/Lux/Images.xcassets/SplashLogo.imageset/SplashLogo@2x 1.png differ diff --git a/apps/mobile/ios/Lux/Images.xcassets/SplashLogo.imageset/SplashLogo@3x 1.png b/apps/mobile/ios/Lux/Images.xcassets/SplashLogo.imageset/SplashLogo@3x 1.png new file mode 100644 index 00000000..5a84604e Binary files /dev/null and b/apps/mobile/ios/Lux/Images.xcassets/SplashLogo.imageset/SplashLogo@3x 1.png differ diff --git a/apps/mobile/ios/Lux/Images.xcassets/SplashScreenBackground.imageset/Contents.json b/apps/mobile/ios/Lux/Images.xcassets/SplashScreenBackground.imageset/Contents.json new file mode 100644 index 00000000..d1531a38 --- /dev/null +++ b/apps/mobile/ios/Lux/Images.xcassets/SplashScreenBackground.imageset/Contents.json @@ -0,0 +1,89 @@ +{ + "images" : [ + { + "filename" : "background.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "light" + } + ], + "filename" : "background-3.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "background-1-dark.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "background-1.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "light" + } + ], + "filename" : "background-4.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "background-1-dark-1.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "background-2.png", + "idiom" : "universal", + "scale" : "3x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "light" + } + ], + "filename" : "background-5.png", + "idiom" : "universal", + "scale" : "3x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "background-1-dark-2.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/mobile/ios/Lux/Images.xcassets/SplashScreenBackground.imageset/background-1-dark-1.png b/apps/mobile/ios/Lux/Images.xcassets/SplashScreenBackground.imageset/background-1-dark-1.png new file mode 100644 index 00000000..12690b0b Binary files /dev/null and b/apps/mobile/ios/Lux/Images.xcassets/SplashScreenBackground.imageset/background-1-dark-1.png differ diff --git a/apps/mobile/ios/Lux/Images.xcassets/SplashScreenBackground.imageset/background-1-dark-2.png b/apps/mobile/ios/Lux/Images.xcassets/SplashScreenBackground.imageset/background-1-dark-2.png new file mode 100644 index 00000000..12690b0b Binary files /dev/null and b/apps/mobile/ios/Lux/Images.xcassets/SplashScreenBackground.imageset/background-1-dark-2.png differ diff --git a/apps/mobile/ios/Lux/Images.xcassets/SplashScreenBackground.imageset/background-1-dark.png b/apps/mobile/ios/Lux/Images.xcassets/SplashScreenBackground.imageset/background-1-dark.png new file mode 100644 index 00000000..12690b0b Binary files /dev/null and b/apps/mobile/ios/Lux/Images.xcassets/SplashScreenBackground.imageset/background-1-dark.png differ diff --git a/apps/mobile/ios/Lux/Images.xcassets/SplashScreenBackground.imageset/background-1.png b/apps/mobile/ios/Lux/Images.xcassets/SplashScreenBackground.imageset/background-1.png new file mode 100644 index 00000000..ff3dd16f Binary files /dev/null and b/apps/mobile/ios/Lux/Images.xcassets/SplashScreenBackground.imageset/background-1.png differ diff --git a/apps/mobile/ios/Lux/Images.xcassets/SplashScreenBackground.imageset/background-2.png b/apps/mobile/ios/Lux/Images.xcassets/SplashScreenBackground.imageset/background-2.png new file mode 100644 index 00000000..ff3dd16f Binary files /dev/null and b/apps/mobile/ios/Lux/Images.xcassets/SplashScreenBackground.imageset/background-2.png differ diff --git a/apps/mobile/ios/Lux/Images.xcassets/SplashScreenBackground.imageset/background-3.png b/apps/mobile/ios/Lux/Images.xcassets/SplashScreenBackground.imageset/background-3.png new file mode 100644 index 00000000..ff3dd16f Binary files /dev/null and b/apps/mobile/ios/Lux/Images.xcassets/SplashScreenBackground.imageset/background-3.png differ diff --git a/apps/mobile/ios/Lux/Images.xcassets/SplashScreenBackground.imageset/background-4.png b/apps/mobile/ios/Lux/Images.xcassets/SplashScreenBackground.imageset/background-4.png new file mode 100644 index 00000000..ff3dd16f Binary files /dev/null and b/apps/mobile/ios/Lux/Images.xcassets/SplashScreenBackground.imageset/background-4.png differ diff --git a/apps/mobile/ios/Lux/Images.xcassets/SplashScreenBackground.imageset/background-5.png b/apps/mobile/ios/Lux/Images.xcassets/SplashScreenBackground.imageset/background-5.png new file mode 100644 index 00000000..ff3dd16f Binary files /dev/null and b/apps/mobile/ios/Lux/Images.xcassets/SplashScreenBackground.imageset/background-5.png differ diff --git a/apps/mobile/ios/Lux/Images.xcassets/SplashScreenBackground.imageset/background.png b/apps/mobile/ios/Lux/Images.xcassets/SplashScreenBackground.imageset/background.png new file mode 100644 index 00000000..ff3dd16f Binary files /dev/null and b/apps/mobile/ios/Lux/Images.xcassets/SplashScreenBackground.imageset/background.png differ diff --git a/apps/mobile/ios/Lux/Info.plist b/apps/mobile/ios/Lux/Info.plist new file mode 100644 index 00000000..3b19089e --- /dev/null +++ b/apps/mobile/ios/Lux/Info.plist @@ -0,0 +1,166 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + Lux + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconName + AppIcon + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleLocalizations + + en + zh-Hans + zh-Hant + fr + ja + pt + vi + + es-419 + es-BZ + es-CU + es-DO + es-GT + es-HN + es-MX + es-NI + es-PA + es-PE + es-PR + es-SV + es-US + + es-AR + es-BO + es-CL + es-CO + es-CR + es-EC + es-ES + es-PY + es-UY + es-VE + + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleSignature + ???? + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLName + lux + CFBundleURLSchemes + + lux + + + + CFBundleTypeRole + Viewer + CFBundleURLName + org.lux.wc + CFBundleURLSchemes + + wc + + + + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + ITSAppUsesNonExemptEncryption + + LSApplicationCategoryType + + LSApplicationQueriesSchemes + + itms-apps + itms-beta + + LSRequiresIPhoneOS + + LSMinimumSystemVersion + 15.1 + NSAdvertisingAttributionReportEndpoint + https://appsflyer-skadnetwork.com/ + NSAppTransportSecurity + + NSExceptionDomains + + localhost + + NSExceptionAllowsInsecureHTTPLoads + + + + + NSCameraUsageDescription + $(PRODUCT_NAME) Wallet needs access to your Camera to scan QR codes + NSFaceIDUsageDescription + Enabling Face ID helps $(PRODUCT_NAME) Wallet keep your assets secure. + NSLocationAlwaysAndWhenInUseUsageDescription + $(PRODUCT_NAME) Wallet does not require access to your location. + NSLocationWhenInUseUsageDescription + $(PRODUCT_NAME) Wallet does not require access to your location. + NSMicrophoneUsageDescription + $(PRODUCT_NAME) Wallet does not require access to the microphone. + NSPhotoLibraryUsageDescription + $(PRODUCT_NAME) Wallet needs access to your Camera Roll to choose an avatar for your username + NSUbiquitousContainers + + iCloud.Lux + + NSUbiquitousContainerIsDocumentScopePublic + + NSUbiquitousContainerName + Lux + NSUbiquitousContainerSupportedFolderLevels + Any + + + NSUserActivityTypes + + TokenPriceConfigurationIntent + + OneSignal_app_groups_key + group.com.lux.mobile.onesignal + OneSignal_suppress_launch_urls + + UIAppFonts + + InputMono-Regular.ttf + Basel-Grotesk-Book.otf + Basel-Grotesk-Medium.otf + + UIBackgroundModes + + remote-notification + + UILaunchStoryboardName + SplashScreen.storyboard + UIRequiredDeviceCapabilities + + arm64 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + + UIViewControllerBasedStatusBarAppearance + + + diff --git a/apps/mobile/ios/Lux/Lux-Bridging-Header.h b/apps/mobile/ios/Lux/Lux-Bridging-Header.h new file mode 100644 index 00000000..80080e9e --- /dev/null +++ b/apps/mobile/ios/Lux/Lux-Bridging-Header.h @@ -0,0 +1,23 @@ +// +// Lux-Bridging-Header.h +// Lux +// +// Bridging header for Swift/Objective-C interoperability +// + +#import +#import +#import +#import +#import +#import +#import +#import +#import +#import +#import +#import +#import +#import "libethers_ffi.h" + +// Import any other Objective-C headers that need to be accessible from Swift \ No newline at end of file diff --git a/apps/mobile/ios/Lux/Lux.entitlements b/apps/mobile/ios/Lux/Lux.entitlements new file mode 100644 index 00000000..da61471f --- /dev/null +++ b/apps/mobile/ios/Lux/Lux.entitlements @@ -0,0 +1,36 @@ + + + + + aps-environment + development + com.apple.developer.associated-domains + + applinks:lux.exchange + applinks:app.lux.exchange + applinks:app.corn-staging.com + webcredentials:app.lux.exchange + webcredentials:lux.exchange + webcredentials:unihq.org + + com.apple.developer.devicecheck.appattest-environment + production + com.apple.developer.icloud-container-identifiers + + iCloud.Lux + + com.apple.developer.icloud-services + + CloudDocuments + + com.apple.developer.ubiquity-container-identifiers + + iCloud.Lux + + com.apple.security.application-groups + + group.com.lux.widgets + group.com.lux.mobile.onesignal + + + diff --git a/apps/mobile/ios/Lux/Notifications/SilentPushEventEmitter.m b/apps/mobile/ios/Lux/Notifications/SilentPushEventEmitter.m new file mode 100644 index 00000000..ee706b38 --- /dev/null +++ b/apps/mobile/ios/Lux/Notifications/SilentPushEventEmitter.m @@ -0,0 +1,18 @@ +// +// SilentPushEventEmitter.m +// Lux +// +// Created by John Short on 9/29/25. +// + +#import +#import +#import + +@interface RCT_EXTERN_MODULE(SilentPushEventEmitter, RCTEventEmitter) + +RCT_EXTERN_METHOD(supportedEvents) +RCT_EXTERN_METHOD(addListener:(NSString *)eventName) +RCT_EXTERN_METHOD(removeListeners:(nonnull NSNumber *)count) + +@end diff --git a/apps/mobile/ios/Lux/Notifications/SilentPushEventEmitter.swift b/apps/mobile/ios/Lux/Notifications/SilentPushEventEmitter.swift new file mode 100644 index 00000000..575d1609 --- /dev/null +++ b/apps/mobile/ios/Lux/Notifications/SilentPushEventEmitter.swift @@ -0,0 +1,31 @@ +// +// SilentPushEventEmitter.swift +// Lux +// +// Created by John Short on 9/29/25. +// + +import React + +@objc(SilentPushEventEmitter) +open class SilentPushEventEmitter: RCTEventEmitter { + + public static weak var emitter: RCTEventEmitter? + + override init() { + super.init() + SilentPushEventEmitter.emitter = self + } + + open override func supportedEvents() -> [String] { + ["SilentPushReceived"] + } + + @objc(emitEventWithPayload:) + public static func emitEvent(with payload: [String: Any]) { + guard let emitter = emitter else { + return + } + emitter.sendEvent(withName: "SilentPushReceived", body: payload) + } +} diff --git a/apps/mobile/ios/Lux/Onboarding/Backup/MnemonicConfirmationManager.m b/apps/mobile/ios/Lux/Onboarding/Backup/MnemonicConfirmationManager.m new file mode 100644 index 00000000..c23df048 --- /dev/null +++ b/apps/mobile/ios/Lux/Onboarding/Backup/MnemonicConfirmationManager.m @@ -0,0 +1,31 @@ +// +// RNTMnemonicTestManager.m +// Lux +// +// Created by Thomas Thachil 8/1/2022. +// + +#import "Lux-Swift.h" +#import +#import "RNSwiftUI-Bridging-Header.h" + +@interface MnemonicConfirmationManager : RCTViewManager +@end + +@implementation MnemonicConfirmationManager +RCT_EXPORT_MODULE() + +RCT_EXPORT_SWIFTUI_PROPERTY(mnemonicId, NSString, MnemonicConfirmationView); +RCT_EXPORT_SWIFTUI_PROPERTY(shouldShowSmallText, BOOL, MnemonicConfirmationView); +RCT_EXPORT_SWIFTUI_CALLBACK(onConfirmComplete, RCTDirectEventBlock, MnemonicConfirmationView); +RCT_EXPORT_SWIFTUI_PROPERTY(selectedWordPlaceholder, NSString, MnemonicConfirmationView); + +- (UIView *)view +{ + MnemonicConfirmationView *proxy = [[MnemonicConfirmationView alloc] init]; + UIView *view = [proxy view]; + NSMutableDictionary *storage = [MnemonicConfirmationView storage]; + storage[[NSValue valueWithNonretainedObject:view]] = proxy; + return view; +} +@end diff --git a/apps/mobile/ios/Lux/Onboarding/Backup/MnemonicConfirmationView.swift b/apps/mobile/ios/Lux/Onboarding/Backup/MnemonicConfirmationView.swift new file mode 100644 index 00000000..e29eff26 --- /dev/null +++ b/apps/mobile/ios/Lux/Onboarding/Backup/MnemonicConfirmationView.swift @@ -0,0 +1,166 @@ +// +// MnemonicConfirmationView.swift +// Lux +// +// Created by Thomas Thachil on 8/1/22. +// + +import React +import SwiftUI + +@objcMembers class MnemonicConfirmationView: NSObject { + private var vc = UIHostingController(rootView: MnemonicConfirmation()) + + static let storage = NSMutableDictionary() + + var mnemonicId: String { + set { vc.rootView.setMnemonicId(mnemonicId: newValue) } + get { return vc.rootView.props.mnemonicId } + } + + var selectedWordPlaceholder: String { + set { vc.rootView.props.selectedWordPlaceholder = newValue} + get { return vc.rootView.props.selectedWordPlaceholder } + } + + var shouldShowSmallText: Bool { + set { vc.rootView.props.shouldShowSmallText = newValue} + get { return vc.rootView.props.shouldShowSmallText } + } + + var onConfirmComplete: RCTDirectEventBlock { + set { vc.rootView.props.onConfirmComplete = newValue } + get { return vc.rootView.props.onConfirmComplete } + } + + var view: UIView { + vc.view.backgroundColor = .clear + return vc.view + } +} + +class MnemonicConfirmationProps : ObservableObject { + @Published var mnemonicId: String = "" + @Published var selectedWordPlaceholder: String = "" + @Published var shouldShowSmallText: Bool = false + @Published var onConfirmComplete: RCTDirectEventBlock = { _ in } + @Published var mnemonicWords: [String] = Array(repeating: "", count: 12) + @Published var scrambledWords: [String] = Array(repeating: "", count: 12) + @Published var typedWordIndexes: [Int] = Array(repeating: -1, count: 12) + @Published var selectedIndex: Int = 0 +} + +struct MnemonicConfirmation: View { + + @ObservedObject var props = MnemonicConfirmationProps() + + let rnEthersRS = RNEthersRS() + + func setMnemonicId(mnemonicId: String) { + props.mnemonicId = mnemonicId + if let mnemonic = rnEthersRS.retrieveMnemonic(mnemonicId: mnemonicId) { + props.mnemonicWords = mnemonic.components(separatedBy: " ") + props.scrambledWords = mnemonic.components(separatedBy: " ").shuffled() + } + } + + func onSuggestionTapped(tappedIndex: Int) { + props.typedWordIndexes[props.selectedIndex] = tappedIndex + + // Check if typed words match mnemonic words only if all fields are filled + if (props.selectedIndex == props.mnemonicWords.count - 1) { + if (isMnemonicMatch()) { + props.onConfirmComplete([:]) + } + } else if (props.mnemonicWords[props.selectedIndex] == props.scrambledWords[tappedIndex] && props.selectedIndex < props.mnemonicWords.count - 1) { + props.selectedIndex += 1 + } + } + + func isMnemonicMatch() -> Bool { + for i in 0.. String { + guard index >= 0 && index < props.typedWordIndexes.count else { + return "" + } + + let scrambledWordIndex = props.typedWordIndexes[index] + if scrambledWordIndex == -1 { + return "" + } + + return props.scrambledWords[scrambledWordIndex] + } + + func getFieldText(index: Int) -> String { + let typedWord = getTypedWord(index: index) + return typedWord.isEmpty ? props.selectedWordPlaceholder : typedWord + } + + func getFieldStatus(index: Int) -> MnemonicInputStatus { + let typedWord = getTypedWord(index: index) + + if (typedWord.isEmpty) { + return MnemonicInputStatus.noInput + } else if (props.mnemonicWords[index] != typedWord) { + return MnemonicInputStatus.wrongInput + } + return MnemonicInputStatus.correctInput + } + + var body: some View { + let end = props.mnemonicWords.count - 1 + let middle = end / 2 + + VStack(alignment: .leading, spacing: 0) { + HStack(alignment: .center, spacing: 24) { + VStack(alignment: .leading, spacing: 8) { + ForEach((0...middle), id: \.self) {index in + MnemonicTextField(index: index + 1, + word: getFieldText(index: index), + status: getFieldStatus(index: index), + shouldShowSmallText: props.shouldShowSmallText + ) + .frame(maxWidth: .infinity, alignment: .leading) + } + }.frame(maxWidth: .infinity) + VStack(alignment: .leading, spacing: 8) { + ForEach((middle + 1...end), id: \.self) {index in + MnemonicTextField(index: index + 1, + word: getFieldText(index: index), + status: getFieldStatus(index: index), + shouldShowSmallText: props.shouldShowSmallText + ) + .frame(maxWidth: .infinity, alignment: .leading) + } + }.frame(maxWidth: .infinity) + }.frame(maxWidth: .infinity) + .padding(EdgeInsets(top: 24, leading: 32, bottom: 24, trailing: 32)) + .background(Colors.surface2) + .cornerRadius(20) + .overlay( + RoundedRectangle(cornerRadius: 20) + .stroke(Colors.surface3, lineWidth: 1) + ) + + MnemonicConfirmationWordBankView( + words: props.scrambledWords, + usedWordIndexes: props.typedWordIndexes, + labelCallback: onSuggestionTapped, + shouldShowSmallText: props.shouldShowSmallText + ) + .frame(maxWidth: .infinity) + .padding(.top, 24) + + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + } +} diff --git a/apps/mobile/ios/Lux/Onboarding/Backup/MnemonicConfirmationWordBankView.swift b/apps/mobile/ios/Lux/Onboarding/Backup/MnemonicConfirmationWordBankView.swift new file mode 100644 index 00000000..d90c6801 --- /dev/null +++ b/apps/mobile/ios/Lux/Onboarding/Backup/MnemonicConfirmationWordBankView.swift @@ -0,0 +1,94 @@ +// +// MnemonicTagsView.swift +// Lux +// +// Created by Thomas Thachil on 8/8/22. +// + +import SwiftUI + +struct BankWord: Hashable { + var index: Int + var word: String = "" + var used: Bool = false +} + +struct MnemonicConfirmationWordBankView: View { + let smallFont = UIFont(name: "BaselGrotesk-Book", size: 14) + let mediumFont = UIFont(name: "BaselGrotesk-Book", size: 16) + + var groupedWords: [[BankWord]] = [[BankWord]]() + let screenWidth = UIScreen.main.bounds.width // Used to calculate max number of tags per row + var labelCallback: ((Int) -> Void)? + let shouldShowSmallText: Bool + + init(words: [String], usedWordIndexes: [Int], labelCallback: @escaping (Int) -> Void, shouldShowSmallText: Bool) { + self.labelCallback = labelCallback + self.shouldShowSmallText = shouldShowSmallText + + // Ensure that proper words are displayed as used in case of duplicates + var wordStructs = words.enumerated().map { index, word in BankWord(index: index, word: word) } + usedWordIndexes.forEach{ idx in + if (idx != -1) { + wordStructs[idx].used = true + } + } + + // Set up grouped words + self.groupedWords = createGroupedWords(wordStructs) + } + + private func createGroupedWords(_ items: [BankWord]) -> [[BankWord]] { + var groupedItems: [[BankWord]] = [[BankWord]]() + var tempItems: [BankWord] = [BankWord]() + var width: CGFloat = 0 + + for word in items { + let label = UILabel() + label.text = word.word + label.sizeToFit() + + let labelWidth = label.frame.size.width + 32 + + if (width + labelWidth + 32) < screenWidth { + width += labelWidth + tempItems.append(word) + } else { + width = labelWidth + groupedItems.append(tempItems) + tempItems.removeAll() + tempItems.append(word) + } + } + + groupedItems.append(tempItems) + return groupedItems + } + + var body: some View { + VStack(alignment: .center) { + ForEach(groupedWords, id: \.self) { subItems in + HStack(spacing: shouldShowSmallText ? 4 : 8) { + ForEach(subItems, id: \.self) { bankWord in + Text(bankWord.word) + .font(Font((shouldShowSmallText ? smallFont : mediumFont)!)) + .fixedSize() + .padding(shouldShowSmallText ? EdgeInsets(top: 8, leading: 10, bottom: 8, trailing: 10) : EdgeInsets(top: 10, leading: 12, bottom: 10, trailing: 12)) + .background(Colors.surface1) + .foregroundColor(Colors.neutral1) + .clipShape(RoundedRectangle(cornerRadius: 100, style: .continuous)) + .shadow(color: Color.black.opacity(0.04), radius: 10) + .overlay( + RoundedRectangle(cornerRadius: 50) + .stroke(Colors.surface3, lineWidth: 1) + ) + .onTapGesture { + labelCallback?(bankWord.index) + } + .opacity(bankWord.used ? 0.5 : 1) + } + } + } + } + } +} diff --git a/apps/mobile/ios/Lux/Onboarding/Backup/MnemonicDisplayManager.m b/apps/mobile/ios/Lux/Onboarding/Backup/MnemonicDisplayManager.m new file mode 100644 index 00000000..6147790f --- /dev/null +++ b/apps/mobile/ios/Lux/Onboarding/Backup/MnemonicDisplayManager.m @@ -0,0 +1,32 @@ +// +// RNTMnemonicManager.m +// Lux +// +// Created by Spencer Yen on 5/24/22. +// + +#import "Lux-Swift.h" +#import +#import "RNSwiftUI-Bridging-Header.h" + +@interface MnemonicDisplayManager : RCTViewManager +@end + +@implementation MnemonicDisplayManager +RCT_EXPORT_MODULE(MnemonicDisplay) + +RCT_EXPORT_SWIFTUI_PROPERTY(mnemonicId, NSString, MnemonicDisplayView); +RCT_EXPORT_SWIFTUI_PROPERTY(copyText, NSString, MnemonicDisplayView); +RCT_EXPORT_SWIFTUI_PROPERTY(copiedText, NSString, MnemonicDisplayView); +RCT_EXPORT_SWIFTUI_CALLBACK(onHeightMeasured, RCTDirectEventBlock, MnemonicDisplayView) +RCT_EXPORT_SWIFTUI_CALLBACK(onEmptyMnemonic, RCTDirectEventBlock, MnemonicDisplayView) + +- (UIView *)view { + MnemonicDisplayView *proxy = [[MnemonicDisplayView alloc] init]; + UIView *view = [proxy view]; + NSMutableDictionary *storage = [MnemonicDisplayView storage]; + storage[[NSValue valueWithNonretainedObject:view]] = proxy; + return view; +} + +@end diff --git a/apps/mobile/ios/Lux/Onboarding/Backup/MnemonicDisplayView.swift b/apps/mobile/ios/Lux/Onboarding/Backup/MnemonicDisplayView.swift new file mode 100644 index 00000000..b232f5b0 --- /dev/null +++ b/apps/mobile/ios/Lux/Onboarding/Backup/MnemonicDisplayView.swift @@ -0,0 +1,155 @@ +// +// MnemonicDisplayView.swift +// Lux +// +// Created by Gary Ye on 8/31/23. +// + +import SwiftUI + +@objcMembers class MnemonicDisplayView: NSObject { + private var vc = UIHostingController(rootView: MnemonicDisplay()) + + static let storage = NSMutableDictionary() + + var mnemonicId: String { + set { vc.rootView.setMnemonicId(mnemonicId: newValue) } + get { return vc.rootView.props.mnemonicId } + } + + var copyText: String { + set { vc.rootView.props.copyText = newValue } + get { return vc.rootView.props.copyText } + } + + var copiedText: String { + set { vc.rootView.props.copiedText = newValue } + get { return vc.rootView.props.copiedText } + } + + var onHeightMeasured: RCTDirectEventBlock? { + didSet { + vc.rootView.props.onHeightMeasured = { [weak self] height in + self?.onHeightMeasured?([ "height": height ]) + } + } + } + + var onEmptyMnemonic: RCTDirectEventBlock? { + didSet { + vc.rootView.props.onEmptyMnemonic = { [weak self] mnemonicId in + self?.onEmptyMnemonic?(["mnemonicId": mnemonicId]) + } + } + } + + var view: UIView { + vc.view.backgroundColor = .clear + return vc.view + } +} + +class MnemonicDisplayProps: ObservableObject { + @Published var mnemonicId: String = "" + @Published var copyText: String = "" + @Published var copiedText: String = "" + @Published var mnemonicWords: [String] = Array(repeating: "", count: 12) + var onHeightMeasured: ((CGFloat) -> Void)? + var onEmptyMnemonic: ((String) -> Void)? +} + +struct MnemonicDisplay: View { + @ObservedObject var props = MnemonicDisplayProps() + @State private var buttonPadding: CGFloat = 20 + + let rnEthersRS = RNEthersRS() + let interFont = UIFont(name: "BaselGrotesk-Medium", size: 20) + + func setMnemonicId(mnemonicId: String) { + props.mnemonicId = mnemonicId + if let mnemonic = rnEthersRS.retrieveMnemonic(mnemonicId: mnemonicId) { + props.mnemonicWords = mnemonic.components(separatedBy: " ") + } + } + + var body: some View { + if (props.mnemonicWords.count > 12) { + ScrollView { + content + }.fadeOutBottom(fadeLength: 50) + } else { + content + } + } + + @ViewBuilder + var content: some View { + let end = props.mnemonicWords.count - 1 + let middle = end / 2 + + VStack(alignment: .leading, spacing: 0) { + ZStack { + HStack(alignment: .center, spacing: 24) { + VStack(alignment: .leading, spacing: 8) { + ForEach((0...middle), id: \.self) { index in + MnemonicTextField(index: index + 1, + word: props.mnemonicWords[index] + ) + .frame(maxWidth: .infinity, alignment: .leading) + } + }.frame(maxWidth: .infinity) + VStack(alignment: .leading, spacing: 8) { + ForEach((middle + 1...end), id: \.self) { index in + MnemonicTextField(index: index + 1, + word: props.mnemonicWords[index] + ) + .frame(maxWidth: .infinity, alignment: .leading) + } + }.frame(maxWidth: .infinity) + } + } + .frame(maxWidth: .infinity) + .padding(EdgeInsets(top: 24, leading: 32, bottom: 24, trailing: 32)) + .background(Colors.surface2) + .cornerRadius(20) + .overlay( + RoundedRectangle(cornerRadius: 20) + .stroke(Colors.surface3, lineWidth: 1) + ) + .overlay( + HStack { + Spacer() + RelativeOffsetView(y: -0.5, onOffsetCalculated: { _, offsetY in + buttonPadding = abs(offsetY) + }) { + CopyButton( + copyButtonText: props.copyText, + copiedButtonText: props.copiedText, + textToCopy: props.mnemonicWords.joined(separator: " ") + ) + } + Spacer() + }, + alignment: .top + ) + } + .frame(maxWidth: .infinity, alignment: .top) + .padding(.top, buttonPadding) + .overlay( + GeometryReader { geometry in + Color.clear + .onAppear { + props.onHeightMeasured?(geometry.size.height) + } + .onChange(of: geometry.size.height) { newValue in + props.onHeightMeasured?(newValue) + } + } + ) + .onAppear { + if props.mnemonicWords.isEmpty || props.mnemonicWords.allSatisfy({ $0.isEmpty }) { + props.onEmptyMnemonic?(props.mnemonicId) + } + } + } +} diff --git a/apps/mobile/ios/Lux/Onboarding/Backup/MnemonicTextField.swift b/apps/mobile/ios/Lux/Onboarding/Backup/MnemonicTextField.swift new file mode 100644 index 00000000..c9b412de --- /dev/null +++ b/apps/mobile/ios/Lux/Onboarding/Backup/MnemonicTextField.swift @@ -0,0 +1,61 @@ +// +// MnemonicTextField.swift +// Lux +// +// Created by Thomas Thachil on 8/8/22. +// + +import SwiftUI + +enum MnemonicInputStatus { + case noInput + case correctInput + case wrongInput +} + +struct MnemonicTextField: View { + let smallFont = UIFont(name: "BaselGrotesk-Book", size: 14) + let mediumFont = UIFont(name: "BaselGrotesk-Book", size: 16) + + var index: Int + var word = "" + var shouldShowSmallText: Bool + var status: MnemonicInputStatus + + init(index: Int, + word: String, + status: MnemonicInputStatus = .correctInput, + shouldShowSmallText: Bool = false + ) { + self.index = index + self.word = word + self.status = status + self.shouldShowSmallText = shouldShowSmallText + } + + func getLabelColor() -> Color { + switch (status) { + case .noInput: + return Colors.neutral3 + case .correctInput: + return Colors.neutral1 + case .wrongInput: + return Colors.statusCritical + } + } + + var body: some View { + HStack(alignment: VerticalAlignment.center, spacing: 18) { + Text(String(index)) + .font(Font((shouldShowSmallText ? smallFont : mediumFont)!)) + .foregroundColor(Colors.neutral2) + .frame(width: shouldShowSmallText ? 14 : 16, alignment: Alignment.leading) + + Text(word) + .font(Font((shouldShowSmallText ? smallFont : mediumFont)!)) + .foregroundColor(getLabelColor()) + .multilineTextAlignment(.leading) + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} diff --git a/apps/mobile/ios/Lux/Onboarding/Backup/RNSwiftUI-Bridging-Header.h b/apps/mobile/ios/Lux/Onboarding/Backup/RNSwiftUI-Bridging-Header.h new file mode 100644 index 00000000..3911e8bc --- /dev/null +++ b/apps/mobile/ios/Lux/Onboarding/Backup/RNSwiftUI-Bridging-Header.h @@ -0,0 +1,40 @@ +// +// RNSwiftUI-Bridging-Header.h +// Lux +// +// Created by Thomas Thachil on 8/3/22. +// +// + +#ifndef RNSwiftUI_Bridging_Header_h +#define RNSwiftUI_Bridging_Header_h + + +#import "React/RCTViewManager.h" +#import "React/RCTConvert.h" +#import "React/RCTComponentData.h" +#import "React/RCTBridgeModule.h" +#import "React/UIView+React.h" + +#define RCT_EXPORT_SWIFTUI_PROPERTY(name, type, proxyClass) \ +RCT_CUSTOM_VIEW_PROPERTY(name, type, proxyClass) { \ + NSMutableDictionary *storage = [proxyClass storage]; \ + proxyClass *proxy = storage[[NSValue valueWithNonretainedObject:view]]; \ + proxy.name = [RCTConvert type:json]; \ +} + +#define RCT_EXPORT_SWIFTUI_CALLBACK(name, type, proxyClass) \ +RCT_REMAP_VIEW_PROPERTY(name, __custom__, type) \ +- (void)set_##name:(id)json forView:(UIView *)view withDefaultView:(UIView *)defaultView RCT_DYNAMIC { \ + NSMutableDictionary *storage = [proxyClass storage]; \ + proxyClass *proxy = storage[[NSValue valueWithNonretainedObject:view]]; \ + void (^eventHandler)(NSDictionary *event) = ^(NSDictionary *event) { \ + RCTComponentEvent *componentEvent = [[RCTComponentEvent alloc] initWithName:@""#name \ + viewTag:view.reactTag \ + body:event]; \ + [self.bridge.eventDispatcher sendEvent:componentEvent]; \ + }; \ + proxy.name = eventHandler; \ +} + +#endif /* RNSwiftUI_Bridging_Header_h */ diff --git a/apps/mobile/ios/Lux/Onboarding/EmbeddedWallet/EmbeddedWallet.m b/apps/mobile/ios/Lux/Onboarding/EmbeddedWallet/EmbeddedWallet.m new file mode 100644 index 00000000..9ec07e91 --- /dev/null +++ b/apps/mobile/ios/Lux/Onboarding/EmbeddedWallet/EmbeddedWallet.m @@ -0,0 +1,21 @@ +// +// EmbeddedWallet.m +// Lux +// +// Created by Bruno R. Nunes on 11/5/24. +// + +#import +#import + +@interface RCT_EXTERN_MODULE(EmbeddedWallet, RCTEventEmitter) + +RCT_EXTERN_METHOD(decryptMnemonicForPublicKey:(NSString *)encryptedMnemonic + publicKeyBase64:(NSString *)publicKeyBase64 + resolver:(RCTPromiseResolveBlock)resolver + rejecter:(RCTPromiseRejectBlock)rejecter) + +RCT_EXTERN_METHOD(generateKeyPair:(RCTPromiseResolveBlock)resolver + rejecter:(RCTPromiseRejectBlock)rejecter) + +@end diff --git a/apps/mobile/ios/Lux/Onboarding/EmbeddedWallet/EmbeddedWallet.swift b/apps/mobile/ios/Lux/Onboarding/EmbeddedWallet/EmbeddedWallet.swift new file mode 100644 index 00000000..6f4d52d9 --- /dev/null +++ b/apps/mobile/ios/Lux/Onboarding/EmbeddedWallet/EmbeddedWallet.swift @@ -0,0 +1,199 @@ +// +// EmbeddedWallet.swift +// Lux +// +// Created by Bruno R. Nunes on 11/5/24. +// + +import Foundation + +@objc(EmbeddedWallet) +class EmbeddedWallet: NSObject { + + private let queue = DispatchQueue(label: "com.uniswap.EmbeddedWallet", qos: .background) + + private var publicKeyPairMap: [String: SecKey] = [:] + + @objc static func requiresMainQueueSetup() -> Bool { + return false + } + + /** + Decrypts an encrypted mnemonic using the private key corresponding to the provided public key. + + - parameter encryptedMnemonic: The Base64 encoded encrypted mnemonic. + - parameter publicKeyBase64: The Base64 encoded public key in SPKI format. + - returns: Resolves with the decrypted mnemonic. + */ + @objc func decryptMnemonicForPublicKey(_ encryptedMnemonic: String, publicKeyBase64: String, resolver: @escaping RCTPromiseResolveBlock, rejecter: @escaping RCTPromiseRejectBlock) { + queue.async { + guard let privateKey = self.publicKeyPairMap[publicKeyBase64] else { + let error = NSError(domain: "EmbeddedWalletModule", code: 404, userInfo: [NSLocalizedDescriptionKey: "Key pair not found for public key \(publicKeyBase64)"]) + rejecter("KEY_PAIR_NOT_FOUND", "Key pair not found for public key \(publicKeyBase64)", error) + return + } + + do { + let decryptedSeedPhrase = try decryptMnemonic(encryptedMnemonic: encryptedMnemonic, privateKey: privateKey) + self.publicKeyPairMap.removeValue(forKey: publicKeyBase64) + resolver(decryptedSeedPhrase) + } catch { + rejecter("DECRYPTION_FAILED", "Failed to decrypt mnemonic", error) + } + } + } + + /** + Generates an RSA key pair for encrypting/decrypting seed phrases. + Stores the private key in memory in publicKeyPairMap, to be used later for decryption. + + - returns: Resolves with the Base64 encoded public key in SPKI format. + */ + @objc func generateKeyPair(_ resolver: @escaping RCTPromiseResolveBlock, rejecter: @escaping RCTPromiseRejectBlock) { + queue.async { + do { + let (publicKey, privateKey) = try generateRSAKeyPair() + self.publicKeyPairMap[publicKey] = privateKey + resolver(publicKey) + } catch { + rejecter("KEY_PAIR_GENERATION_FAILED", "Failed to generate RSA key pair", error) + } + } + } +} + +enum EmbeddedWalletError: Error { + case keyGenerationFailed + case keyExportFailed + case invalidEncryptedData + case decryptionFailed + case invalidDecryptedData +} + +/** + Generates an RSA key pair for encrypting/decrypting seed phrases. + Matches the web implementation using RSA-OAEP with SHA-256. + + - returns: A tuple containing the Base64 encoded public key in SPKI format and the SecKeyPair for later decryption. + */ +func generateRSAKeyPair() throws -> (publicKeyBase64: String, privateKey: SecKey) { + // Define key pair attributes + let keyPairAttr: [String: Any] = [ + kSecAttrKeyType as String: kSecAttrKeyTypeRSA, + kSecAttrKeySizeInBits as String: 2048, + kSecPrivateKeyAttrs as String: [ + kSecAttrIsPermanent as String: false + ] + ] + + var error: Unmanaged? + + // Generate private key using SecKeyCreateRandomKey + guard let privateKey = SecKeyCreateRandomKey(keyPairAttr as CFDictionary, &error) else { + if let error = error?.takeRetainedValue() { + throw error + } + throw EmbeddedWalletError.keyGenerationFailed + } + + // Obtain the public key from the private key + guard let publicKey = SecKeyCopyPublicKey(privateKey) else { + throw EmbeddedWalletError.keyGenerationFailed + } + + // Export the public key in SPKI format + let spkiData = try exportPublicKeySPKI(publicKey: publicKey) + let publicKeyBase64 = spkiData.base64EncodedString() + + return (publicKeyBase64, privateKey) +} + +/** + Decrypts an encrypted seed phrase using the provided RSA private key. + + - parameter encryptedMnemonic: The Base64 encoded encrypted seed phrase. + - parameter privateKey: The RSA private key for decryption. + - returns: The decrypted seed phrase string. + */ +func decryptMnemonic(encryptedMnemonic: String, privateKey: SecKey) throws -> String { + guard let encryptedData = Data(base64Encoded: encryptedMnemonic) else { + throw EmbeddedWalletError.invalidEncryptedData + } + + var error: Unmanaged? + guard let decryptedData = SecKeyCreateDecryptedData(privateKey, .rsaEncryptionOAEPSHA256, encryptedData as CFData, &error) as Data? else { + throw EmbeddedWalletError.decryptionFailed + } + + guard let decryptedString = String(data: decryptedData, encoding: .utf8) else { + throw EmbeddedWalletError.invalidDecryptedData + } + + return decryptedString +} + +enum ASN1Error: Error { + case encodingFailed +} + +// OID for rsaEncryption: 1.2.840.113549.1.1.1 +let rsaOID: [UInt8] = [ + 0x30, 0x0D, // SEQUENCE, length 13 + 0x06, 0x09, // OBJECT IDENTIFIER, length 9 + 0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x01, 0x01, + 0x05, 0x00 // NULL +] + +func encodeASN1Length(_ length: Int) -> Data { + if length < 128 { + // Short form: single byte + return Data([UInt8(length)]) + } else { + // Determine the number of bytes needed to represent the length + var tempLength = length + var lengthBytes: [UInt8] = [] + while tempLength > 0 { + lengthBytes.insert(UInt8(tempLength & 0xFF), at: 0) + tempLength >>= 8 + } + let lengthOfLength = UInt8(lengthBytes.count) + // First byte: 0x80 | number of length bytes + var encoded = Data([0x80 | lengthOfLength]) + encoded.append(contentsOf: lengthBytes) + return encoded + } +} + +func encodeASN1Sequence(_ sequence: Data) -> Data { + var encoded = Data([0x30]) // SEQUENCE tag + let length = encodeASN1Length(sequence.count) + encoded.append(length) + encoded.append(sequence) + return encoded +} + +func encodeASN1BitString(_ bitString: Data) -> Data { + var encoded = Data([0x03]) // BIT STRING tag + // Add a leading 0x00 to indicate no unused bits + let bitStringWithPadding = Data([0x00]) + bitString + let length = encodeASN1Length(bitStringWithPadding.count) + encoded.append(length) + encoded.append(bitStringWithPadding) + return encoded +} + +func exportPublicKeySPKI(publicKey: SecKey) throws -> Data { + // Retrieve the raw public key data (PKCS#1) + guard let keyData = SecKeyCopyExternalRepresentation(publicKey, nil) as Data? else { + throw ASN1Error.encodingFailed + } + + // Encode the AlgorithmIdentifier (rsaEncryption OID with NULL parameters) + let algorithmIdentifier = Data(rsaOID) + // Encode the BIT STRING + let bitString = encodeASN1BitString(keyData) + // Combine AlgorithmIdentifier and BIT STRING into SubjectPublicKeyInfo SEQUENCE + let subjectPublicKeyInfo = encodeASN1Sequence(algorithmIdentifier + bitString) + + return subjectPublicKeyInfo +} diff --git a/apps/mobile/ios/Lux/Onboarding/Import/SeedPhraseInputManager.m b/apps/mobile/ios/Lux/Onboarding/Import/SeedPhraseInputManager.m new file mode 100644 index 00000000..2f8ff94a --- /dev/null +++ b/apps/mobile/ios/Lux/Onboarding/Import/SeedPhraseInputManager.m @@ -0,0 +1,25 @@ +// +// SeedPhraseInputManager.swift +// Lux +// +// Created by Gary Ye on 9/7/23. +// + +#import "React/RCTViewManager.h" + +@interface RCT_EXTERN_MODULE(SeedPhraseInputManager, RCTViewManager) + +RCT_EXPORT_VIEW_PROPERTY(targetMnemonicId, NSString?) +RCT_EXPORT_VIEW_PROPERTY(strings, NSDictionary) +RCT_EXPORT_VIEW_PROPERTY(onInputValidated, RCTDirectEventBlock); +RCT_EXPORT_VIEW_PROPERTY(onMnemonicStored, RCTDirectEventBlock); +RCT_EXPORT_VIEW_PROPERTY(onPasteStart, RCTDirectEventBlock); +RCT_EXPORT_VIEW_PROPERTY(onPasteEnd, RCTDirectEventBlock); +RCT_EXPORT_VIEW_PROPERTY(onSubmitError, RCTDirectEventBlock); +RCT_EXPORT_VIEW_PROPERTY(onHeightMeasured, RCTDirectEventBlock); +RCT_EXPORT_VIEW_PROPERTY(testID, NSString?) +RCT_EXTERN_METHOD(handleSubmit: (nonnull NSNumber *)node) +RCT_EXTERN_METHOD(focus: (nonnull NSNumber *)node) +RCT_EXTERN_METHOD(blur: (nonnull NSNumber *)node) + +@end diff --git a/apps/mobile/ios/Lux/Onboarding/Import/SeedPhraseInputManager.swift b/apps/mobile/ios/Lux/Onboarding/Import/SeedPhraseInputManager.swift new file mode 100644 index 00000000..c9f37ae4 --- /dev/null +++ b/apps/mobile/ios/Lux/Onboarding/Import/SeedPhraseInputManager.swift @@ -0,0 +1,48 @@ +// +// SeedPhraseInputManager.swift +// Lux +// +// Created by Gary Ye on 9/15/23. +// + +// Using a view manager written in Swift instead of bridging headers +// because couldn't get RCT_EXTERN_METHOD to work with that approach +@objc(SeedPhraseInputManager) +class SeedPhraseInputManager: RCTViewManager { + + override func view() -> UIView! { + return SeedPhraseInputView() + } + + // Required by RN to initialize on main thread + override class func requiresMainQueueSetup() -> Bool { + true + } + + @objc func focus(_ node: NSNumber) -> Void { + DispatchQueue.main.async { + let component = self.bridge.uiManager.view(forReactTag: node) as? SeedPhraseInputView + + component?.focus() + } + } + + @objc func blur(_ node: NSNumber) -> Void { + DispatchQueue.main.async { + let component = self.bridge.uiManager.view(forReactTag: node) as? SeedPhraseInputView + + component?.blur() + } + } + + @objc func handleSubmit(_ node: NSNumber) -> Void { + DispatchQueue.main.async { + let component = self.bridge.uiManager.view( + forReactTag: node + ) as? SeedPhraseInputView + + component?.handleSubmit() + // TODO garydebug add error logging for view not found + } + } +} diff --git a/apps/mobile/ios/Lux/Onboarding/Import/SeedPhraseInputView.swift b/apps/mobile/ios/Lux/Onboarding/Import/SeedPhraseInputView.swift new file mode 100644 index 00000000..e37ae0e9 --- /dev/null +++ b/apps/mobile/ios/Lux/Onboarding/Import/SeedPhraseInputView.swift @@ -0,0 +1,321 @@ +// +// SeedPhraseInputView.swift +// Lux +// +// Created by Gary Ye on 9/7/23. +// + +import React +import SwiftUI + +class SeedPhraseInputView: UIView { + private let vc = UIHostingController(rootView: SeedPhraseInput()) + + override init(frame: CGRect) { + super.init(frame: frame) + + vc.view.translatesAutoresizingMaskIntoConstraints = false + vc.view.backgroundColor = .clear + + self.addSubview(vc.view) + + NSLayoutConstraint.activate([ + vc.view.topAnchor.constraint(equalTo: self.topAnchor), + vc.view.bottomAnchor.constraint(equalTo: self.bottomAnchor), + vc.view.leadingAnchor.constraint(equalTo: self.leadingAnchor), + vc.view.trailingAnchor.constraint(equalTo: self.trailingAnchor) + ]) + } + + required init?(coder aDecoder: NSCoder) { + // Used to load view into storyboarder + fatalError("init(coder:) has not been implemented") + } + + override func reactSetFrame(_ frame: CGRect) { + super.reactSetFrame(frame); + vc.view.frame = frame + } + + @objc + var targetMnemonicId: String? { + get { vc.rootView.viewModel.targetMnemonicId } + set { vc.rootView.viewModel.targetMnemonicId = newValue } + } + + @objc + var strings: Dictionary { + get { vc.rootView.viewModel.rawRNStrings } + set { vc.rootView.viewModel.rawRNStrings = newValue } + } + + @objc + var onInputValidated: RCTDirectEventBlock { + get { vc.rootView.viewModel.onInputValidated } + set { vc.rootView.viewModel.onInputValidated = newValue } + } + + @objc + var onMnemonicStored: RCTDirectEventBlock { + set { vc.rootView.viewModel.onMnemonicStored = newValue } + get { return vc.rootView.viewModel.onMnemonicStored } + } + + @objc + var onPasteStart: RCTDirectEventBlock { + set { vc.rootView.viewModel.onPasteStart = newValue } + get { return vc.rootView.viewModel.onPasteStart } + } + + @objc + var onPasteEnd: RCTDirectEventBlock { + set { vc.rootView.viewModel.onPasteEnd = newValue } + get { return vc.rootView.viewModel.onPasteEnd } + } + + @objc + var onSubmitError: RCTDirectEventBlock { + set { vc.rootView.viewModel.onSubmitError = newValue } + get { return vc.rootView.viewModel.onSubmitError } + } + + @objc + func focus() { + vc.rootView.focusInput() + } + + @objc + func blur() { + vc.rootView.blurInput() + } + + @objc + var onHeightMeasured: RCTDirectEventBlock { + set { vc.rootView.viewModel.onHeightMeasured = newValue } + get { return vc.rootView.viewModel.onHeightMeasured } + } + + @objc + var testID: String? { + get { vc.rootView.viewModel.testID } + set { vc.rootView.viewModel.testID = newValue } + } + + @objc + var handleSubmit: () -> Void { + get { return vc.rootView.viewModel.handleSubmit } + } +} + +struct TextEditModifier: ViewModifier { + func body(content: Content) -> some View { + if #available(iOS 16.0, *) { + content + .scrollContentBackground(.hidden) + } else { + content + .onAppear { + UITextView.appearance().backgroundColor = .clear + } + } + } +} + +// We use UIKit and UIViewRepresentable instead of SwiftUI to have better control of focus state here +// At the time of updating this, there were limitations of @FocusState in SwiftUI, particularly in relation to dynamic updates + interactions from outside the view +// So, we use this approach to maintain the focus state +struct SeedPhraseTextView: UIViewRepresentable { + @Binding var text: String + @Binding var isFocused: Bool + + class Coordinator: NSObject, UITextViewDelegate { + var parent: SeedPhraseTextView + + init(_ parent: SeedPhraseTextView) { + self.parent = parent + } + + func textViewDidChange(_ textView: UITextView) { + parent.text = textView.text + } + + func textViewDidBeginEditing(_ textView: UITextView) { + // Sync isFocused binding when user focuses the input + DispatchQueue.main.async { + self.parent.isFocused = true + } + } + + func textViewDidEndEditing(_ textView: UITextView) { + // Sync isFocused binding when user blurs the input + DispatchQueue.main.async { + self.parent.isFocused = false + } + } + } + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + func makeUIView(context: Context) -> UITextView { + let textView = UITextView() + + textView.delegate = context.coordinator + textView.font = UIFont(name: "BaselGrotesk-Book", size: 17) + textView.autocorrectionType = .no + textView.autocapitalizationType = .none + textView.backgroundColor = UIColor.clear + + return textView + } + + func updateUIView(_ uiView: UITextView, context: Context) { + uiView.text = text + + if isFocused { + if !uiView.isFirstResponder, uiView.window != nil { + uiView.becomeFirstResponder() + } + } else { + if uiView.isFirstResponder { + uiView.resignFirstResponder() + } + } + } +} + +struct SeedPhraseInput: View { + @ObservedObject var viewModel = SeedPhraseInputViewModel() + + private var font = Font(UIFont(name: "BaselGrotesk-Book", size: 17)!) + private var subtitleFont = Font(UIFont(name: "BaselGrotesk-Book", size: 17)!) + private var labelFont = Font(UIFont(name: "BaselGrotesk-Book", size: 15)!) + private var buttonFont = Font(UIFont(name: "BaselGrotesk-Medium", size: 15)!) + + func focusInput() { + viewModel.isFocused = true + } + + func blurInput() { + viewModel.isFocused = false + } + + var body: some View { + VStack(spacing: 12) { + VStack { + VStack { + ZStack(alignment: .topLeading) { + SeedPhraseTextView(text: $viewModel.input, isFocused: $viewModel.isFocused) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + .modifier(TextEditModifier()) + .frame(minHeight: 96) // 120 - 2 * 12 for padding + .accessibility(identifier: viewModel.testID ?? "import-account-input") + if (viewModel.input.isEmpty) { + Text(viewModel.strings.inputPlaceholder) + .font(subtitleFont) + .padding(.horizontal, 6) + .padding(.vertical, 8) + .foregroundColor(Colors.neutral3) + .allowsHitTesting(false) // Prevents capturing touch events by the placeholder instead of input + } + } + .fixedSize(horizontal: false, vertical: true) + .background(Colors.surface1) + .padding(12) // Adds to default TextEditor padding 8 + .cornerRadius(16) + .overlay( + RoundedRectangle(cornerRadius: 16) + .inset(by: 1) + .stroke(mapStatusToColor(status: viewModel.status), lineWidth: 1) + ) + .onTapGesture { + self.focusInput() + } + .overlay( + Group { + if viewModel.input.isEmpty { + HStack { + Spacer() + RelativeOffsetView(y: 0.5) { + PasteButton( + pasteButtonText: viewModel.strings.pasteButton, + onPaste: handlePaste, + onPasteStart: { + viewModel.onPasteStart([:]) + }, + onPasteEnd: { + viewModel.onPasteEnd([:]) + } + ) + } + Spacer() + } + } + }, + alignment: .bottom + ) + }.padding(.bottom, 12) + + + if (errorMessage() != nil) { + HStack(spacing: 4) { + AlertTriangleIcon() + .frame(width: 14, height: 14) + .foregroundColor(Colors.statusCritical) + Text(errorMessage() ?? "") + .font(labelFont) + .foregroundColor(Colors.statusCritical) + } + .frame(alignment: .center) + } + } + .overlay( + GeometryReader { geometry in + Color.clear + .onAppear { + viewModel.onHeightMeasured(["height": geometry.size.height]) + } + .onChange(of: geometry.size.height) { newValue in + viewModel.onHeightMeasured(["height": newValue]) + } + } + ) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .font(font) + } + + private func mapStatusToColor(status: SeedPhraseInputViewModel.Status) -> Color { + switch viewModel.status { + case .none: + return Colors.surface3 + case .valid: + return Colors.statusSuccess + case .error: + return Colors.statusCritical + } + } + + private func errorMessage() -> String? { + switch viewModel.error { + case .invalidPhrase: + return viewModel.strings.errorInvalidPhrase + case .invalidWord(let word): + return "\(viewModel.strings.errorInvalidWord) \(word)" + case .tooManyWords, .notEnoughWords: + return viewModel.strings.errorPhraseLength + case .wrongRecoveryPhrase: + return viewModel.strings.errorWrongPhrase + case .wordIsAddress: + return viewModel.strings.errorWordIsAddress + default: + return nil + } + } + + private func handlePaste(pastedText: String) { + viewModel.input = pastedText + } +} diff --git a/apps/mobile/ios/Lux/Onboarding/Import/SeedPhraseInputViewModel.swift b/apps/mobile/ios/Lux/Onboarding/Import/SeedPhraseInputViewModel.swift new file mode 100644 index 00000000..c05faec9 --- /dev/null +++ b/apps/mobile/ios/Lux/Onboarding/Import/SeedPhraseInputViewModel.swift @@ -0,0 +1,219 @@ +// +// SeedPhraseInputViewModel.swift +// Lux +// +// Created by Gary Ye on 9/10/23. +// + +import Foundation + +class SeedPhraseInputViewModel: ObservableObject { + + enum Status: String { + case none + case valid + case error + } + + enum MnemonicError: Equatable { + case invalidPhrase + case invalidWord(String) + case notEnoughWords + case tooManyWords + case wrongRecoveryPhrase + case wordIsAddress + } + + struct ReactNativeStrings { + var inputPlaceholder: String + var pasteButton: String + var errorInvalidWord: String + var errorPhraseLength: String + var errorWrongPhrase: String + var errorInvalidPhrase: String + var errorWordIsAddress: String + } + + let rnEthersRS = RNEthersRS() + + // Following block of variables will come from RN + @Published var targetMnemonicId: String? = nil + + @Published var testID: String? = nil + + @Published var rawRNStrings: Dictionary = Dictionary() { + didSet { + strings = ReactNativeStrings( + inputPlaceholder: rawRNStrings["inputPlaceholder"] ?? "", + pasteButton: rawRNStrings["pasteButton"] ?? "", + errorInvalidWord: rawRNStrings["errorInvalidWord"] ?? "", + errorPhraseLength: rawRNStrings["errorPhraseLength"] ?? "", + errorWrongPhrase: rawRNStrings["errorWrongPhrase"] ?? "", + errorInvalidPhrase: rawRNStrings["errorInvalidPhrase"] ?? "", + errorWordIsAddress: rawRNStrings["errorWordIsAddress"] ?? "" + ) + } + } + @Published var strings: ReactNativeStrings = ReactNativeStrings( + inputPlaceholder: "", + pasteButton: "", + errorInvalidWord: "", + errorPhraseLength: "", + errorWrongPhrase: "", + errorInvalidPhrase: "", + errorWordIsAddress: "" + ) + @Published var onInputValidated: RCTDirectEventBlock = { _ in } + @Published var onMnemonicStored: RCTDirectEventBlock = { _ in } + @Published var onPasteStart: RCTDirectEventBlock = { _ in } + @Published var onPasteEnd: RCTDirectEventBlock = { _ in } + @Published var onSubmitError: RCTDirectEventBlock = { _ in } + @Published var onHeightMeasured: RCTDirectEventBlock = { _ in } + + private var lastWordValidationTimer: Timer? + private let lastWordValidationTimeout: TimeInterval = 1.0 + + @Published var input = "" { + didSet { + handleInputChange() + } + } + + @Published var isFocused = false + + @Published var skipLastWord = true + @Published var status: Status = .none + @Published var error: MnemonicError? = nil + + private let minCount = 12 + private let maxCount = 24 + + func handleSubmit() { + lastWordValidationTimer?.invalidate() + + let normalized = normalizeInput(value: input) + let mnemonic = trimInput(value: normalized) + let words = mnemonic.components(separatedBy: " ") + let valid = rnEthersRS.validateMnemonic(mnemonic: mnemonic) + + error = nil + if (words.count < minCount || minCount + 1 ..< maxCount ~= words.count) { + status = .error + error = .notEnoughWords + } else if (words.count > maxCount) { + status = .error + error = .tooManyWords + } else if (!valid) { + status = .error + error = .invalidPhrase + } else { + submitMnemonic(mnemonic: mnemonic) + } + + if (error != nil) { + onSubmitError([:]) + } + } + + private func submitMnemonic(mnemonic: String) { + if (targetMnemonicId != nil) { + rnEthersRS.generateAddressForMnemonic( + mnemonic: mnemonic, + derivationIndex: 0, + resolve: { mnemonicId in + if (targetMnemonicId == String(describing: mnemonicId ?? "")) { + storeMnemonic(mnemonic: mnemonic) + } else { + status = .error + error = .wrongRecoveryPhrase + onSubmitError([:]) + } + }, reject: { code, message, error in + onSubmitError([:]) + print("SeedPhraseInputView model error while generating address: \(message ?? "")") + }) + } else { + storeMnemonic(mnemonic: mnemonic) + } + } + + private func storeMnemonic(mnemonic: String) { + rnEthersRS.importMnemonic( + mnemonic: mnemonic, + resolve: { mnemonicId in + onMnemonicStored(["mnemonicId": String(describing: mnemonicId ?? "")]) + }, + reject: { code, message, error in + onSubmitError([:]) + print("SeedPhraseInputView model error while storing mnemonic: \(message ?? "")") + } + ) + } + + private func normalizeInput(value: String) -> String { + return value.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression).lowercased() + } + + private func isAddress(value: String) -> Bool { + return value.starts(with: "0x") && value.count == 42 + } + + private func trimInput(value: String) -> String { + return value.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private func handleInputChange() { + let normalized = normalizeInput(value: input) + let skipLastWord = normalized.last != " " + let skipInvalidWord = skipLastWord && !isAddress(value: normalized) + validateInput(normalizedInput: normalized, skipInvalidWord: skipInvalidWord) + + lastWordValidationTimer?.invalidate() + + if (skipLastWord) { + lastWordValidationTimer = Timer.scheduledTimer( + withTimeInterval: lastWordValidationTimeout, + repeats: false) { _ in + DispatchQueue.global(qos: .background).async { + self.validateInput(normalizedInput: normalized, skipInvalidWord: false) + } + } + } + } + + private func validateInput(normalizedInput: String, skipInvalidWord: Bool) { + let mnemonic = trimInput(value: normalizedInput) + + let words = mnemonic.components(separatedBy: " ") + + let isValidLength = words.count >= minCount && words.count <= maxCount + let firstInvalidWord = rnEthersRS.findInvalidWord(mnemonic: mnemonic) + + let isAddress = mnemonic.starts(with: "0x") && mnemonic.count == 42 + let isFirstWordInvalid = firstInvalidWord == words.last && skipInvalidWord + let isInvalidLengthError = (error == .notEnoughWords || error == .tooManyWords) && !isValidLength + + if (isFirstWordInvalid) { + return + } else if (isAddress) { + status = .error + error = .wordIsAddress + } else if (firstInvalidWord != "") { + status = .error + error = .invalidWord(firstInvalidWord) + } else if (isInvalidLengthError) { + return + } else if (firstInvalidWord == "" && isValidLength) { + status = .valid + } else{ + status = .none + } + + if (status != .error) { + error = nil + } + + let canSubmit = error == nil && mnemonic != "" + onInputValidated(["canSubmit": canSubmit]) + } +} diff --git a/apps/mobile/ios/Lux/Onboarding/Scantastic/EncryptionUtils.swift b/apps/mobile/ios/Lux/Onboarding/Scantastic/EncryptionUtils.swift new file mode 100644 index 00000000..01e19f76 --- /dev/null +++ b/apps/mobile/ios/Lux/Onboarding/Scantastic/EncryptionUtils.swift @@ -0,0 +1,139 @@ +// +// EncryptionUtils.swift +// Lux +// +// Created by Christine Legge on 1/23/24. +// + +import CryptoKit +import Foundation + +enum EncryptionError: Error { + case invalidModulus + case invalidExponent + case invalidPublicKey + case unknown +} + +// Convert Base64URL to Base64 and add padding if necessary +func Base64URLToBase64(base64url: String) -> String { + var base64 = base64url + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + if base64.count % 4 != 0 { + base64.append(String(repeating: "=", count: 4 - base64.count % 4)) + } + return base64 +} + +// Calculate the length field for an ASN.1 sequence. +func lengthField(of valueField: [UInt8]) throws -> [UInt8] { + var count = valueField.count + + if count < 128 { + return [ UInt8(count) ] + } + + // The number of bytes needed to encode count. + let lengthBytesCount = Int((log2(Double(count)) / 8) + 1) + + // The first byte in the length field encoding the number of remaining bytes. + let firstLengthFieldByte = UInt8(128 + lengthBytesCount) + + var lengthField: [UInt8] = [] + for _ in 0..> 8 + } + + // Include the first byte. + lengthField.insert(firstLengthFieldByte, at: 0) + + return lengthField +} + +func generatePublicRSAKey(modulus: String, exponent: String) throws -> SecKey { + // Lets encode them from b64 url to b64 + let encodedModulus = Base64URLToBase64(base64url: modulus) + let encodedExponent = Base64URLToBase64(base64url: exponent) + + // First we need to get our modulus and exponent into UInt8 arrays + // We can do this by decoding the Base64 strings (URL safe) into Data + // and then converting the Data into UInt8 arrays + guard let modulusData = Data(base64Encoded: encodedModulus) else { + throw EncryptionError.invalidModulus + } + + guard let exponentData = Data(base64Encoded: encodedExponent) else { + throw EncryptionError.invalidExponent + } + + var modulus = modulusData.withUnsafeBytes { Data(Array($0)).withUnsafeBytes { Array($0) } } + let exponent = exponentData.withUnsafeBytes { Data(Array($0)).withUnsafeBytes { Array($0) } } + + // Lets add 0x00 at the front of the modulus + modulus.insert(0x00, at: 0) + + var sequenceEncoded: [UInt8] = [] + do { + // encode as integers + var modulusEncoded: [UInt8] = [] + modulusEncoded.append(0x02) + modulusEncoded.append(contentsOf: try lengthField(of: modulus)) + modulusEncoded.append(contentsOf: modulus) + + var exponentEncoded: [UInt8] = [] + exponentEncoded.append(0x02) + exponentEncoded.append(contentsOf: try lengthField(of: exponent)) + exponentEncoded.append(contentsOf: exponent) + + sequenceEncoded.append(0x30) + sequenceEncoded.append(contentsOf: try lengthField(of: (modulusEncoded + exponentEncoded))) + sequenceEncoded.append(contentsOf: (modulusEncoded + exponentEncoded)) + } catch { + throw EncryptionError.invalidPublicKey + } + + let keyData = Data(sequenceEncoded) + + // RSA key size is the number of bits of the modulus. + let keySize = (modulus.count * 8) + + let attributes: [String: Any] = [ + kSecAttrKeyType as String: kSecAttrKeyTypeRSA, + kSecAttrKeyClass as String: kSecAttrKeyClassPublic, + kSecAttrKeySizeInBits as String: keySize + ] + + guard let publicKey = SecKeyCreateWithData(keyData as CFData, attributes as CFDictionary, nil) else { + throw EncryptionError.invalidPublicKey + } + + return publicKey +} + +func encryptForStorage(plaintext: String, publicKey: SecKey) throws -> Data +{ + // Encrypt the plaintext + let plaintextData = Data(plaintext.utf8) + let algorithm: SecKeyAlgorithm = .rsaEncryptionOAEPSHA256 + + guard SecKeyIsAlgorithmSupported(publicKey, .encrypt, algorithm) else { + throw EncryptionError.invalidPublicKey + } + + var error: Unmanaged? + guard let ciphertextData = SecKeyCreateEncryptedData(publicKey, algorithm, plaintextData as CFData, &error) else { + if let error = error { + throw error.takeRetainedValue() as Error + } else { + throw EncryptionError.unknown + } + } + + return ciphertextData as Data +} diff --git a/apps/mobile/ios/Lux/Onboarding/Scantastic/ScantasticEncryption.m b/apps/mobile/ios/Lux/Onboarding/Scantastic/ScantasticEncryption.m new file mode 100644 index 00000000..5380fbf5 --- /dev/null +++ b/apps/mobile/ios/Lux/Onboarding/Scantastic/ScantasticEncryption.m @@ -0,0 +1,19 @@ +// +// ScantasticEncryption.m +// Lux +// +// Created by Christine Legge on 1/23/24. +// + +#import +#import + +@interface RCT_EXTERN_MODULE(ScantasticEncryption, RCTEventEmitter) + +RCT_EXTERN_METHOD(getEncryptedMnemonic: (NSString *)mnemonicId + n: (NSString *)n + e: (NSString *)e + resolve: (RCTPromiseResolveBlock)resolve + reject: (RCTPromiseRejectBlock)reject) + +@end diff --git a/apps/mobile/ios/Lux/Onboarding/Scantastic/ScantasticEncryption.swift b/apps/mobile/ios/Lux/Onboarding/Scantastic/ScantasticEncryption.swift new file mode 100644 index 00000000..c1fa4961 --- /dev/null +++ b/apps/mobile/ios/Lux/Onboarding/Scantastic/ScantasticEncryption.swift @@ -0,0 +1,62 @@ +// +// ScantasticEncryption.swift +// Lux +// +// Created by Christine Legge on 1/23/24. +// + +import Foundation +import CryptoKit + +enum ScantasticError: String, Error { + case publicKeyError = "publicKeyError" + case cipherTextError = "cipherTextError" +} + +@objc(ScantasticEncryption) +class ScantasticEncryption: RCTEventEmitter { + let rnEthersRS = RNEthersRS() + + @objc override static func requiresMainQueueSetup() -> Bool { + return false + } + + override func supportedEvents() -> [String]! { + return [] + } + + /** + Retrieves encrypted mnemonic + + - parameter mnemonicId: key string associated with mnemonic to backup + - parameter n: base64encoded value + - parameter e: base64encoded value + */ + @objc(getEncryptedMnemonic:n:e:resolve:reject:) + func getEncryptedMnemonic( + mnemonicId: String, n: String, e: String, resolve: RCTPromiseResolveBlock, + reject: RCTPromiseRejectBlock + ) { + + guard let mnemonic = rnEthersRS.retrieveMnemonic(mnemonicId: mnemonicId) else { + return reject(RNEthersRSError.retrieveMnemonicError.rawValue, "Failed to retrieve mnemonic", RNEthersRSError.retrieveMnemonicError) + } + + let publicKey: SecKey + do { + publicKey = try generatePublicRSAKey(modulus: n, exponent: e) + } catch { + return reject(ScantasticError.publicKeyError.rawValue, "Failed to generate public Key ", ScantasticError.publicKeyError) + } + + let encodedCiphertext: Data + do { + encodedCiphertext = try encryptForStorage(plaintext:mnemonic,publicKey:publicKey) + } catch { + return reject(ScantasticError.cipherTextError.rawValue, "Failed to encrypt the mnemonic", ScantasticError.cipherTextError) + } + + let b64encodedCiphertext = encodedCiphertext.base64EncodedString() + return resolve(b64encodedCiphertext) + } +} diff --git a/apps/mobile/ios/Lux/PrivacyInfo.xcprivacy b/apps/mobile/ios/Lux/PrivacyInfo.xcprivacy new file mode 100644 index 00000000..02a8a056 --- /dev/null +++ b/apps/mobile/ios/Lux/PrivacyInfo.xcprivacy @@ -0,0 +1,50 @@ + + + + + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryFileTimestamp + NSPrivacyAccessedAPITypeReasons + + C617.1 + 0A2A.1 + 3B52.1 + + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryUserDefaults + NSPrivacyAccessedAPITypeReasons + + CA92.1 + 1C8F.1 + C56D.1 + + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryDiskSpace + NSPrivacyAccessedAPITypeReasons + + E174.1 + 85F4.1 + + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategorySystemBootTime + NSPrivacyAccessedAPITypeReasons + + 35F9.1 + + + + NSPrivacyCollectedDataTypes + + NSPrivacyTracking + + + diff --git a/apps/mobile/ios/Lux/RNCloudBackupsManager/EncryptionHelper.swift b/apps/mobile/ios/Lux/RNCloudBackupsManager/EncryptionHelper.swift new file mode 100644 index 00000000..254cf8c3 --- /dev/null +++ b/apps/mobile/ios/Lux/RNCloudBackupsManager/EncryptionHelper.swift @@ -0,0 +1,85 @@ +// +// EncryptionHelper.swift +// Lux +// +// Created by Spencer Yen on 7/26/22. +// + +import CryptoKit +import Argon2Swift + +/** + Encrypts given secret using AES-GCM cipher secured by symmetric key derived from given password. + + - parameter secret: plaintext secret to encrypt + - parameter password: password to generate encryption key + - parameter salt: randomized data string used with password to generate encryption key + - returns: encrypted secret string + */ +func encrypt(secret: String, password: String, salt: String) throws -> String { + let key = try keyFromPassword(password: password, salt: salt) + let secretData = secret.data(using: .utf8)! + + // Encrypt data into SealedBox, return as string + let sealedBox = try AES.GCM.seal(secretData, using: key) + let encryptedData = sealedBox.combined + let encryptedSecret = encryptedData!.base64EncodedString() + + return encryptedSecret +} + +/** + Attempts to decrypt AES-GCM encrypted secret using symmetric key derived from given user pin. + + - parameter encryptedSecret: secret in cipher encrypted form + - parameter password: password to generate encryption key + - parameter salt: randomized data string used when secret was originally encrypted + - returns: decrypted secret string + */ +func decrypt(encryptedSecret: String, password: String, salt: String) throws -> String { + let key = try keyFromPassword(password: password, salt: salt) + + // Recreate SealedBox from encrypted string + let encryptedData = Data(base64Encoded: encryptedSecret)! + let sealedBox = try AES.GCM.SealedBox(combined: encryptedData) + + // Decrypt SealedBox and decode result to string + let decryptedData = try AES.GCM.open(sealedBox, using: key) + let decryptedSecret = String(data: decryptedData, encoding: .utf8)! + + return decryptedSecret +} + +/** + Generate encryption key from user specified password and randomized salt using argon2id. + + The parameters used for Argon2 are based on recommended values from the security audit and the Argon2 RFC (https://datatracker.ietf.org/doc/rfc9106/) + The memory and iterations values are tuned based on benchmark timing tests to take ~1s (see EncryptionHelperTests) + - Mode: argon2id + - Parallelism: 4 + - Memory: 128MiB (2^17 KiB) + - Hash length: 32 bytes + - Iterations: 3 + + - parameter password: password to generate encryption key + - parameter salt: randomized data string used with password to generate encryption key + - returns: SymmetricKey to be used with CryptoKit encryption functions + */ + +func keyFromPassword(password: String, salt: String) throws -> SymmetricKey { + let derivedKey = try Argon2Swift.hashPasswordString(password: password, salt: Salt(bytes: Data(salt.utf8)), iterations: 3, memory: 2 << 16, parallelism: 4, length: 32, type: .id) + let key = SymmetricKey(data: derivedKey.hashData()) + return key +} + +/** + Generate random data string of specified byte length + + - parameter length:number of bytes for random data string + - returns: randomized string + */ +func generateSalt(length: Int) -> String { + var bytes = [UInt8](repeating: 0, count: length) + _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) + return Data(bytes).base64EncodedString() +} diff --git a/apps/mobile/ios/Lux/RNCloudBackupsManager/RNCloudStorageBackupsManager.m b/apps/mobile/ios/Lux/RNCloudBackupsManager/RNCloudStorageBackupsManager.m new file mode 100644 index 00000000..53d8578b --- /dev/null +++ b/apps/mobile/ios/Lux/RNCloudBackupsManager/RNCloudStorageBackupsManager.m @@ -0,0 +1,33 @@ +// +// RNCloudStorageBackupsManager.m +// Lux +// +// Created by Spencer Yen on 7/13/22. +// + +#import +#import + +@interface RCT_EXTERN_MODULE(RNCloudStorageBackupsManager, RCTEventEmitter) + +RCT_EXTERN_METHOD(isCloudStorageAvailable: (RCTPromiseResolveBlock)resolve + reject: (RCTPromiseRejectBlock)reject) + +RCT_EXTERN_METHOD(backupMnemonicToCloudStorage: (NSString *)mnemonicId + password: (NSString *)password + resolve: (RCTPromiseResolveBlock)resolve + reject: (RCTPromiseRejectBlock)reject) + +RCT_EXTERN_METHOD(restoreMnemonicFromCloudStorage: (NSString *)mnemonicId + password: (NSString *)password + resolve: (RCTPromiseResolveBlock)resolve + reject: (RCTPromiseRejectBlock)reject) + +RCT_EXTERN_METHOD(deleteCloudStorageMnemonicBackup: (NSString *)mnemonicId + resolve: (RCTPromiseResolveBlock)resolve + reject: (RCTPromiseRejectBlock)reject) + +RCT_EXTERN_METHOD(getCloudBackupList: (RCTPromiseResolveBlock)resolve + reject: (RCTPromiseRejectBlock)reject) + +@end diff --git a/apps/mobile/ios/Lux/RNCloudBackupsManager/RNCloudStorageBackupsManager.swift b/apps/mobile/ios/Lux/RNCloudBackupsManager/RNCloudStorageBackupsManager.swift new file mode 100644 index 00000000..9ee1e850 --- /dev/null +++ b/apps/mobile/ios/Lux/RNCloudBackupsManager/RNCloudStorageBackupsManager.swift @@ -0,0 +1,232 @@ +// +// RNCloudStorageBackupsManager.swift +// Lux +// +// Created by Spencer Yen on 7/13/22. +// + +import Foundation +import CryptoKit + +struct CloudStorageMnemonicBackup: Codable { + let mnemonicId: String + let encryptedMnemonic: String + let encryptionSalt: String + let createdAt: Double +} + +enum ICloudBackupError: String, Error { + case backupNotFoundError = "backupNotFoundError" + case backupEncryptionError = "backupEncryptionError" + case backupDecryptionError = "backupDecryptionError" + case backupIncorrectPasswordError = "backupIncorrectPasswordError" + case deleteBackupError = "deleteBackupError" + case iCloudContainerError = "iCloudContainerError" + case iCloudError = "iCloudError" +} + +@objc(RNCloudStorageBackupsManager) +class RNCloudStorageBackupsManager: NSObject, RCTBridgeModule { + + let rnEthersRS = RNEthersRS() + + static func moduleName() -> String! { + return "RNCloudStorageBackupsManager" + } + + @objc static func requiresMainQueueSetup() -> Bool { + return false + } + + /** + Determine if iCloud Documents is available on device + + - returns: boolean if iCloud Documents is available or not + */ + @objc(isCloudStorageAvailable:reject:) + func isCloudStorageAvailable(resolve: RCTPromiseResolveBlock, + reject: RCTPromiseRejectBlock + ) { + if FileManager.default.ubiquityIdentityToken == nil { + return resolve(false) + } else { + return resolve(true) + } + } + + /** + Stores mnemonic to iCloud Documents + + - parameter mnemonicId: key string associated with mnemonic to backup + - parameter password: user provided password to encrypt the mnemonic + - returns: true if successful, otherwise throws an error + */ + @objc(backupMnemonicToCloudStorage:password:resolve:reject:) + func backupMnemonicToCloudStorage( + mnemonicId: String, password: String, resolve: RCTPromiseResolveBlock, + reject: RCTPromiseRejectBlock + ) { + guard let mnemonic = rnEthersRS.retrieveMnemonic(mnemonicId: mnemonicId) else { + return reject(RNEthersRSError.retrieveMnemonicError.rawValue, "Failed to retrieve mnemonic", RNEthersRSError.retrieveMnemonicError) + } + + // Access Uniswap iCloud Documents container + guard let containerUrl = FileManager.default.url(forUbiquityContainerIdentifier: nil) else { + return reject(ICloudBackupError.iCloudError.rawValue, "Failed to find iCloud container", ICloudBackupError.iCloudError) + } + + // Create iCloud container if empty + if !FileManager.default.fileExists(atPath: containerUrl.path, isDirectory: nil) { + do { + try FileManager.default.createDirectory(at: containerUrl, withIntermediateDirectories: true, attributes: nil) + } catch { + return reject(ICloudBackupError.iCloudError.rawValue, "Failed to create iCloud container \(error)", ICloudBackupError.iCloudError) + } + } + + let encryptedMnemonic: String + let encryptionSalt: String + do { + encryptionSalt = generateSalt(length: 16) + encryptedMnemonic = try encrypt(secret: mnemonic, password: password, salt: encryptionSalt) + } catch { + return reject(ICloudBackupError.backupEncryptionError.rawValue, "Failed to password encrypt mnemonic", ICloudBackupError.backupEncryptionError) + } + + // Write backup file to iCloud + let iCloudFileURL = containerUrl.appendingPathComponent("\(mnemonicId).json") + do { + let backup = CloudStorageMnemonicBackup(mnemonicId: mnemonicId, encryptedMnemonic: encryptedMnemonic, encryptionSalt: encryptionSalt, createdAt: Date().timeIntervalSince1970) + try JSONEncoder().encode(backup).write(to: iCloudFileURL) + return resolve(true) + } catch { + return reject(ICloudBackupError.iCloudError.rawValue, "Failed to write backup file to iCloud", ICloudBackupError.iCloudError) + } + } + + /** + + Attempts to restore mnemonic into native keychain from iCloud backup file. Assumes that the backup file `[mnemonicId].json` has already been downloaded from iCloud Documents using `RNCloudStorageBackupsManager` + + - parameter mnemonicId: key string associated with JSON backup file stored in iCloud + - parameter password: user inputted password used to decrypt backup + - returns: true if mnemonic successfully restored, otherwise a relevant error will be thrown + */ + @objc(restoreMnemonicFromCloudStorage:password:resolve:reject:) + func restoreMnemonicFromCloudStorage(mnemonicId: String, password: String, resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock + ) { + // Access Uniswap iCloud Documents container + guard let containerUrl = FileManager.default.url(forUbiquityContainerIdentifier: nil) else { + return reject(ICloudBackupError.iCloudError.rawValue, "Failed to find iCloud container", ICloudBackupError.iCloudError) + } + + // Fetch backup file from iCloud + let iCloudFileURL = containerUrl.appendingPathComponent("\(mnemonicId).json") + + guard FileManager.default.fileExists(atPath: iCloudFileURL.path) else { + return reject(ICloudBackupError.iCloudError.rawValue, "Failed to locate iCloud backup", ICloudBackupError.iCloudError) + } + + let data = try? Data(contentsOf: iCloudFileURL) + guard let backup = try? JSONDecoder().decode(CloudStorageMnemonicBackup.self, from: data!) else { + return reject(ICloudBackupError.iCloudError.rawValue, "Failed to load iCloud backup", ICloudBackupError.iCloudError) + } + + let decryptedMnemonic: String + do { + decryptedMnemonic = try decrypt(encryptedSecret: backup.encryptedMnemonic, password: password, salt: backup.encryptionSalt) + } catch CryptoKitError.authenticationFailure { + return reject(ICloudBackupError.backupIncorrectPasswordError.rawValue, "Invalid password. Please try again.", ICloudBackupError.backupIncorrectPasswordError) + } catch { + return reject(ICloudBackupError.backupDecryptionError.rawValue, "Failed to password decrypt mnemonic", ICloudBackupError.backupDecryptionError) + } + + // Restore mnemonic from backup into native keychain + let res = rnEthersRS.storeNewMnemonic(mnemonic: decryptedMnemonic, address: backup.mnemonicId) + if res == nil { + return reject(RNEthersRSError.storeMnemonicError.rawValue, "Failed to restore mnemonic into native keychain", RNEthersRSError.storeMnemonicError) + } + + return resolve(true) + } + + /** + Deletes mnemonic backup in iCloud Documents container + + - parameter mnemonicId: mnemonic backup filename to delete + - returns: boolean if deletion successful, otherwise throws error + */ + @objc(deleteCloudStorageMnemonicBackup:resolve:reject:) + func deleteCloudStorageMnemonicBackup(mnemonicId: String, resolve: @escaping RCTPromiseResolveBlock, + reject: @escaping RCTPromiseRejectBlock) { + // Access Uniswap iCloud Documents container + guard let containerUrl = FileManager.default.url(forUbiquityContainerIdentifier: nil) else { + return reject(ICloudBackupError.iCloudContainerError.rawValue, "Failed to find iCloud container", ICloudBackupError.iCloudContainerError) + } + + // Ensure backup file exists + let iCloudFileURL = containerUrl.appendingPathComponent("\(mnemonicId).json") + guard FileManager.default.fileExists(atPath: iCloudFileURL.path) else { + return reject(ICloudBackupError.backupNotFoundError.rawValue, "Failed to locate iCloud backup", ICloudBackupError.backupNotFoundError) + } + + // Delete backup file from iCloud + DispatchQueue.global(qos: .default).async { + let fileCoordinator = NSFileCoordinator(filePresenter: nil) + fileCoordinator.coordinate(writingItemAt: URL(fileURLWithPath: iCloudFileURL.path), options: NSFileCoordinator.WritingOptions.forDeleting, error: nil) { + url in + do { + try FileManager.default.removeItem(at: url) + return resolve(true) + } catch { + return reject(ICloudBackupError.deleteBackupError.rawValue, "Failed to delete iCloud backup", ICloudBackupError.deleteBackupError) + } + } + } + } + + /** + Starts NSMetadataQuery to discover backup files stored in iCloud Documents. Initializes listeners to handle downloading and sending found backups to JS. + + Referenced sample implementation here: https://developer.apple.com/documentation/uikit/documents_data_and_pasteboard/synchronizing_documents_in_the_icloud_environment + */ + @objc(getCloudBackupList:reject:) + func getCloudBackupList(resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) { + // Access Uniswap iCloud Documents container + guard let containerUrl = FileManager.default.url(forUbiquityContainerIdentifier: nil) else { + return reject(ICloudBackupError.iCloudError.rawValue, "Failed to find iCloud container", ICloudBackupError.iCloudError) + } + + // Try to list all JSON files in the iCloud Documents container + do { + let directoryContents = try FileManager.default.contentsOfDirectory(at: containerUrl, includingPropertiesForKeys: nil) + + // Filter only .json files + let jsonFiles = directoryContents.filter { $0.pathExtension == "json" } + + if jsonFiles.isEmpty { + return reject(ICloudBackupError.iCloudError.rawValue, "No backup files found", ICloudBackupError.iCloudError) + } + + // Serializable type to send it via bridge + var backups = [[String : Any]]() + + for file in jsonFiles { + if let data = try? Data(contentsOf: file.absoluteURL), + let backup = try? JSONDecoder().decode(CloudStorageMnemonicBackup.self, from: data) { + backups.append([ + "mnemonicId": backup.mnemonicId, + "createdAt": backup.createdAt + ]) + } else { + print("Error reading or decoding iCloud backup JSON at \(file.absoluteURL)") + } + } + + return resolve(backups) + + } catch { + return reject(ICloudBackupError.iCloudError.rawValue, "Failed to read iCloud directory", ICloudBackupError.iCloudError) + } + } +} diff --git a/apps/mobile/ios/Lux/RNEthersRs/KeychainConstants.swift b/apps/mobile/ios/Lux/RNEthersRs/KeychainConstants.swift new file mode 100644 index 00000000..1ed71d4f --- /dev/null +++ b/apps/mobile/ios/Lux/RNEthersRs/KeychainConstants.swift @@ -0,0 +1,12 @@ +// +// KeychainConstants.swift +// Lux +// +// Created by Thomas Thachil on 2/3/25. +// + +let prefix = "com.lux.mobile" +let mnemonicPrefix = ".mnemonic." +let privateKeyPrefix = ".privateKey." +let entireMnemonicPrefix = prefix + mnemonicPrefix +let entirePrivateKeyPrefix = prefix + privateKeyPrefix diff --git a/apps/mobile/ios/Lux/RNEthersRs/KeychainSwiftDistrib.swift b/apps/mobile/ios/Lux/RNEthersRs/KeychainSwiftDistrib.swift new file mode 100644 index 00000000..9017d78e --- /dev/null +++ b/apps/mobile/ios/Lux/RNEthersRs/KeychainSwiftDistrib.swift @@ -0,0 +1,486 @@ +// +// Keychain helper for iOS/Swift. +// +// https://github.com/evgenyneu/keychain-swift +// +// This file was automatically generated by combining multiple Swift source files. +// + +// ---------------------------- +// +// KeychainSwift.swift +// +// ---------------------------- +import Security +import Foundation + +/** +A collection of helper functions for saving text and data in the keychain. +*/ +open class KeychainSwift { + + var lastQueryParameters: [String: Any]? // Used by the unit tests + + /// Contains result code from the last operation. Value is noErr (0) for a successful result. + open var lastResultCode: OSStatus = noErr + + var keyPrefix = "" // Can be useful in test. + + /** + Specify an access group that will be used to access keychain items. Access groups can be used to share keychain items between applications. When access group value is nil all application access groups are being accessed. Access group name is used by all functions: set, get, delete and clear. + */ + open var accessGroup: String? + + + /** + + Specifies whether the items can be synchronized with other devices through iCloud. Setting this property to true will + add the item to other devices with the `set` method and obtain synchronizable items with the `get` command. Deleting synchronizable items will remove them from all devices. In order for keychain synchronization to work the user must enable "Keychain" in iCloud settings. + + Does not work on macOS. + + */ + open var synchronizable: Bool = false + + private let lock = NSLock() + + + /// Instantiate a KeychainSwift object + public init() { } + + /** + + - parameter keyPrefix: a prefix that is added before the key in get/set methods. Note that `clear` method still clears everything from the Keychain. + */ + public init(keyPrefix: String) { + self.keyPrefix = keyPrefix + } + + /** + + Stores the text value in the keychain item under the given key. + + - parameter key: Key under which the text value is stored in the keychain. + - parameter value: Text string to be written to the keychain. + - parameter withAccess: Value that indicates when your app needs access to the text in the keychain item. By default the .AccessibleWhenUnlocked option is used that permits the data to be accessed only while the device is unlocked by the user. + + - returns: True if the text was successfully written to the keychain. + */ + @discardableResult + open func set(_ value: String, forKey key: String, + withAccess access: KeychainSwiftAccessOptions? = nil) -> Bool { + + if let value = value.data(using: String.Encoding.utf8) { + return set(value, forKey: key, withAccess: access) + } + + return false + } + + /** + + Stores the data in the keychain item under the given key. + + - parameter key: Key under which the data is stored in the keychain. + - parameter value: Data to be written to the keychain. + - parameter withAccess: Value that indicates when your app needs access to the text in the keychain item. By default the .AccessibleWhenUnlocked option is used that permits the data to be accessed only while the device is unlocked by the user. + + - returns: True if the text was successfully written to the keychain. + + */ + @discardableResult + open func set(_ value: Data, forKey key: String, + withAccess access: KeychainSwiftAccessOptions? = nil) -> Bool { + + // The lock prevents the code to be run simultaneously + // from multiple threads which may result in crashing + lock.lock() + defer { lock.unlock() } + + deleteNoLock(key) // Delete any existing key before saving it + let accessible = access?.value ?? KeychainSwiftAccessOptions.defaultOption.value + + let prefixedKey = keyWithPrefix(key) + + var query: [String : Any] = [ + KeychainSwiftConstants.klass : kSecClassGenericPassword, + KeychainSwiftConstants.attrAccount : prefixedKey, + KeychainSwiftConstants.valueData : value, + KeychainSwiftConstants.accessible : accessible + ] + + query = addAccessGroupWhenPresent(query) + query = addSynchronizableIfRequired(query, addingItems: true) + lastQueryParameters = query + + lastResultCode = SecItemAdd(query as CFDictionary, nil) + + return lastResultCode == noErr + } + + /** + Stores the boolean value in the keychain item under the given key. + - parameter key: Key under which the value is stored in the keychain. + - parameter value: Boolean to be written to the keychain. + - parameter withAccess: Value that indicates when your app needs access to the value in the keychain item. By default the .AccessibleWhenUnlocked option is used that permits the data to be accessed only while the device is unlocked by the user. + - returns: True if the value was successfully written to the keychain. + */ + @discardableResult + open func set(_ value: Bool, forKey key: String, + withAccess access: KeychainSwiftAccessOptions? = nil) -> Bool { + + let bytes: [UInt8] = value ? [1] : [0] + let data = Data(bytes) + + return set(data, forKey: key, withAccess: access) + } + + /** + + Retrieves the text value from the keychain that corresponds to the given key. + + - parameter key: The key that is used to read the keychain item. + - returns: The text value from the keychain. Returns nil if unable to read the item. + + */ + open func get(_ key: String) -> String? { + if let data = getData(key) { + + if let currentString = String(data: data, encoding: .utf8) { + return currentString + } + + lastResultCode = -67853 // errSecInvalidEncoding + } + + return nil + } + + /** + + Retrieves the data from the keychain that corresponds to the given key. + + - parameter key: The key that is used to read the keychain item. + - parameter asReference: If true, returns the data as reference (needed for things like NEVPNProtocol). + - returns: The text value from the keychain. Returns nil if unable to read the item. + + */ + open func getData(_ key: String, asReference: Bool = false) -> Data? { + // The lock prevents the code to be run simultaneously + // from multiple threads which may result in crashing + lock.lock() + defer { lock.unlock() } + + let prefixedKey = keyWithPrefix(key) + + var query: [String: Any] = [ + KeychainSwiftConstants.klass : kSecClassGenericPassword, + KeychainSwiftConstants.attrAccount : prefixedKey, + KeychainSwiftConstants.matchLimit : kSecMatchLimitOne + ] + + if asReference { + query[KeychainSwiftConstants.returnReference] = kCFBooleanTrue + } else { + query[KeychainSwiftConstants.returnData] = kCFBooleanTrue + } + + query = addAccessGroupWhenPresent(query) + query = addSynchronizableIfRequired(query, addingItems: false) + lastQueryParameters = query + + var result: AnyObject? + + lastResultCode = withUnsafeMutablePointer(to: &result) { + SecItemCopyMatching(query as CFDictionary, UnsafeMutablePointer($0)) + } + + if lastResultCode == noErr { + return result as? Data + } + + return nil + } + + /** + Retrieves the boolean value from the keychain that corresponds to the given key. + - parameter key: The key that is used to read the keychain item. + - returns: The boolean value from the keychain. Returns nil if unable to read the item. + */ + open func getBool(_ key: String) -> Bool? { + guard let data = getData(key) else { return nil } + guard let firstBit = data.first else { return nil } + return firstBit == 1 + } + + /** + Deletes the single keychain item specified by the key. + + - parameter key: The key that is used to delete the keychain item. + - returns: True if the item was successfully deleted. + + */ + @discardableResult + open func delete(_ key: String) -> Bool { + // The lock prevents the code to be run simultaneously + // from multiple threads which may result in crashing + lock.lock() + defer { lock.unlock() } + + return deleteNoLock(key) + } + + /** + Return all keys from keychain + + - returns: An string array with all keys from the keychain. + + */ + public var allKeys: [String] { + var query: [String: Any] = [ + KeychainSwiftConstants.klass : kSecClassGenericPassword, + KeychainSwiftConstants.returnData : true, + KeychainSwiftConstants.returnAttributes: true, + KeychainSwiftConstants.returnReference: true, + KeychainSwiftConstants.matchLimit: KeychainSwiftConstants.secMatchLimitAll + ] + + query = addAccessGroupWhenPresent(query) + query = addSynchronizableIfRequired(query, addingItems: false) + + var result: AnyObject? + + let lastResultCode = withUnsafeMutablePointer(to: &result) { + SecItemCopyMatching(query as CFDictionary, UnsafeMutablePointer($0)) + } + + if lastResultCode == noErr { + return (result as? [[String: Any]])?.compactMap { + $0[KeychainSwiftConstants.attrAccount] as? String } ?? [] + } + + return [] + } + + /** + + Same as `delete` but is only accessed internally, since it is not thread safe. + + - parameter key: The key that is used to delete the keychain item. + - returns: True if the item was successfully deleted. + + */ + @discardableResult + func deleteNoLock(_ key: String) -> Bool { + let prefixedKey = keyWithPrefix(key) + + var query: [String: Any] = [ + KeychainSwiftConstants.klass : kSecClassGenericPassword, + KeychainSwiftConstants.attrAccount : prefixedKey + ] + + query = addAccessGroupWhenPresent(query) + query = addSynchronizableIfRequired(query, addingItems: false) + lastQueryParameters = query + + lastResultCode = SecItemDelete(query as CFDictionary) + + return lastResultCode == noErr + } + + /** + + Deletes all Keychain items used by the app. Note that this method deletes all items regardless of the prefix settings used for initializing the class. + + - returns: True if the keychain items were successfully deleted. + + */ + @discardableResult + open func clear() -> Bool { + // The lock prevents the code to be run simultaneously + // from multiple threads which may result in crashing + lock.lock() + defer { lock.unlock() } + + var query: [String: Any] = [ kSecClass as String : kSecClassGenericPassword ] + query = addAccessGroupWhenPresent(query) + query = addSynchronizableIfRequired(query, addingItems: false) + lastQueryParameters = query + + lastResultCode = SecItemDelete(query as CFDictionary) + + return lastResultCode == noErr + } + + /// Returns the key with currently set prefix. + func keyWithPrefix(_ key: String) -> String { + return "\(keyPrefix)\(key)" + } + + func addAccessGroupWhenPresent(_ items: [String: Any]) -> [String: Any] { + guard let accessGroup = accessGroup else { return items } + + var result: [String: Any] = items + result[KeychainSwiftConstants.accessGroup] = accessGroup + return result + } + + /** + + Adds kSecAttrSynchronizable: kSecAttrSynchronizableAny` item to the dictionary when the `synchronizable` property is true. + + - parameter items: The dictionary where the kSecAttrSynchronizable items will be added when requested. + - parameter addingItems: Use `true` when the dictionary will be used with `SecItemAdd` method (adding a keychain item). For getting and deleting items, use `false`. + + - returns: the dictionary with kSecAttrSynchronizable item added if it was requested. Otherwise, it returns the original dictionary. + + */ + func addSynchronizableIfRequired(_ items: [String: Any], addingItems: Bool) -> [String: Any] { + if !synchronizable { return items } + var result: [String: Any] = items + result[KeychainSwiftConstants.attrSynchronizable] = addingItems == true ? true : kSecAttrSynchronizableAny + return result + } +} + + +// ---------------------------- +// +// TegKeychainConstants.swift +// +// ---------------------------- +import Foundation +import Security + +/// Constants used by the library +public struct KeychainSwiftConstants { + /// Specifies a Keychain access group. Used for sharing Keychain items between apps. + public static var accessGroup: String { return toString(kSecAttrAccessGroup) } + + /** + + A value that indicates when your app needs access to the data in a keychain item. The default value is AccessibleWhenUnlocked. For a list of possible values, see KeychainSwiftAccessOptions. + + */ + public static var accessible: String { return toString(kSecAttrAccessible) } + + /// Used for specifying a String key when setting/getting a Keychain value. + public static var attrAccount: String { return toString(kSecAttrAccount) } + + /// Used for specifying synchronization of keychain items between devices. + public static var attrSynchronizable: String { return toString(kSecAttrSynchronizable) } + + /// An item class key used to construct a Keychain search dictionary. + public static var klass: String { return toString(kSecClass) } + + /// Specifies the number of values returned from the keychain. The library only supports single values. + public static var matchLimit: String { return toString(kSecMatchLimit) } + + /// A return data type used to get the data from the Keychain. + public static var returnData: String { return toString(kSecReturnData) } + + /// Used for specifying a value when setting a Keychain value. + public static var valueData: String { return toString(kSecValueData) } + + /// Used for returning a reference to the data from the keychain + public static var returnReference: String { return toString(kSecReturnPersistentRef) } + + /// A key whose value is a Boolean indicating whether or not to return item attributes + public static var returnAttributes : String { return toString(kSecReturnAttributes) } + + /// A value that corresponds to matching an unlimited number of items + public static var secMatchLimitAll : String { return toString(kSecMatchLimitAll) } + + static func toString(_ value: CFString) -> String { + return value as String + } +} + + +// ---------------------------- +// +// KeychainSwiftAccessOptions.swift +// +// ---------------------------- +import Security + +/** +These options are used to determine when a keychain item should be readable. The default value is AccessibleWhenUnlocked. +*/ +public enum KeychainSwiftAccessOptions { + + /** + + The data in the keychain item can be accessed only while the device is unlocked by the user. + + This is recommended for items that need to be accessible only while the application is in the foreground. Items with this attribute migrate to a new device when using encrypted backups. + + This is the default value for keychain items added without explicitly setting an accessibility constant. + + */ + case accessibleWhenUnlocked + + /** + + The data in the keychain item can be accessed only while the device is unlocked by the user. + + This is recommended for items that need to be accessible only while the application is in the foreground. Items with this attribute do not migrate to a new device. Thus, after restoring from a backup of a different device, these items will not be present. + + */ + case accessibleWhenUnlockedThisDeviceOnly + + /** + + The data in the keychain item cannot be accessed after a restart until the device has been unlocked once by the user. + + After the first unlock, the data remains accessible until the next restart. This is recommended for items that need to be accessed by background applications. Items with this attribute migrate to a new device when using encrypted backups. + + */ + case accessibleAfterFirstUnlock + + /** + + The data in the keychain item cannot be accessed after a restart until the device has been unlocked once by the user. + + After the first unlock, the data remains accessible until the next restart. This is recommended for items that need to be accessed by background applications. Items with this attribute do not migrate to a new device. Thus, after restoring from a backup of a different device, these items will not be present. + + */ + case accessibleAfterFirstUnlockThisDeviceOnly + + /** + + The data in the keychain can only be accessed when the device is unlocked. Only available if a passcode is set on the device. + + This is recommended for items that only need to be accessible while the application is in the foreground. Items with this attribute never migrate to a new device. After a backup is restored to a new device, these items are missing. No items can be stored in this class on devices without a passcode. Disabling the device passcode causes all items in this class to be deleted. + + */ + case accessibleWhenPasscodeSetThisDeviceOnly + + static var defaultOption: KeychainSwiftAccessOptions { + return .accessibleWhenUnlocked + } + + var value: String { + switch self { + case .accessibleWhenUnlocked: + return toString(kSecAttrAccessibleWhenUnlocked) + + case .accessibleWhenUnlockedThisDeviceOnly: + return toString(kSecAttrAccessibleWhenUnlockedThisDeviceOnly) + + case .accessibleAfterFirstUnlock: + return toString(kSecAttrAccessibleAfterFirstUnlock) + + case .accessibleAfterFirstUnlockThisDeviceOnly: + return toString(kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly) + + case .accessibleWhenPasscodeSetThisDeviceOnly: + return toString(kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly) + } + } + + func toString(_ value: CFString) -> String { + return KeychainSwiftConstants.toString(value) + } +} + diff --git a/apps/mobile/ios/Lux/RNEthersRs/KeychainUtils.swift b/apps/mobile/ios/Lux/RNEthersRs/KeychainUtils.swift new file mode 100644 index 00000000..dd57e2dc --- /dev/null +++ b/apps/mobile/ios/Lux/RNEthersRs/KeychainUtils.swift @@ -0,0 +1,21 @@ +import CryptoKit +import Foundation + +@objcMembers +class KeychainUtils: NSObject { + private static let CAN_CLEAR_KEYCHAIN_ON_REINSTALL_FLAG = "can_clear_keychain_on_reinstall" + private static let keychain = KeychainSwift(keyPrefix: prefix) + + @objc static func clearKeychain() { + keychain.clear() + } + + @objc static func getCanClearKeychainOnReinstall() -> Bool { + return (keychain.getBool(CAN_CLEAR_KEYCHAIN_ON_REINSTALL_FLAG) == true) + } + + @objc static func setCanClearKeychainOnReinstall() { + keychain.set( + true, forKey: CAN_CLEAR_KEYCHAIN_ON_REINSTALL_FLAG, withAccess: .accessibleWhenUnlockedThisDeviceOnly) + } +} diff --git a/apps/mobile/ios/Lux/RNEthersRs/RNEthersRS-Bridging-Header.h b/apps/mobile/ios/Lux/RNEthersRs/RNEthersRS-Bridging-Header.h new file mode 100644 index 00000000..4807bf0b --- /dev/null +++ b/apps/mobile/ios/Lux/RNEthersRs/RNEthersRS-Bridging-Header.h @@ -0,0 +1,12 @@ +// +// RNEthersRS-Bridging-Header.h +// Lux +// +// Created by Connor McEwen on 10/28/21. +// + +#import +#import +#import "libethers_ffi.h" +#import +#import diff --git a/apps/mobile/ios/Lux/RNEthersRs/RNEthersRS.swift b/apps/mobile/ios/Lux/RNEthersRs/RNEthersRS.swift new file mode 100644 index 00000000..4a54c40b --- /dev/null +++ b/apps/mobile/ios/Lux/RNEthersRs/RNEthersRS.swift @@ -0,0 +1,262 @@ +// +// RNEthers.swift +// Lux +// +// Created by Connor McEwen on 10/28/21. + +/** + Provides the generation, storage, and signing logic for mnemonics and private keys so that they never passed to JS. + + Mnemonics and private keys are stored and accessed in the native iOS secure keychain key-value store via associated keys formed from concatenating a constant prefix with the associated public address. + + Uses KeychainSwift as a wrapper utility to interface with the native iOS secure keychain. */ + +import Foundation +import CryptoKit + + +enum RNEthersRSError: String, Error { + case storeMnemonicError = "storeMnemonicError" + case retrieveMnemonicError = "retrieveMnemonicError" +} + +@objc(RNEthersRS) + +class RNEthersRS: NSObject { + private let keychain = KeychainSwift(keyPrefix: prefix) + // TODO: [MOB-208] LRU cache to ensure we don't create too many (unlikely to happen) + private var walletCache: [String: OpaquePointer] = [:] + + @objc static func requiresMainQueueSetup() -> Bool { + return false + } + + func findInvalidWord(mnemonic: String) -> String { + let firstInvalidMnemonic = find_invalid_word(mnemonic) + return String(cString: firstInvalidMnemonic!) + } + + func validateMnemonic(mnemonic: String) -> Bool { + return validate_mnemonic(mnemonic) + } + + /** + Fetches all mnemonic IDs, which are used as keys to access the actual mnemonics in the native keychain secure key-value store. + + - returns: array of mnemonic IDs + */ + @objc(getMnemonicIds:reject:) + func getMnemonicIds(resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock) { + let mnemonicIds = keychain.allKeys.filter { key in + key.contains(mnemonicPrefix) + }.map { key in + key.replacingOccurrences(of: entireMnemonicPrefix, with: "") + } + resolve(mnemonicIds) + } + + /** + Derives private key from mnemonic with derivation index 0 and retrieves associated public address. Stores imported mnemonic in native keychain with the mnemonic ID key as the public address. + + - parameter mnemonic: The mnemonic phrase to import + - returns: public address from the mnemonic's first derived private key + */ + @objc(importMnemonic:resolve:reject:) + func importMnemonic( + mnemonic: String, resolve: RCTPromiseResolveBlock, + reject: RCTPromiseRejectBlock + ) { + let private_key = private_key_from_mnemonic( + mnemonic, UInt32(exactly: 0)!) + let address = String(cString: private_key.address!) + + let res = storeNewMnemonic(mnemonic: mnemonic, address: address) + if res != nil { + resolve(res) + return + } + + reject("Unable to import new mnemonic", "Failed store new mnemonic in ethers library", nil) + return + } + + @objc(removeMnemonic:resolve:reject:) + func removeMnemonic( + mnemonicId: String, + resolve: RCTPromiseResolveBlock, + reject: RCTPromiseRejectBlock + ) { + let res = keychain.delete(keychainKeyForMnemonicId(mnemonicId: mnemonicId)) + resolve(res) + } + + /** + Generates a new mnemonic and retrieves associated public address. Stores new mnemonic in native keychain with the mnemonic ID key as the public address. + + - returns: public address from the mnemonic's first derived private key + */ + @objc(generateAndStoreMnemonic:reject:) + func generateAndStoreMnemonic(resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock) { + let mnemonic_ptr = generate_mnemonic() + let mnemonic_str = String(cString: mnemonic_ptr.mnemonic!) + let address_str = String(cString: mnemonic_ptr.address!) + let res = storeNewMnemonic(mnemonic: mnemonic_str, address: address_str) + mnemonic_free(mnemonic_ptr) + resolve(res) + } + + /** + Stores mnemonic phrase in Native Keychain under the address + + - returns: public address if successfully stored in native keychain + */ + func storeNewMnemonic(mnemonic: String, address: String) -> String? { + let checkStored = retrieveMnemonic(mnemonicId: address) + + if checkStored == nil { + let newMnemonicKey = keychainKeyForMnemonicId(mnemonicId: address); + keychain.set(mnemonic, forKey: newMnemonicKey, withAccess: .accessibleWhenUnlockedThisDeviceOnly) + return address + } + + return address + } + + func keychainKeyForMnemonicId(mnemonicId: String) -> String { + return mnemonicPrefix + mnemonicId + } + + func retrieveMnemonic(mnemonicId: String) -> String? { + return keychain.get(keychainKeyForMnemonicId(mnemonicId: mnemonicId)) + } + + /** + Fetches all public addresses from private keys stored under `privateKeyPrefix` in native keychain. Used from React Native to verify the native keychain has the private key for an account that is attempting create a NativeSigner that calls native signing methods + + - returns: public addresses for all stored private keys + */ + @objc(getAddressesForStoredPrivateKeys:reject:) + func getAddressesForStoredPrivateKeys( + resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock + ) { + let addresses = keychain.allKeys.filter { key in + key.contains(privateKeyPrefix) + }.map { key in + key.replacingOccurrences(of: entirePrivateKeyPrefix, with: "") + } + resolve(addresses) + } + + func storeNewPrivateKey(address: String, privateKey: String) { + let newKey = keychainKeyForPrivateKey(address: address); + keychain.set(privateKey, forKey: newKey, withAccess: .accessibleWhenUnlockedThisDeviceOnly) + } + + /** + Derives public address from mnemonic for given `derivationIndex`. + + - parameter mnemonic: mnemonic to generate public key for + - parameter derivationIndex: number used to specify a which derivation index to use for deriving a private key from the mnemonic + - returns: public address associated with private key generated from the mnemonic at given derivation index + */ + @objc(generateAddressForMnemonic:derivationIndex:resolve:reject:) + func generateAddressForMnemonic( + mnemonic: String, derivationIndex: Int, resolve: RCTPromiseResolveBlock, + reject: RCTPromiseRejectBlock + ) { + let private_key = private_key_from_mnemonic( + mnemonic, UInt32(exactly: derivationIndex)!) + let address = String(cString: private_key.address!) + private_key_free(private_key) + resolve(address) + } + + /** + Derives private key and public address from mnemonic associated with `mnemonicId` for given `derivationIndex`. Stores the private key in native keychain with key. + + - parameter mnemonicId: key string associated with mnemonic to generate private key for (currently convention is to use public address associated with mnemonic) + - parameter derivationIndex: number used to specify a which derivation index to use for deriving a private key from the mnemonic + - returns: public address associated with private key generated from the mnemonic at given derivation index + */ + @objc(generateAndStorePrivateKey:derivationIndex:resolve:reject:) + func generateAndStorePrivateKey( + mnemonicId: String, derivationIndex: Int, resolve: RCTPromiseResolveBlock, + reject: RCTPromiseRejectBlock + ) { + let mnemonic = retrieveMnemonic(mnemonicId: mnemonicId) + + if (mnemonic == nil) { + reject("Mnemonic not found", "Could not find mnemonic for given mnemonicId", nil) + return + } + + let private_key = private_key_from_mnemonic( + mnemonic, UInt32(exactly: derivationIndex)!) + let xprv = String(cString: private_key.private_key!) + let address = String(cString: private_key.address!) + storeNewPrivateKey(address: address, privateKey: xprv) + private_key_free(private_key) + resolve(address) + } + + @objc(removePrivateKey:resolve:reject:) + func removePrivateKey( + address: String, resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock + ) { + let res = keychain.delete(keychainKeyForPrivateKey(address: address)) + resolve(res) + } + + @objc(signTransactionHashForAddress:hash:chainId:resolve:reject:) + func signTransactionForAddress( + address: String, hash: String, chainId: NSNumber, resolve: RCTPromiseResolveBlock, + reject: RCTPromiseRejectBlock + ) { + let wallet = retrieveOrCreateWalletForAddress(address: address) + let signedHash = sign_tx_with_wallet(wallet, hash, UInt64(chainId)) + let result = String(cString: signedHash.signature!) + resolve(result); + } + + @objc(signMessageForAddress:message:resolve:reject:) + func signMessageForAddress( + address: String, message: String, resolve: RCTPromiseResolveBlock, + reject: RCTPromiseRejectBlock + ) { + let wallet = retrieveOrCreateWalletForAddress(address: address) + let signedMessage = sign_message_with_wallet(wallet, message) + let result = String(cString: signedMessage!) + string_free(signedMessage) + resolve(result) + } + + @objc(signHashForAddress:hash:chainId:resolve:reject:) + func signHashForAddress( + address: String, hash: String, chainId: NSNumber, resolve: RCTPromiseResolveBlock, + reject: RCTPromiseRejectBlock + ) { + let wallet = retrieveOrCreateWalletForAddress(address: address) + let signedHash = sign_hash_with_wallet(wallet, hash, UInt64(chainId)) + let result = String(cString: signedHash!) + string_free(signedHash) + resolve(result) + } + + func retrieveOrCreateWalletForAddress(address: String) -> OpaquePointer { + if walletCache[address] != nil { + return walletCache[address]! + } + let privateKey = retrievePrivateKey(address: address) + let wallet = wallet_from_private_key(privateKey) + walletCache[address] = wallet + return wallet! + } + + func retrievePrivateKey(address: String) -> String? { + return keychain.get(keychainKeyForPrivateKey(address: address)) + } + + func keychainKeyForPrivateKey(address: String) -> String { + return privateKeyPrefix + address + } +} diff --git a/apps/mobile/ios/Lux/RNEthersRs/RnEthersRS.m b/apps/mobile/ios/Lux/RNEthersRs/RnEthersRS.m new file mode 100644 index 00000000..8195396a --- /dev/null +++ b/apps/mobile/ios/Lux/RNEthersRs/RnEthersRS.m @@ -0,0 +1,60 @@ +// +// RnEthersBridge.m +// Lux +// +// Created by Connor McEwen on 10/28/21. +// + +#import + +@interface RCT_EXTERN_MODULE(RNEthersRS, NSObject) + +RCT_EXTERN_METHOD(getMnemonicIds: (RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject) + +RCT_EXTERN_METHOD(importMnemonic: (NSString *)mnemonic + resolve: (RCTPromiseResolveBlock)resolve + reject: (RCTPromiseRejectBlock)reject) + +RCT_EXTERN_METHOD(removeMnemonic: (NSString *)mnemonicId + resolve: (RCTPromiseResolveBlock)resolve + reject: (RCTPromiseRejectBlock)reject) + +RCT_EXTERN_METHOD(generateAndStoreMnemonic: (RCTPromiseResolveBlock)resolve + reject: (RCTPromiseRejectBlock)reject) + +RCT_EXTERN_METHOD(getAddressesForStoredPrivateKeys: (RCTPromiseResolveBlock)resolve + reject: (RCTPromiseRejectBlock)reject) + +RCT_EXTERN_METHOD(generateAddressForMnemonic: (NSString *)mnemonic + derivationIndex: (NSInteger)index + resolve: (RCTPromiseResolveBlock)resolve + reject: (RCTPromiseRejectBlock)reject) + +RCT_EXTERN_METHOD(generateAndStorePrivateKey: (NSString *)mnemonicId + derivationIndex: (NSInteger)index + resolve: (RCTPromiseResolveBlock)resolve + reject: (RCTPromiseRejectBlock)reject) + +RCT_EXTERN_METHOD(removePrivateKey: (NSString *)address + resolve: (RCTPromiseResolveBlock)resolve + reject: (RCTPromiseRejectBlock)reject) + +RCT_EXTERN_METHOD(signTransactionHashForAddress: (NSString *)address + hash: (NSString *)hash + chainId: NSNumber + resolve: (RCTPromiseResolveBlock)resolve + reject: (RCTPromiseRejectBlock)reject) + +RCT_EXTERN_METHOD(signMessageForAddress: (NSString *)address + message: (NSString *)message + resolve: (RCTPromiseResolveBlock)resolve + reject: (RCTPromiseRejectBlock)reject) + +RCT_EXTERN_METHOD(signHashForAddress: (NSString *)address + hash: (NSString *)hash + chainId: NSNumber + resolve: (RCTPromiseResolveBlock)resolve + reject: (RCTPromiseRejectBlock)reject) + +@end diff --git a/apps/mobile/ios/Lux/SplashScreen.storyboard b/apps/mobile/ios/Lux/SplashScreen.storyboard new file mode 100644 index 00000000..61161c37 --- /dev/null +++ b/apps/mobile/ios/Lux/SplashScreen.storyboard @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/mobile/ios/Lux/WalletConnect/RNWalletConnect.h b/apps/mobile/ios/Lux/WalletConnect/RNWalletConnect.h new file mode 100644 index 00000000..0c7f2b7a --- /dev/null +++ b/apps/mobile/ios/Lux/WalletConnect/RNWalletConnect.h @@ -0,0 +1,15 @@ +// +// RNWalletConnect.h +// Lux +// +// Created by Tina Zheng on 3/4/22. +// + +#ifndef RNWalletConnect_h +#define RNWalletConnect_h + +#import +@interface RNWalletConnect : NSObject +@end + +#endif /* RNWalletConnect_h */ diff --git a/apps/mobile/ios/Lux/WalletConnect/RNWalletConnect.m b/apps/mobile/ios/Lux/WalletConnect/RNWalletConnect.m new file mode 100644 index 00000000..e5acdd9b --- /dev/null +++ b/apps/mobile/ios/Lux/WalletConnect/RNWalletConnect.m @@ -0,0 +1,14 @@ +// +// RNWalletConnect.m +// Lux +// +// Created by Tina Zheng on 3/7/22. +// + +#import + +@interface RCT_EXTERN_MODULE(RNWalletConnect, NSObject) + +RCT_EXTERN_METHOD(returnToPreviousApp) + +@end diff --git a/apps/mobile/ios/Lux/WalletConnect/RNWalletConnect.swift b/apps/mobile/ios/Lux/WalletConnect/RNWalletConnect.swift new file mode 100644 index 00000000..cef96d56 --- /dev/null +++ b/apps/mobile/ios/Lux/WalletConnect/RNWalletConnect.swift @@ -0,0 +1,47 @@ +// +// RNWalletConnect.swift +// Lux +// +// Created by Tina Zheng on 3/4/22. +// + +import Foundation + +// Used to return to previously opened app (wallet to dapp in mobile browser) +@objc private protocol PrivateSelectors: NSObjectProtocol { + var destinations: [NSNumber] { get } + func sendResponseForDestination(_ destination: NSNumber) +} + +@objc(RNWalletConnect) +class RNWalletConnect: NSObject { + + /* + * Open the previously opened app that deep linked to Lux app + * (eg. Dapp website in Safari -> Wallet -> Dapp website in Safari). + * Returns false and does nothing if there is no previous opened app to link back to. + * Returns true if successfully opened previous app + */ + @objc + func returnToPreviousApp() -> Bool { + let sys = "_system" + let nav = "Navigation" + let action = "Action" + guard + let sysNavIvar = class_getInstanceVariable(UIApplication.self, sys + nav + action), + let action = object_getIvar(UIApplication.shared, sysNavIvar) as? NSObject, + let destinations = action.perform(#selector(getter: PrivateSelectors.destinations)).takeUnretainedValue() as? [NSNumber], + let firstDestination = destinations.first + else { + return false + } + + action.perform(#selector(PrivateSelectors.sendResponseForDestination), with: firstDestination) + return true + } + + @objc static func requiresMainQueueSetup() -> Bool { + return false + } + +} diff --git a/apps/mobile/ios/Lux/Widget/RNWidgets.m b/apps/mobile/ios/Lux/Widget/RNWidgets.m new file mode 100644 index 00000000..ce8f9b6b --- /dev/null +++ b/apps/mobile/ios/Lux/Widget/RNWidgets.m @@ -0,0 +1,15 @@ +// +// RNWidgets.m +// Lux +// +// Created by Eric Huang on 8/2/23. +// + +#import + +@interface RCT_EXTERN_MODULE(RNWidgets, NSObject) + +RCT_EXTERN_METHOD(hasWidgetsInstalled: (RCTPromiseResolveBlock *)resolve + reject:(RCTPromiseRejectBlock *)reject) + +@end diff --git a/apps/mobile/ios/Lux/Widget/RNWidgets.swift b/apps/mobile/ios/Lux/Widget/RNWidgets.swift new file mode 100644 index 00000000..f039a629 --- /dev/null +++ b/apps/mobile/ios/Lux/Widget/RNWidgets.swift @@ -0,0 +1,29 @@ +// +// RNWidgets.swift +// Lux +// +// Created by Eric Huang on 8/2/23. +// + +import Foundation +import WidgetKit + +@objc(RNWidgets) +class RNWidgets: NSObject { + + @objc + func hasWidgetsInstalled(_ resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) { + WidgetCenter.shared.getCurrentConfigurations() { result in + if case .success(let config) = result { + resolve(config.count > 0) + } else { + resolve(false) + } + } + } + + @objc + static func requiresMainQueueSetup() -> Bool { + return false + } +} diff --git a/apps/mobile/ios/LuxTests/EncryptionHelperTests.swift b/apps/mobile/ios/LuxTests/EncryptionHelperTests.swift new file mode 100644 index 00000000..9b3ac251 --- /dev/null +++ b/apps/mobile/ios/LuxTests/EncryptionHelperTests.swift @@ -0,0 +1,107 @@ +// +// EncryptionHelperTests.swift +// LuxTests +// +// Created by Spencer Yen on 7/27/22. +// + +import XCTest +import Argon2Swift +@testable import Lux + +class EncryptionHelperTests: XCTestCase { + + private let secret = "student zone flight quote trial case shadow alien yard choose quiz produce" + private let password = "012345" + private let saltLength = 16 + + func testEncryptAndDecrypt() throws { + let salt = generateSalt(length: saltLength) + print("Secret: \(secret)") + print("Password: \(password)") + print("Salt: \(salt)") + + let encryptedSecret = try encrypt(secret: secret, password: password, salt: salt) + XCTAssertNotNil(encryptedSecret, "Failed to encrypt secret") + print("Encrypted Secret: \(encryptedSecret)") + + let decryptedSecret = try decrypt(encryptedSecret: encryptedSecret, password: password, salt: salt) + XCTAssertEqual(secret, decryptedSecret, "Decrypted secret does not match plaintext secret") + print("Decrypted Secret: \(decryptedSecret)") + } + + func testEncryptAndDecryptFail() throws { + let salt = generateSalt(length: saltLength) + + let encryptedSecret = try encrypt(secret: secret, password: password, salt: salt) + XCTAssertNotNil(encryptedSecret, "Failed to encrypt secret") + + XCTAssertThrowsError(try decrypt(encryptedSecret: encryptedSecret, password: "wrong", salt: salt), "No error thrown when decrypting with invalid password") + } + + func testArgon2KDF1Iteration1GBMemory() throws { + measure { + do { + let iterations = 1 + let memory = 2 << 19 // 2^20 KiB = 1024MiB + let salt = generateSalt(length: saltLength) + let _ = try Argon2Swift.hashPasswordString(password: password, salt: Salt(bytes: Data(salt.utf8)), iterations: iterations, memory: memory, parallelism: 4, length: 32, type: .id) + } catch { + XCTAssertNil(error, "Error hashing password with Argon2") + } + } + } + + func testArgon2KDF3Iteration512MBMemory() throws { + measure { + do { + let iterations = 3 + let memory = 2 << 18 // 2^19 KiB = 512MiB + let salt = generateSalt(length: saltLength) + let _ = try Argon2Swift.hashPasswordString(password: password, salt: Salt(bytes: Data(salt.utf8)), iterations: iterations, memory: memory, parallelism: 4, length: 32, type: .id) + } catch { + XCTAssertNil(error, "Error hashing password with Argon2") + } + } + } + + func testArgon2KDF3Iteration256MBMemory() throws { + measure { + do { + let iterations = 3 + let memory = 2 << 17 // 2^18 KiB = 256MiB + let salt = generateSalt(length: saltLength) + let _ = try Argon2Swift.hashPasswordString(password: password, salt: Salt(bytes: Data(salt.utf8)), iterations: iterations, memory: memory, parallelism: 4, length: 32, type: .id) + } catch { + XCTAssertNil(error, "Error hashing password with Argon2") + } + } + } + + func testArgon2KDF3Iteration128MBMemory() throws { + measure { + do { + let iterations = 3 + let memory = 2 << 16 // 2^17 KiB = 128MiB + let salt = generateSalt(length: saltLength) + let _ = try Argon2Swift.hashPasswordString(password: password, salt: Salt(bytes: Data(salt.utf8)), iterations: iterations, memory: memory, parallelism: 4, length: 32, type: .id) + } catch { + XCTAssertNil(error, "Error hashing password with Argon2") + } + } + } + + func testArgon2KDF3Iteration64MBMemory() throws { + measure { + do { + let iterations = 3 + let memory = 2 << 15 // 2^16 KiB = 64MiB + let salt = generateSalt(length: saltLength) + let _ = try Argon2Swift.hashPasswordString(password: password, salt: Salt(bytes: Data(salt.utf8)), iterations: iterations, memory: memory, parallelism: 4, length: 32, type: .id) + } catch { + XCTAssertNil(error, "Error hashing password with Argon2") + } + } + } + +} diff --git a/apps/mobile/ios/LuxTests/Info.plist b/apps/mobile/ios/LuxTests/Info.plist new file mode 100644 index 00000000..a7d41dcd --- /dev/null +++ b/apps/mobile/ios/LuxTests/Info.plist @@ -0,0 +1,24 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + BNDL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleSignature + ???? + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + + diff --git a/apps/mobile/ios/LuxTests/LuxTests.m b/apps/mobile/ios/LuxTests/LuxTests.m new file mode 100644 index 00000000..5e8dedee --- /dev/null +++ b/apps/mobile/ios/LuxTests/LuxTests.m @@ -0,0 +1,65 @@ +#import +#import + +#import +#import + +#define TIMEOUT_SECONDS 600 +#define TEXT_TO_LOOK_FOR @"Welcome to React" + +@interface LuxTests : XCTestCase + +@end + +@implementation LuxTests + +- (BOOL)findSubviewInView:(UIView *)view matching:(BOOL(^)(UIView *view))test +{ + if (test(view)) { + return YES; + } + for (UIView *subview in [view subviews]) { + if ([self findSubviewInView:subview matching:test]) { + return YES; + } + } + return NO; +} + +- (void)testRendersWelcomeScreen +{ + UIViewController *vc = [[[RCTSharedApplication() delegate] window] rootViewController]; + NSDate *date = [NSDate dateWithTimeIntervalSinceNow:TIMEOUT_SECONDS]; + BOOL foundElement = NO; + + __block NSString *redboxError = nil; +#ifdef DEBUG + RCTSetLogFunction(^(RCTLogLevel level, RCTLogSource source, NSString *fileName, NSNumber *lineNumber, NSString *message) { + if (level >= RCTLogLevelError) { + redboxError = message; + } + }); +#endif + + while ([date timeIntervalSinceNow] > 0 && !foundElement && !redboxError) { + [[NSRunLoop mainRunLoop] runMode:NSDefaultRunLoopMode beforeDate:[NSDate dateWithTimeIntervalSinceNow:0.1]]; + [[NSRunLoop mainRunLoop] runMode:NSRunLoopCommonModes beforeDate:[NSDate dateWithTimeIntervalSinceNow:0.1]]; + + foundElement = [self findSubviewInView:vc.view matching:^BOOL(UIView *view) { + if ([view.accessibilityLabel isEqualToString:TEXT_TO_LOOK_FOR]) { + return YES; + } + return NO; + }]; + } + +#ifdef DEBUG + RCTSetLogFunction(RCTDefaultLogFunction); +#endif + + XCTAssertNil(redboxError, @"RedBox error: %@", redboxError); + XCTAssertTrue(foundElement, @"Couldn't find element with text '%@' in %d seconds", TEXT_TO_LOOK_FOR, TIMEOUT_SECONDS); +} + + +@end diff --git a/apps/mobile/ios/OneSignalNotificationServiceExtension/Info.plist b/apps/mobile/ios/OneSignalNotificationServiceExtension/Info.plist new file mode 100644 index 00000000..f7910636 --- /dev/null +++ b/apps/mobile/ios/OneSignalNotificationServiceExtension/Info.plist @@ -0,0 +1,21 @@ + + + + + NSExtension + + NSExtensionPointIdentifier + com.apple.usernotifications.service + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).NotificationService + + OneSignal_app_groups_key +<<<<<<< HEAD + group.com.lux.mobile.onesignal +======= + group.com.uniswap.mobile.onesignal +>>>>>>> upstream/main + BUNDLE_ID_SUFFIX + $(BUNDLE_ID_SUFFIX) + + diff --git a/apps/mobile/ios/OneSignalNotificationServiceExtension/NotificationService.swift b/apps/mobile/ios/OneSignalNotificationServiceExtension/NotificationService.swift new file mode 100644 index 00000000..556bdd87 --- /dev/null +++ b/apps/mobile/ios/OneSignalNotificationServiceExtension/NotificationService.swift @@ -0,0 +1,34 @@ +import UserNotifications +import OneSignalExtension + +class NotificationService: UNNotificationServiceExtension { + + var contentHandler: ((UNNotificationContent) -> Void)? + var receivedRequest: UNNotificationRequest! + var bestAttemptContent: UNMutableNotificationContent? + + override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { + self.receivedRequest = request + self.contentHandler = contentHandler + self.bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent) + + if let bestAttemptContent = bestAttemptContent { + /* DEBUGGING: Uncomment the 2 lines below to check this extension is executing + Note, this extension only runs when mutable-content is set + Setting an attachment or action buttons automatically adds this */ + // print("Running NotificationServiceExtension") + // bestAttemptContent.body = "[Modified] " + bestAttemptContent.body + + OneSignalExtension.didReceiveNotificationExtensionRequest(self.receivedRequest, with: bestAttemptContent, withContentHandler: self.contentHandler) + } + } + + override func serviceExtensionTimeWillExpire() { + // Called just before the extension will be terminated by the system. + // Use this as an opportunity to deliver your "best attempt" at modified content, otherwise the original push payload will be used. + if let contentHandler = contentHandler, let bestAttemptContent = bestAttemptContent { + OneSignalExtension.serviceExtensionTimeWillExpireRequest(self.receivedRequest, with: self.bestAttemptContent) + contentHandler(bestAttemptContent) + } + } +} diff --git a/apps/mobile/ios/OneSignalNotificationServiceExtension/OneSignalNotificationServiceExtension.entitlements b/apps/mobile/ios/OneSignalNotificationServiceExtension/OneSignalNotificationServiceExtension.entitlements new file mode 100644 index 00000000..c96a3559 --- /dev/null +++ b/apps/mobile/ios/OneSignalNotificationServiceExtension/OneSignalNotificationServiceExtension.entitlements @@ -0,0 +1,14 @@ + + + + + com.apple.security.application-groups + +<<<<<<< HEAD + group.com.lux.mobile.onesignal +======= + group.com.uniswap.mobile.onesignal +>>>>>>> upstream/main + + + diff --git a/apps/mobile/ios/Podfile b/apps/mobile/ios/Podfile new file mode 100644 index 00000000..f84316ca --- /dev/null +++ b/apps/mobile/ios/Podfile @@ -0,0 +1,225 @@ +# should be removed after migrating to react-native new architecture +ENV['RCT_NEW_ARCH_ENABLED'] = '0' +# changes from here: https://docs.expo.dev/bare/installing-expo-modules/ +require File.join(File.dirname(`node --print "require.resolve('expo/package.json')"`), "scripts/autolinking") +require_relative '../../../node_modules/react-native/scripts/react_native_pods' +require_relative '../../../node_modules/react-native-permissions/scripts/setup' + +platform :ios, '15.1' +prepare_react_native_project! + +setup_permissions([ + 'FaceID', + 'Notifications', +]) + +$RNFirebaseAsStaticFramework = true +$RNFirebaseAnalyticsWithoutAdIdSupport=true + +<<<<<<< HEAD +target 'Lux' do +======= +project 'Uniswap', + 'Debug' => :debug, + 'DebugOptimized' => :debug, + 'Release' => :release, + 'Dev' => :release, + 'Beta' => :release + +target 'Uniswap' do +>>>>>>> upstream/main + use_frameworks! :linkage => :static + use_expo_modules! + post_integrate do |installer| + begin + expo_patch_react_imports!(installer) + rescue => e + Pod::UI.warn e + end + end + + if ENV['EXPO_USE_COMMUNITY_AUTOLINKING'] == '1' + config_command = ['node', '-e', "process.argv=['', '', 'config'];require('@react-native-community/cli').run()"]; + else + config_command = [ + 'node', + '--no-warnings', + '--eval', + 'require(require.resolve(\'expo-modules-autolinking\', { paths: [require.resolve(\'expo/package.json\')] }))(process.argv.slice(1))', + 'react-native-config', + '--json', + '--platform', + 'ios' + ] + end + + config = use_native_modules!(config_command) + + use_react_native!( + :path => config[:reactNativePath], + :fabric_enabled => false, + # to enable hermes on iOS, change `false` to `true` and then install pods + :hermes_enabled => true + ) + +<<<<<<< HEAD + target 'LuxTests' do +======= + target 'UniswapTests' do +>>>>>>> upstream/main + inherit! :complete + # Pods for testing + end + +<<<<<<< HEAD + pod 'EthersRS', :path => '../../../node_modules/@l.x/ethers-rs-mobile' +======= + pod 'EthersRS', :path => '../../../node_modules/@uniswap/ethers-rs-mobile' +>>>>>>> upstream/main + pod 'Argon2Swift', '1.0.3' + + post_install do |installer| + react_native_post_install(installer, "../../../node_modules/react-native") + +<<<<<<< HEAD + # Get absolute paths for Xcode 26 compatibility + pods_root = installer.sandbox.root.to_s + project_root = File.expand_path('../../../..', pods_root) + react_native_path = "#{project_root}/node_modules/react-native" + + # Argon2Swift module path + argon2_module_path = "#{pods_root}/Argon2Swift/Sources/Modules/module.modulemap" + + # Absolute paths for Xcode 26 React Native header search path fixes + react_common_path = "#{react_native_path}/ReactCommon" + folly_path = "#{pods_root}/RCT-Folly" + double_conversion_path = "#{pods_root}/DoubleConversion" + fmt_path = "#{pods_root}/fmt/include" + + # Create flat React headers directory for Xcode 26 compatibility + # This allows #import to find headers that are in nested directories + react_flat_headers = "#{pods_root}/../ReactFlatHeaders" + FileUtils.rm_rf(react_flat_headers) if File.exist?(react_flat_headers) + FileUtils.mkdir_p("#{react_flat_headers}/React") + + # Symlink all React headers into flat structure + Dir.glob("#{react_native_path}/React/**/*.h").each do |header| + header_name = File.basename(header) + symlink_path = "#{react_flat_headers}/React/#{header_name}" + FileUtils.ln_sf(header, symlink_path) unless File.exist?(symlink_path) + end + +======= +>>>>>>> upstream/main + installer.pods_project.targets.each do |target| + target.build_configurations.each do |config| + config.build_settings['CODE_SIGNING_ALLOWED'] = 'NO' + config.build_settings['APPLICATION_EXTENSION_API_ONLY'] = 'No' +<<<<<<< HEAD + end + + # Fix Argon2Swift module for Xcode 26 - add to Argon2Swift pod target + if target.name == 'Argon2Swift' + target.build_configurations.each do |config| + existing_flags = config.build_settings['OTHER_SWIFT_FLAGS'] || '$(inherited)' + config.build_settings['OTHER_SWIFT_FLAGS'] = "#{existing_flags} -Xcc -fmodule-map-file=#{argon2_module_path}" + end + end + + # Fix React Native header search paths for Xcode 26 - apply to all React-* targets + if target.name.start_with?('React-') || target.name == 'RNReanimated' + target.build_configurations.each do |config| + # Always use $(inherited) to preserve xcconfig settings, then add our paths + config.build_settings['HEADER_SEARCH_PATHS'] = "$(inherited) \"#{react_common_path}\" \"#{folly_path}\" \"#{double_conversion_path}\" \"#{fmt_path}\" \"#{react_flat_headers}\"" + # Disable non-modular header warning for Xcode 26 compatibility + config.build_settings['OTHER_CFLAGS'] = "$(inherited) -Wno-non-modular-include-in-framework-module" + config.build_settings['OTHER_CPLUSPLUSFLAGS'] = "$(inherited) -Wno-non-modular-include-in-framework-module" + end + end + + # Special handling for RNReanimated - disable strict module decluse + if target.name == 'RNReanimated' + target.build_configurations.each do |config| + # Disable Clang modules to avoid "must be imported from module" errors + config.build_settings['CLANG_ENABLE_MODULES'] = 'NO' +======= + + # Optimize native code in DebugOptimized for faster Simulator runtime. + # Hermes ships pre-compiled and is unaffected. Source-compiled pods + # (Yoga, React-Core, RNScreens, RNReanimated, RNGestureHandler, etc.) + # run dramatically faster with optimization enabled. + # RN dev tools (Metro, React DevTools) work over the network and are unaffected. + if config.name == 'DebugOptimized' + config.build_settings['GCC_OPTIMIZATION_LEVEL'] = 's' + config.build_settings['SWIFT_OPTIMIZATION_LEVEL'] = '-O' + config.build_settings['MTL_ENABLE_DEBUG_INFO'] = 'NO' +>>>>>>> upstream/main + end + end + end + + # Xcode 26+ has a stricter module verifier that requires the consuming target + # to resolve transitive C module dependencies. Argon2Swift depends on an internal +<<<<<<< HEAD + # 'argon2' C module — add its modulemap path so the Lux target (the only +======= + # 'argon2' C module — add its modulemap path so the Uniswap target (the only +>>>>>>> upstream/main + # target declaring Argon2Swift) can find it. + argon2_srcroot = installer.sandbox.pod_dir('Argon2Swift') + argon2_module_paths = [ + "#{argon2_srcroot}/Sources/Modules", + "#{argon2_srcroot}/Sources/Argon2", + "#{argon2_srcroot}/Sources/Argon2/include", + ] +<<<<<<< HEAD + lux_aggregate = installer.aggregate_targets.find { |t| t.label == 'Pods-Lux' } + if lux_aggregate + lux_aggregate.xcconfigs.each do |config_name, xcconfig| + existing = xcconfig.attributes['SWIFT_INCLUDE_PATHS'] || '' + new_paths = argon2_module_paths.map { |p| "\"#{p}\"" }.join(' ') + xcconfig.attributes['SWIFT_INCLUDE_PATHS'] = "$(inherited) #{new_paths} #{existing}".strip + xcconfig_path = lux_aggregate.xcconfig_path(config_name) +======= + uniswap_aggregate = installer.aggregate_targets.find { |t| t.label == 'Pods-Uniswap' } + if uniswap_aggregate + uniswap_aggregate.xcconfigs.each do |config_name, xcconfig| + existing = xcconfig.attributes['SWIFT_INCLUDE_PATHS'] || '' + new_paths = argon2_module_paths.map { |p| "\"#{p}\"" }.join(' ') + xcconfig.attributes['SWIFT_INCLUDE_PATHS'] = "$(inherited) #{new_paths} #{existing}".strip + xcconfig_path = uniswap_aggregate.xcconfig_path(config_name) +>>>>>>> upstream/main + xcconfig.save_as(xcconfig_path) + end + end + end +end + +target 'OneSignalNotificationServiceExtension' do + use_frameworks! :linkage => :static + pod 'OneSignalXCFramework', '>= 5.0.0', '< 6.0' +end + +def prepare_target_commons + use_frameworks! :linkage => :static + + pod 'Apollo', '1.2.1' + pod 'UIImageColors', '2.1.0' +end + +target 'Widgets' do + prepare_target_commons + # Pods for widgets +end +target 'WidgetsCore' do + prepare_target_commons + # Pods for widgets core +end +target 'WidgetsCoreTests' do + prepare_target_commons + # Pods for widgets core test +end +target 'WidgetIntentExtension' do + prepare_target_commons + # Pods for intent extension +end diff --git a/apps/mobile/ios/Podfile.lock b/apps/mobile/ios/Podfile.lock new file mode 100644 index 00000000..b3a6e037 --- /dev/null +++ b/apps/mobile/ios/Podfile.lock @@ -0,0 +1,4667 @@ +PODS: + - abseil/algorithm (1.20240116.2): + - abseil/algorithm/algorithm (= 1.20240116.2) + - abseil/algorithm/container (= 1.20240116.2) + - abseil/algorithm/algorithm (1.20240116.2): + - abseil/base/config + - abseil/xcprivacy + - abseil/algorithm/container (1.20240116.2): + - abseil/algorithm/algorithm + - abseil/base/core_headers + - abseil/base/nullability + - abseil/meta/type_traits + - abseil/xcprivacy + - abseil/base (1.20240116.2): + - abseil/base/atomic_hook (= 1.20240116.2) + - abseil/base/base (= 1.20240116.2) + - abseil/base/base_internal (= 1.20240116.2) + - abseil/base/config (= 1.20240116.2) + - abseil/base/core_headers (= 1.20240116.2) + - abseil/base/cycleclock_internal (= 1.20240116.2) + - abseil/base/dynamic_annotations (= 1.20240116.2) + - abseil/base/endian (= 1.20240116.2) + - abseil/base/errno_saver (= 1.20240116.2) + - abseil/base/fast_type_id (= 1.20240116.2) + - abseil/base/log_severity (= 1.20240116.2) + - abseil/base/malloc_internal (= 1.20240116.2) + - abseil/base/no_destructor (= 1.20240116.2) + - abseil/base/nullability (= 1.20240116.2) + - abseil/base/prefetch (= 1.20240116.2) + - abseil/base/pretty_function (= 1.20240116.2) + - abseil/base/raw_logging_internal (= 1.20240116.2) + - abseil/base/spinlock_wait (= 1.20240116.2) + - abseil/base/strerror (= 1.20240116.2) + - abseil/base/throw_delegate (= 1.20240116.2) + - abseil/base/atomic_hook (1.20240116.2): + - abseil/base/config + - abseil/base/core_headers + - abseil/xcprivacy + - abseil/base/base (1.20240116.2): + - abseil/base/atomic_hook + - abseil/base/base_internal + - abseil/base/config + - abseil/base/core_headers + - abseil/base/cycleclock_internal + - abseil/base/dynamic_annotations + - abseil/base/log_severity + - abseil/base/nullability + - abseil/base/raw_logging_internal + - abseil/base/spinlock_wait + - abseil/meta/type_traits + - abseil/xcprivacy + - abseil/base/base_internal (1.20240116.2): + - abseil/base/config + - abseil/meta/type_traits + - abseil/xcprivacy + - abseil/base/config (1.20240116.2): + - abseil/xcprivacy + - abseil/base/core_headers (1.20240116.2): + - abseil/base/config + - abseil/xcprivacy + - abseil/base/cycleclock_internal (1.20240116.2): + - abseil/base/base_internal + - abseil/base/config + - abseil/xcprivacy + - abseil/base/dynamic_annotations (1.20240116.2): + - abseil/base/config + - abseil/base/core_headers + - abseil/xcprivacy + - abseil/base/endian (1.20240116.2): + - abseil/base/base + - abseil/base/config + - abseil/base/core_headers + - abseil/base/nullability + - abseil/xcprivacy + - abseil/base/errno_saver (1.20240116.2): + - abseil/base/config + - abseil/xcprivacy + - abseil/base/fast_type_id (1.20240116.2): + - abseil/base/config + - abseil/xcprivacy + - abseil/base/log_severity (1.20240116.2): + - abseil/base/config + - abseil/base/core_headers + - abseil/xcprivacy + - abseil/base/malloc_internal (1.20240116.2): + - abseil/base/base + - abseil/base/base_internal + - abseil/base/config + - abseil/base/core_headers + - abseil/base/dynamic_annotations + - abseil/base/raw_logging_internal + - abseil/xcprivacy + - abseil/base/no_destructor (1.20240116.2): + - abseil/base/config + - abseil/xcprivacy + - abseil/base/nullability (1.20240116.2): + - abseil/base/core_headers + - abseil/meta/type_traits + - abseil/xcprivacy + - abseil/base/prefetch (1.20240116.2): + - abseil/base/config + - abseil/base/core_headers + - abseil/xcprivacy + - abseil/base/pretty_function (1.20240116.2): + - abseil/xcprivacy + - abseil/base/raw_logging_internal (1.20240116.2): + - abseil/base/atomic_hook + - abseil/base/config + - abseil/base/core_headers + - abseil/base/errno_saver + - abseil/base/log_severity + - abseil/xcprivacy + - abseil/base/spinlock_wait (1.20240116.2): + - abseil/base/base_internal + - abseil/base/core_headers + - abseil/base/errno_saver + - abseil/xcprivacy + - abseil/base/strerror (1.20240116.2): + - abseil/base/config + - abseil/base/core_headers + - abseil/base/errno_saver + - abseil/xcprivacy + - abseil/base/throw_delegate (1.20240116.2): + - abseil/base/config + - abseil/base/raw_logging_internal + - abseil/xcprivacy + - abseil/cleanup/cleanup (1.20240116.2): + - abseil/base/config + - abseil/base/core_headers + - abseil/cleanup/cleanup_internal + - abseil/xcprivacy + - abseil/cleanup/cleanup_internal (1.20240116.2): + - abseil/base/base_internal + - abseil/base/core_headers + - abseil/utility/utility + - abseil/xcprivacy + - abseil/container/common (1.20240116.2): + - abseil/meta/type_traits + - abseil/types/optional + - abseil/xcprivacy + - abseil/container/common_policy_traits (1.20240116.2): + - abseil/meta/type_traits + - abseil/xcprivacy + - abseil/container/compressed_tuple (1.20240116.2): + - abseil/utility/utility + - abseil/xcprivacy + - abseil/container/container_memory (1.20240116.2): + - abseil/base/config + - abseil/memory/memory + - abseil/meta/type_traits + - abseil/utility/utility + - abseil/xcprivacy + - abseil/container/fixed_array (1.20240116.2): + - abseil/algorithm/algorithm + - abseil/base/config + - abseil/base/core_headers + - abseil/base/dynamic_annotations + - abseil/base/throw_delegate + - abseil/container/compressed_tuple + - abseil/memory/memory + - abseil/xcprivacy + - abseil/container/flat_hash_map (1.20240116.2): + - abseil/algorithm/container + - abseil/base/core_headers + - abseil/container/container_memory + - abseil/container/hash_function_defaults + - abseil/container/raw_hash_map + - abseil/memory/memory + - abseil/xcprivacy + - abseil/container/flat_hash_set (1.20240116.2): + - abseil/algorithm/container + - abseil/base/core_headers + - abseil/container/container_memory + - abseil/container/hash_function_defaults + - abseil/container/raw_hash_set + - abseil/memory/memory + - abseil/xcprivacy + - abseil/container/hash_function_defaults (1.20240116.2): + - abseil/base/config + - abseil/hash/hash + - abseil/strings/cord + - abseil/strings/strings + - abseil/xcprivacy + - abseil/container/hash_policy_traits (1.20240116.2): + - abseil/container/common_policy_traits + - abseil/meta/type_traits + - abseil/xcprivacy + - abseil/container/hashtable_debug_hooks (1.20240116.2): + - abseil/base/config + - abseil/xcprivacy + - abseil/container/hashtablez_sampler (1.20240116.2): + - abseil/base/base + - abseil/base/config + - abseil/base/core_headers + - abseil/base/raw_logging_internal + - abseil/debugging/stacktrace + - abseil/memory/memory + - abseil/profiling/exponential_biased + - abseil/profiling/sample_recorder + - abseil/synchronization/synchronization + - abseil/time/time + - abseil/utility/utility + - abseil/xcprivacy + - abseil/container/inlined_vector (1.20240116.2): + - abseil/algorithm/algorithm + - abseil/base/core_headers + - abseil/base/throw_delegate + - abseil/container/inlined_vector_internal + - abseil/memory/memory + - abseil/meta/type_traits + - abseil/xcprivacy + - abseil/container/inlined_vector_internal (1.20240116.2): + - abseil/base/config + - abseil/base/core_headers + - abseil/container/compressed_tuple + - abseil/memory/memory + - abseil/meta/type_traits + - abseil/types/span + - abseil/xcprivacy + - abseil/container/layout (1.20240116.2): + - abseil/base/config + - abseil/base/core_headers + - abseil/debugging/demangle_internal + - abseil/meta/type_traits + - abseil/strings/strings + - abseil/types/span + - abseil/utility/utility + - abseil/xcprivacy + - abseil/container/raw_hash_map (1.20240116.2): + - abseil/base/config + - abseil/base/core_headers + - abseil/base/throw_delegate + - abseil/container/container_memory + - abseil/container/raw_hash_set + - abseil/xcprivacy + - abseil/container/raw_hash_set (1.20240116.2): + - abseil/base/config + - abseil/base/core_headers + - abseil/base/dynamic_annotations + - abseil/base/endian + - abseil/base/prefetch + - abseil/base/raw_logging_internal + - abseil/container/common + - abseil/container/compressed_tuple + - abseil/container/container_memory + - abseil/container/hash_policy_traits + - abseil/container/hashtable_debug_hooks + - abseil/container/hashtablez_sampler + - abseil/hash/hash + - abseil/memory/memory + - abseil/meta/type_traits + - abseil/numeric/bits + - abseil/utility/utility + - abseil/xcprivacy + - abseil/crc/cpu_detect (1.20240116.2): + - abseil/base/base + - abseil/base/config + - abseil/xcprivacy + - abseil/crc/crc32c (1.20240116.2): + - abseil/base/config + - abseil/base/core_headers + - abseil/base/endian + - abseil/base/prefetch + - abseil/crc/cpu_detect + - abseil/crc/crc_internal + - abseil/crc/non_temporal_memcpy + - abseil/strings/str_format + - abseil/strings/strings + - abseil/xcprivacy + - abseil/crc/crc_cord_state (1.20240116.2): + - abseil/base/config + - abseil/crc/crc32c + - abseil/numeric/bits + - abseil/strings/strings + - abseil/xcprivacy + - abseil/crc/crc_internal (1.20240116.2): + - abseil/base/config + - abseil/base/core_headers + - abseil/base/endian + - abseil/base/prefetch + - abseil/base/raw_logging_internal + - abseil/crc/cpu_detect + - abseil/memory/memory + - abseil/numeric/bits + - abseil/xcprivacy + - abseil/crc/non_temporal_arm_intrinsics (1.20240116.2): + - abseil/base/config + - abseil/xcprivacy + - abseil/crc/non_temporal_memcpy (1.20240116.2): + - abseil/base/config + - abseil/base/core_headers + - abseil/crc/non_temporal_arm_intrinsics + - abseil/xcprivacy + - abseil/debugging/debugging_internal (1.20240116.2): + - abseil/base/config + - abseil/base/core_headers + - abseil/base/dynamic_annotations + - abseil/base/errno_saver + - abseil/base/raw_logging_internal + - abseil/xcprivacy + - abseil/debugging/demangle_internal (1.20240116.2): + - abseil/base/base + - abseil/base/config + - abseil/base/core_headers + - abseil/xcprivacy + - abseil/debugging/examine_stack (1.20240116.2): + - abseil/base/config + - abseil/base/core_headers + - abseil/base/raw_logging_internal + - abseil/debugging/stacktrace + - abseil/debugging/symbolize + - abseil/xcprivacy + - abseil/debugging/stacktrace (1.20240116.2): + - abseil/base/config + - abseil/base/core_headers + - abseil/base/dynamic_annotations + - abseil/base/raw_logging_internal + - abseil/debugging/debugging_internal + - abseil/xcprivacy + - abseil/debugging/symbolize (1.20240116.2): + - abseil/base/base + - abseil/base/config + - abseil/base/core_headers + - abseil/base/dynamic_annotations + - abseil/base/malloc_internal + - abseil/base/raw_logging_internal + - abseil/debugging/debugging_internal + - abseil/debugging/demangle_internal + - abseil/strings/strings + - abseil/xcprivacy + - abseil/flags/commandlineflag (1.20240116.2): + - abseil/base/config + - abseil/base/fast_type_id + - abseil/flags/commandlineflag_internal + - abseil/strings/strings + - abseil/types/optional + - abseil/xcprivacy + - abseil/flags/commandlineflag_internal (1.20240116.2): + - abseil/base/config + - abseil/base/fast_type_id + - abseil/xcprivacy + - abseil/flags/config (1.20240116.2): + - abseil/base/config + - abseil/base/core_headers + - abseil/flags/path_util + - abseil/flags/program_name + - abseil/strings/strings + - abseil/synchronization/synchronization + - abseil/xcprivacy + - abseil/flags/flag (1.20240116.2): + - abseil/base/base + - abseil/base/config + - abseil/base/core_headers + - abseil/flags/config + - abseil/flags/flag_internal + - abseil/flags/reflection + - abseil/strings/strings + - abseil/xcprivacy + - abseil/flags/flag_internal (1.20240116.2): + - abseil/base/base + - abseil/base/config + - abseil/base/core_headers + - abseil/base/dynamic_annotations + - abseil/flags/commandlineflag + - abseil/flags/commandlineflag_internal + - abseil/flags/config + - abseil/flags/marshalling + - abseil/flags/reflection + - abseil/memory/memory + - abseil/meta/type_traits + - abseil/strings/strings + - abseil/synchronization/synchronization + - abseil/utility/utility + - abseil/xcprivacy + - abseil/flags/marshalling (1.20240116.2): + - abseil/base/config + - abseil/base/core_headers + - abseil/base/log_severity + - abseil/numeric/int128 + - abseil/strings/str_format + - abseil/strings/strings + - abseil/types/optional + - abseil/xcprivacy + - abseil/flags/path_util (1.20240116.2): + - abseil/base/config + - abseil/strings/strings + - abseil/xcprivacy + - abseil/flags/private_handle_accessor (1.20240116.2): + - abseil/base/config + - abseil/flags/commandlineflag + - abseil/flags/commandlineflag_internal + - abseil/strings/strings + - abseil/xcprivacy + - abseil/flags/program_name (1.20240116.2): + - abseil/base/config + - abseil/base/core_headers + - abseil/flags/path_util + - abseil/strings/strings + - abseil/synchronization/synchronization + - abseil/xcprivacy + - abseil/flags/reflection (1.20240116.2): + - abseil/base/config + - abseil/base/core_headers + - abseil/base/no_destructor + - abseil/container/flat_hash_map + - abseil/flags/commandlineflag + - abseil/flags/commandlineflag_internal + - abseil/flags/config + - abseil/flags/private_handle_accessor + - abseil/strings/strings + - abseil/synchronization/synchronization + - abseil/xcprivacy + - abseil/functional/any_invocable (1.20240116.2): + - abseil/base/base_internal + - abseil/base/config + - abseil/base/core_headers + - abseil/meta/type_traits + - abseil/utility/utility + - abseil/xcprivacy + - abseil/functional/bind_front (1.20240116.2): + - abseil/base/base_internal + - abseil/container/compressed_tuple + - abseil/meta/type_traits + - abseil/utility/utility + - abseil/xcprivacy + - abseil/functional/function_ref (1.20240116.2): + - abseil/base/base_internal + - abseil/base/core_headers + - abseil/functional/any_invocable + - abseil/meta/type_traits + - abseil/xcprivacy + - abseil/hash/city (1.20240116.2): + - abseil/base/config + - abseil/base/core_headers + - abseil/base/endian + - abseil/xcprivacy + - abseil/hash/hash (1.20240116.2): + - abseil/base/config + - abseil/base/core_headers + - abseil/base/endian + - abseil/container/fixed_array + - abseil/functional/function_ref + - abseil/hash/city + - abseil/hash/low_level_hash + - abseil/meta/type_traits + - abseil/numeric/bits + - abseil/numeric/int128 + - abseil/strings/strings + - abseil/types/optional + - abseil/types/variant + - abseil/utility/utility + - abseil/xcprivacy + - abseil/hash/low_level_hash (1.20240116.2): + - abseil/base/config + - abseil/base/endian + - abseil/base/prefetch + - abseil/numeric/int128 + - abseil/xcprivacy + - abseil/log/absl_check (1.20240116.2): + - abseil/log/internal/check_impl + - abseil/xcprivacy + - abseil/log/absl_log (1.20240116.2): + - abseil/log/internal/log_impl + - abseil/xcprivacy + - abseil/log/absl_vlog_is_on (1.20240116.2): + - abseil/base/config + - abseil/base/core_headers + - abseil/log/internal/vlog_config + - abseil/strings/strings + - abseil/xcprivacy + - abseil/log/check (1.20240116.2): + - abseil/log/internal/check_impl + - abseil/log/internal/check_op + - abseil/log/internal/conditions + - abseil/log/internal/log_message + - abseil/log/internal/strip + - abseil/xcprivacy + - abseil/log/globals (1.20240116.2): + - abseil/base/atomic_hook + - abseil/base/config + - abseil/base/core_headers + - abseil/base/log_severity + - abseil/base/raw_logging_internal + - abseil/hash/hash + - abseil/log/internal/vlog_config + - abseil/strings/strings + - abseil/xcprivacy + - abseil/log/internal/append_truncated (1.20240116.2): + - abseil/base/config + - abseil/strings/strings + - abseil/types/span + - abseil/xcprivacy + - abseil/log/internal/check_impl (1.20240116.2): + - abseil/base/core_headers + - abseil/log/internal/check_op + - abseil/log/internal/conditions + - abseil/log/internal/log_message + - abseil/log/internal/strip + - abseil/xcprivacy + - abseil/log/internal/check_op (1.20240116.2): + - abseil/base/config + - abseil/base/core_headers + - abseil/log/internal/nullguard + - abseil/log/internal/nullstream + - abseil/log/internal/strip + - abseil/strings/strings + - abseil/xcprivacy + - abseil/log/internal/conditions (1.20240116.2): + - abseil/base/base + - abseil/base/config + - abseil/base/core_headers + - abseil/log/internal/voidify + - abseil/xcprivacy + - abseil/log/internal/config (1.20240116.2): + - abseil/base/config + - abseil/base/core_headers + - abseil/xcprivacy + - abseil/log/internal/fnmatch (1.20240116.2): + - abseil/base/config + - abseil/strings/strings + - abseil/xcprivacy + - abseil/log/internal/format (1.20240116.2): + - abseil/base/config + - abseil/base/core_headers + - abseil/base/log_severity + - abseil/log/internal/append_truncated + - abseil/log/internal/config + - abseil/log/internal/globals + - abseil/strings/str_format + - abseil/strings/strings + - abseil/time/time + - abseil/types/span + - abseil/xcprivacy + - abseil/log/internal/globals (1.20240116.2): + - abseil/base/config + - abseil/base/core_headers + - abseil/base/log_severity + - abseil/base/raw_logging_internal + - abseil/strings/strings + - abseil/time/time + - abseil/xcprivacy + - abseil/log/internal/log_impl (1.20240116.2): + - abseil/log/absl_vlog_is_on + - abseil/log/internal/conditions + - abseil/log/internal/log_message + - abseil/log/internal/strip + - abseil/xcprivacy + - abseil/log/internal/log_message (1.20240116.2): + - abseil/base/base + - abseil/base/config + - abseil/base/core_headers + - abseil/base/errno_saver + - abseil/base/log_severity + - abseil/base/raw_logging_internal + - abseil/base/strerror + - abseil/container/inlined_vector + - abseil/debugging/examine_stack + - abseil/log/globals + - abseil/log/internal/append_truncated + - abseil/log/internal/format + - abseil/log/internal/globals + - abseil/log/internal/log_sink_set + - abseil/log/internal/nullguard + - abseil/log/internal/proto + - abseil/log/log_entry + - abseil/log/log_sink + - abseil/log/log_sink_registry + - abseil/memory/memory + - abseil/strings/strings + - abseil/time/time + - abseil/types/span + - abseil/xcprivacy + - abseil/log/internal/log_sink_set (1.20240116.2): + - abseil/base/base + - abseil/base/config + - abseil/base/core_headers + - abseil/base/log_severity + - abseil/base/no_destructor + - abseil/base/raw_logging_internal + - abseil/cleanup/cleanup + - abseil/log/globals + - abseil/log/internal/config + - abseil/log/internal/globals + - abseil/log/log_entry + - abseil/log/log_sink + - abseil/strings/strings + - abseil/synchronization/synchronization + - abseil/types/span + - abseil/xcprivacy + - abseil/log/internal/nullguard (1.20240116.2): + - abseil/base/config + - abseil/base/core_headers + - abseil/xcprivacy + - abseil/log/internal/nullstream (1.20240116.2): + - abseil/base/config + - abseil/base/core_headers + - abseil/base/log_severity + - abseil/strings/strings + - abseil/xcprivacy + - abseil/log/internal/proto (1.20240116.2): + - abseil/base/base + - abseil/base/config + - abseil/base/core_headers + - abseil/strings/strings + - abseil/types/span + - abseil/xcprivacy + - abseil/log/internal/strip (1.20240116.2): + - abseil/base/log_severity + - abseil/log/internal/log_message + - abseil/log/internal/nullstream + - abseil/xcprivacy + - abseil/log/internal/vlog_config (1.20240116.2): + - abseil/base/base + - abseil/base/config + - abseil/base/core_headers + - abseil/base/no_destructor + - abseil/log/internal/fnmatch + - abseil/memory/memory + - abseil/strings/strings + - abseil/synchronization/synchronization + - abseil/types/optional + - abseil/xcprivacy + - abseil/log/internal/voidify (1.20240116.2): + - abseil/base/config + - abseil/xcprivacy + - abseil/log/log (1.20240116.2): + - abseil/log/internal/log_impl + - abseil/log/vlog_is_on + - abseil/xcprivacy + - abseil/log/log_entry (1.20240116.2): + - abseil/base/config + - abseil/base/core_headers + - abseil/base/log_severity + - abseil/log/internal/config + - abseil/strings/strings + - abseil/time/time + - abseil/types/span + - abseil/xcprivacy + - abseil/log/log_sink (1.20240116.2): + - abseil/base/config + - abseil/log/log_entry + - abseil/xcprivacy + - abseil/log/log_sink_registry (1.20240116.2): + - abseil/base/config + - abseil/log/internal/log_sink_set + - abseil/log/log_sink + - abseil/xcprivacy + - abseil/log/vlog_is_on (1.20240116.2): + - abseil/log/absl_vlog_is_on + - abseil/xcprivacy + - abseil/memory (1.20240116.2): + - abseil/memory/memory (= 1.20240116.2) + - abseil/memory/memory (1.20240116.2): + - abseil/base/core_headers + - abseil/meta/type_traits + - abseil/xcprivacy + - abseil/meta (1.20240116.2): + - abseil/meta/type_traits (= 1.20240116.2) + - abseil/meta/type_traits (1.20240116.2): + - abseil/base/config + - abseil/base/core_headers + - abseil/xcprivacy + - abseil/numeric/bits (1.20240116.2): + - abseil/base/config + - abseil/base/core_headers + - abseil/xcprivacy + - abseil/numeric/int128 (1.20240116.2): + - abseil/base/config + - abseil/base/core_headers + - abseil/numeric/bits + - abseil/xcprivacy + - abseil/numeric/representation (1.20240116.2): + - abseil/base/config + - abseil/xcprivacy + - abseil/profiling/exponential_biased (1.20240116.2): + - abseil/base/config + - abseil/base/core_headers + - abseil/xcprivacy + - abseil/profiling/sample_recorder (1.20240116.2): + - abseil/base/config + - abseil/base/core_headers + - abseil/synchronization/synchronization + - abseil/time/time + - abseil/xcprivacy + - abseil/random/bit_gen_ref (1.20240116.2): + - abseil/base/core_headers + - abseil/base/fast_type_id + - abseil/meta/type_traits + - abseil/random/internal/distribution_caller + - abseil/random/internal/fast_uniform_bits + - abseil/random/random + - abseil/xcprivacy + - abseil/random/distributions (1.20240116.2): + - abseil/base/base_internal + - abseil/base/config + - abseil/base/core_headers + - abseil/meta/type_traits + - abseil/numeric/bits + - abseil/random/internal/distribution_caller + - abseil/random/internal/fast_uniform_bits + - abseil/random/internal/fastmath + - abseil/random/internal/generate_real + - abseil/random/internal/iostream_state_saver + - abseil/random/internal/traits + - abseil/random/internal/uniform_helper + - abseil/random/internal/wide_multiply + - abseil/strings/strings + - abseil/xcprivacy + - abseil/random/internal/distribution_caller (1.20240116.2): + - abseil/base/config + - abseil/base/fast_type_id + - abseil/utility/utility + - abseil/xcprivacy + - abseil/random/internal/fast_uniform_bits (1.20240116.2): + - abseil/base/config + - abseil/meta/type_traits + - abseil/random/internal/traits + - abseil/xcprivacy + - abseil/random/internal/fastmath (1.20240116.2): + - abseil/numeric/bits + - abseil/xcprivacy + - abseil/random/internal/generate_real (1.20240116.2): + - abseil/meta/type_traits + - abseil/numeric/bits + - abseil/random/internal/fastmath + - abseil/random/internal/traits + - abseil/xcprivacy + - abseil/random/internal/iostream_state_saver (1.20240116.2): + - abseil/meta/type_traits + - abseil/numeric/int128 + - abseil/xcprivacy + - abseil/random/internal/nonsecure_base (1.20240116.2): + - abseil/base/core_headers + - abseil/container/inlined_vector + - abseil/meta/type_traits + - abseil/random/internal/pool_urbg + - abseil/random/internal/salted_seed_seq + - abseil/random/internal/seed_material + - abseil/types/span + - abseil/xcprivacy + - abseil/random/internal/pcg_engine (1.20240116.2): + - abseil/base/config + - abseil/meta/type_traits + - abseil/numeric/bits + - abseil/numeric/int128 + - abseil/random/internal/fastmath + - abseil/random/internal/iostream_state_saver + - abseil/xcprivacy + - abseil/random/internal/platform (1.20240116.2): + - abseil/base/config + - abseil/xcprivacy + - abseil/random/internal/pool_urbg (1.20240116.2): + - abseil/base/base + - abseil/base/config + - abseil/base/core_headers + - abseil/base/endian + - abseil/base/raw_logging_internal + - abseil/random/internal/randen + - abseil/random/internal/seed_material + - abseil/random/internal/traits + - abseil/random/seed_gen_exception + - abseil/types/span + - abseil/xcprivacy + - abseil/random/internal/randen (1.20240116.2): + - abseil/base/raw_logging_internal + - abseil/random/internal/platform + - abseil/random/internal/randen_hwaes + - abseil/random/internal/randen_slow + - abseil/xcprivacy + - abseil/random/internal/randen_engine (1.20240116.2): + - abseil/base/endian + - abseil/meta/type_traits + - abseil/random/internal/iostream_state_saver + - abseil/random/internal/randen + - abseil/xcprivacy + - abseil/random/internal/randen_hwaes (1.20240116.2): + - abseil/base/config + - abseil/random/internal/platform + - abseil/random/internal/randen_hwaes_impl + - abseil/xcprivacy + - abseil/random/internal/randen_hwaes_impl (1.20240116.2): + - abseil/base/config + - abseil/base/core_headers + - abseil/numeric/int128 + - abseil/random/internal/platform + - abseil/xcprivacy + - abseil/random/internal/randen_slow (1.20240116.2): + - abseil/base/config + - abseil/base/core_headers + - abseil/base/endian + - abseil/numeric/int128 + - abseil/random/internal/platform + - abseil/xcprivacy + - abseil/random/internal/salted_seed_seq (1.20240116.2): + - abseil/container/inlined_vector + - abseil/meta/type_traits + - abseil/random/internal/seed_material + - abseil/types/optional + - abseil/types/span + - abseil/xcprivacy + - abseil/random/internal/seed_material (1.20240116.2): + - abseil/base/core_headers + - abseil/base/dynamic_annotations + - abseil/base/raw_logging_internal + - abseil/random/internal/fast_uniform_bits + - abseil/strings/strings + - abseil/types/optional + - abseil/types/span + - abseil/xcprivacy + - abseil/random/internal/traits (1.20240116.2): + - abseil/base/config + - abseil/numeric/bits + - abseil/numeric/int128 + - abseil/xcprivacy + - abseil/random/internal/uniform_helper (1.20240116.2): + - abseil/base/config + - abseil/meta/type_traits + - abseil/numeric/int128 + - abseil/random/internal/traits + - abseil/xcprivacy + - abseil/random/internal/wide_multiply (1.20240116.2): + - abseil/base/config + - abseil/numeric/bits + - abseil/numeric/int128 + - abseil/random/internal/traits + - abseil/xcprivacy + - abseil/random/random (1.20240116.2): + - abseil/random/distributions + - abseil/random/internal/nonsecure_base + - abseil/random/internal/pcg_engine + - abseil/random/internal/pool_urbg + - abseil/random/internal/randen_engine + - abseil/random/seed_sequences + - abseil/xcprivacy + - abseil/random/seed_gen_exception (1.20240116.2): + - abseil/base/config + - abseil/xcprivacy + - abseil/random/seed_sequences (1.20240116.2): + - abseil/base/config + - abseil/random/internal/pool_urbg + - abseil/random/internal/salted_seed_seq + - abseil/random/internal/seed_material + - abseil/random/seed_gen_exception + - abseil/types/span + - abseil/xcprivacy + - abseil/status/status (1.20240116.2): + - abseil/base/atomic_hook + - abseil/base/config + - abseil/base/core_headers + - abseil/base/no_destructor + - abseil/base/nullability + - abseil/base/raw_logging_internal + - abseil/base/strerror + - abseil/container/inlined_vector + - abseil/debugging/stacktrace + - abseil/debugging/symbolize + - abseil/functional/function_ref + - abseil/memory/memory + - abseil/strings/cord + - abseil/strings/str_format + - abseil/strings/strings + - abseil/types/optional + - abseil/types/span + - abseil/xcprivacy + - abseil/status/statusor (1.20240116.2): + - abseil/base/base + - abseil/base/config + - abseil/base/core_headers + - abseil/base/nullability + - abseil/base/raw_logging_internal + - abseil/meta/type_traits + - abseil/status/status + - abseil/strings/has_ostream_operator + - abseil/strings/str_format + - abseil/strings/strings + - abseil/types/variant + - abseil/utility/utility + - abseil/xcprivacy + - abseil/strings/charset (1.20240116.2): + - abseil/base/core_headers + - abseil/strings/string_view + - abseil/xcprivacy + - abseil/strings/cord (1.20240116.2): + - abseil/base/base + - abseil/base/config + - abseil/base/core_headers + - abseil/base/endian + - abseil/base/nullability + - abseil/base/raw_logging_internal + - abseil/container/inlined_vector + - abseil/crc/crc32c + - abseil/crc/crc_cord_state + - abseil/functional/function_ref + - abseil/meta/type_traits + - abseil/numeric/bits + - abseil/strings/cord_internal + - abseil/strings/cordz_functions + - abseil/strings/cordz_info + - abseil/strings/cordz_statistics + - abseil/strings/cordz_update_scope + - abseil/strings/cordz_update_tracker + - abseil/strings/internal + - abseil/strings/strings + - abseil/types/optional + - abseil/types/span + - abseil/xcprivacy + - abseil/strings/cord_internal (1.20240116.2): + - abseil/base/base_internal + - abseil/base/config + - abseil/base/core_headers + - abseil/base/endian + - abseil/base/raw_logging_internal + - abseil/base/throw_delegate + - abseil/container/compressed_tuple + - abseil/container/container_memory + - abseil/container/inlined_vector + - abseil/container/layout + - abseil/crc/crc_cord_state + - abseil/functional/function_ref + - abseil/meta/type_traits + - abseil/strings/strings + - abseil/types/span + - abseil/xcprivacy + - abseil/strings/cordz_functions (1.20240116.2): + - abseil/base/config + - abseil/base/core_headers + - abseil/base/raw_logging_internal + - abseil/profiling/exponential_biased + - abseil/xcprivacy + - abseil/strings/cordz_handle (1.20240116.2): + - abseil/base/base + - abseil/base/config + - abseil/base/raw_logging_internal + - abseil/synchronization/synchronization + - abseil/xcprivacy + - abseil/strings/cordz_info (1.20240116.2): + - abseil/base/base + - abseil/base/config + - abseil/base/core_headers + - abseil/base/raw_logging_internal + - abseil/container/inlined_vector + - abseil/debugging/stacktrace + - abseil/strings/cord_internal + - abseil/strings/cordz_functions + - abseil/strings/cordz_handle + - abseil/strings/cordz_statistics + - abseil/strings/cordz_update_tracker + - abseil/synchronization/synchronization + - abseil/time/time + - abseil/types/span + - abseil/xcprivacy + - abseil/strings/cordz_statistics (1.20240116.2): + - abseil/base/config + - abseil/strings/cordz_update_tracker + - abseil/xcprivacy + - abseil/strings/cordz_update_scope (1.20240116.2): + - abseil/base/config + - abseil/base/core_headers + - abseil/strings/cord_internal + - abseil/strings/cordz_info + - abseil/strings/cordz_update_tracker + - abseil/xcprivacy + - abseil/strings/cordz_update_tracker (1.20240116.2): + - abseil/base/config + - abseil/xcprivacy + - abseil/strings/has_ostream_operator (1.20240116.2): + - abseil/base/config + - abseil/xcprivacy + - abseil/strings/internal (1.20240116.2): + - abseil/base/config + - abseil/base/core_headers + - abseil/base/endian + - abseil/base/raw_logging_internal + - abseil/meta/type_traits + - abseil/xcprivacy + - abseil/strings/str_format (1.20240116.2): + - abseil/base/config + - abseil/base/core_headers + - abseil/base/nullability + - abseil/strings/str_format_internal + - abseil/strings/string_view + - abseil/types/span + - abseil/xcprivacy + - abseil/strings/str_format_internal (1.20240116.2): + - abseil/base/config + - abseil/base/core_headers + - abseil/container/fixed_array + - abseil/container/inlined_vector + - abseil/functional/function_ref + - abseil/meta/type_traits + - abseil/numeric/bits + - abseil/numeric/int128 + - abseil/numeric/representation + - abseil/strings/strings + - abseil/types/optional + - abseil/types/span + - abseil/utility/utility + - abseil/xcprivacy + - abseil/strings/string_view (1.20240116.2): + - abseil/base/base + - abseil/base/config + - abseil/base/core_headers + - abseil/base/nullability + - abseil/base/throw_delegate + - abseil/xcprivacy + - abseil/strings/strings (1.20240116.2): + - abseil/base/base + - abseil/base/config + - abseil/base/core_headers + - abseil/base/endian + - abseil/base/nullability + - abseil/base/raw_logging_internal + - abseil/base/throw_delegate + - abseil/memory/memory + - abseil/meta/type_traits + - abseil/numeric/bits + - abseil/numeric/int128 + - abseil/strings/charset + - abseil/strings/internal + - abseil/strings/string_view + - abseil/xcprivacy + - abseil/synchronization/graphcycles_internal (1.20240116.2): + - abseil/base/base + - abseil/base/base_internal + - abseil/base/config + - abseil/base/core_headers + - abseil/base/malloc_internal + - abseil/base/raw_logging_internal + - abseil/xcprivacy + - abseil/synchronization/kernel_timeout_internal (1.20240116.2): + - abseil/base/base + - abseil/base/config + - abseil/base/core_headers + - abseil/base/raw_logging_internal + - abseil/time/time + - abseil/xcprivacy + - abseil/synchronization/synchronization (1.20240116.2): + - abseil/base/atomic_hook + - abseil/base/base + - abseil/base/base_internal + - abseil/base/config + - abseil/base/core_headers + - abseil/base/dynamic_annotations + - abseil/base/malloc_internal + - abseil/base/raw_logging_internal + - abseil/debugging/stacktrace + - abseil/debugging/symbolize + - abseil/synchronization/graphcycles_internal + - abseil/synchronization/kernel_timeout_internal + - abseil/time/time + - abseil/xcprivacy + - abseil/time (1.20240116.2): + - abseil/time/internal (= 1.20240116.2) + - abseil/time/time (= 1.20240116.2) + - abseil/time/internal (1.20240116.2): + - abseil/time/internal/cctz (= 1.20240116.2) + - abseil/time/internal/cctz (1.20240116.2): + - abseil/time/internal/cctz/civil_time (= 1.20240116.2) + - abseil/time/internal/cctz/time_zone (= 1.20240116.2) + - abseil/time/internal/cctz/civil_time (1.20240116.2): + - abseil/base/config + - abseil/xcprivacy + - abseil/time/internal/cctz/time_zone (1.20240116.2): + - abseil/base/config + - abseil/time/internal/cctz/civil_time + - abseil/xcprivacy + - abseil/time/time (1.20240116.2): + - abseil/base/base + - abseil/base/config + - abseil/base/core_headers + - abseil/base/raw_logging_internal + - abseil/numeric/int128 + - abseil/strings/strings + - abseil/time/internal/cctz/civil_time + - abseil/time/internal/cctz/time_zone + - abseil/types/optional + - abseil/xcprivacy + - abseil/types (1.20240116.2): + - abseil/types/any (= 1.20240116.2) + - abseil/types/bad_any_cast (= 1.20240116.2) + - abseil/types/bad_any_cast_impl (= 1.20240116.2) + - abseil/types/bad_optional_access (= 1.20240116.2) + - abseil/types/bad_variant_access (= 1.20240116.2) + - abseil/types/compare (= 1.20240116.2) + - abseil/types/optional (= 1.20240116.2) + - abseil/types/span (= 1.20240116.2) + - abseil/types/variant (= 1.20240116.2) + - abseil/types/any (1.20240116.2): + - abseil/base/config + - abseil/base/core_headers + - abseil/base/fast_type_id + - abseil/meta/type_traits + - abseil/types/bad_any_cast + - abseil/utility/utility + - abseil/xcprivacy + - abseil/types/bad_any_cast (1.20240116.2): + - abseil/base/config + - abseil/types/bad_any_cast_impl + - abseil/xcprivacy + - abseil/types/bad_any_cast_impl (1.20240116.2): + - abseil/base/config + - abseil/base/raw_logging_internal + - abseil/xcprivacy + - abseil/types/bad_optional_access (1.20240116.2): + - abseil/base/config + - abseil/base/raw_logging_internal + - abseil/xcprivacy + - abseil/types/bad_variant_access (1.20240116.2): + - abseil/base/config + - abseil/base/raw_logging_internal + - abseil/xcprivacy + - abseil/types/compare (1.20240116.2): + - abseil/base/config + - abseil/base/core_headers + - abseil/meta/type_traits + - abseil/xcprivacy + - abseil/types/optional (1.20240116.2): + - abseil/base/base_internal + - abseil/base/config + - abseil/base/core_headers + - abseil/base/nullability + - abseil/memory/memory + - abseil/meta/type_traits + - abseil/types/bad_optional_access + - abseil/utility/utility + - abseil/xcprivacy + - abseil/types/span (1.20240116.2): + - abseil/algorithm/algorithm + - abseil/base/core_headers + - abseil/base/nullability + - abseil/base/throw_delegate + - abseil/meta/type_traits + - abseil/xcprivacy + - abseil/types/variant (1.20240116.2): + - abseil/base/base_internal + - abseil/base/config + - abseil/base/core_headers + - abseil/meta/type_traits + - abseil/types/bad_variant_access + - abseil/utility/utility + - abseil/xcprivacy + - abseil/utility/utility (1.20240116.2): + - abseil/base/base_internal + - abseil/base/config + - abseil/meta/type_traits + - abseil/xcprivacy + - abseil/xcprivacy (1.20240116.2) + - amplitude-react-native (1.4.11): + - React-Core + - Apollo (1.2.1): + - Apollo/Core (= 1.2.1) + - Apollo/Core (1.2.1) + - AppsFlyerFramework (6.13.1): + - AppsFlyerFramework/Main (= 6.13.1) + - AppsFlyerFramework/Main (6.13.1) + - Argon2Swift (1.0.3) + - boost (1.84.0) + - BoringSSL-GRPC (0.0.36): + - BoringSSL-GRPC/Implementation (= 0.0.36) + - BoringSSL-GRPC/Interface (= 0.0.36) + - BoringSSL-GRPC/Implementation (0.0.36): + - BoringSSL-GRPC/Interface (= 0.0.36) + - BoringSSL-GRPC/Interface (0.0.36) + - DatadogCore (2.30.0): + - DatadogInternal (= 2.30.0) + - DatadogCrashReporting (2.30.0): + - DatadogInternal (= 2.30.0) + - PLCrashReporter (~> 1.12.0) + - DatadogInternal (2.30.0) + - DatadogLogs (2.30.0): + - DatadogInternal (= 2.30.0) + - DatadogRUM (2.30.0): + - DatadogInternal (= 2.30.0) + - DatadogSDKReactNative (2.12.2): + - DatadogCore (= 2.30.0) + - DatadogCrashReporting (= 2.30.0) + - DatadogLogs (= 2.30.0) + - DatadogRUM (= 2.30.0) + - DatadogTrace (= 2.30.0) + - DatadogWebViewTracking (= 2.30.0) + - React-Core + - DatadogTrace (2.30.0): + - DatadogInternal (= 2.30.0) + - OpenTelemetrySwiftApi (= 1.13.1) + - DatadogWebViewTracking (2.30.0): + - DatadogInternal (= 2.30.0) + - DoubleConversion (1.1.6) + - EthersRS (0.0.5) + - EXConstants (17.1.8): + - ExpoModulesCore + - EXJSONUtils (0.15.0) + - EXManifests (0.16.6): + - ExpoModulesCore + - Expo (53.0.22): + - DoubleConversion + - ExpoModulesCore + - glog + - hermes-engine + - RCT-Folly (= 2024.11.18.00) + - RCTRequired + - RCTTypeSafety + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTAppDelegate + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactAppDependencyProvider + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - Yoga + - expo-dev-client (5.2.4): + - EXManifests + - expo-dev-launcher + - expo-dev-menu + - expo-dev-menu-interface + - EXUpdatesInterface + - expo-dev-launcher (5.1.16): + - DoubleConversion + - EXManifests + - expo-dev-launcher/Main (= 5.1.16) + - expo-dev-menu + - expo-dev-menu-interface + - ExpoModulesCore + - EXUpdatesInterface + - glog + - hermes-engine + - RCT-Folly (= 2024.11.18.00) + - RCTRequired + - RCTTypeSafety + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-ImageManager + - React-jsi + - React-jsinspector + - React-NativeModulesApple + - React-RCTAppDelegate + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactAppDependencyProvider + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - Yoga + - expo-dev-launcher/Main (5.1.16): + - DoubleConversion + - EXManifests + - expo-dev-launcher/Unsafe + - expo-dev-menu + - expo-dev-menu-interface + - ExpoModulesCore + - EXUpdatesInterface + - glog + - hermes-engine + - RCT-Folly (= 2024.11.18.00) + - RCTRequired + - RCTTypeSafety + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-ImageManager + - React-jsi + - React-jsinspector + - React-NativeModulesApple + - React-RCTAppDelegate + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactAppDependencyProvider + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - Yoga + - expo-dev-launcher/Unsafe (5.1.16): + - DoubleConversion + - EXManifests + - expo-dev-menu + - expo-dev-menu-interface + - ExpoModulesCore + - EXUpdatesInterface + - glog + - hermes-engine + - RCT-Folly (= 2024.11.18.00) + - RCTRequired + - RCTTypeSafety + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-ImageManager + - React-jsi + - React-jsinspector + - React-NativeModulesApple + - React-RCTAppDelegate + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactAppDependencyProvider + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - Yoga + - expo-dev-menu (6.1.14): + - DoubleConversion + - expo-dev-menu/Main (= 6.1.14) + - expo-dev-menu/ReactNativeCompatibles (= 6.1.14) + - glog + - hermes-engine + - RCT-Folly (= 2024.11.18.00) + - RCTRequired + - RCTTypeSafety + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - Yoga + - expo-dev-menu-interface (1.10.0) + - expo-dev-menu/Main (6.1.14): + - DoubleConversion + - EXManifests + - expo-dev-menu-interface + - expo-dev-menu/Vendored + - ExpoModulesCore + - glog + - hermes-engine + - RCT-Folly (= 2024.11.18.00) + - RCTRequired + - RCTTypeSafety + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-ImageManager + - React-jsi + - React-jsinspector + - React-NativeModulesApple + - React-RCTFabric + - React-rendererconsistency + - React-renderercss + - React-rendererdebug + - React-utils + - ReactAppDependencyProvider + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - Yoga + - expo-dev-menu/ReactNativeCompatibles (6.1.14): + - DoubleConversion + - glog + - hermes-engine + - RCT-Folly (= 2024.11.18.00) + - RCTRequired + - RCTTypeSafety + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - Yoga + - expo-dev-menu/SafeAreaView (6.1.14): + - DoubleConversion + - ExpoModulesCore + - glog + - hermes-engine + - RCT-Folly (= 2024.11.18.00) + - RCTRequired + - RCTTypeSafety + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - Yoga + - expo-dev-menu/Vendored (6.1.14): + - DoubleConversion + - expo-dev-menu/SafeAreaView + - glog + - hermes-engine + - RCT-Folly (= 2024.11.18.00) + - RCTRequired + - RCTTypeSafety + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - Yoga + - ExpoAsset (11.1.7): + - ExpoModulesCore + - ExpoBlur (14.1.5): + - ExpoModulesCore + - ExpoCamera (16.1.11): + - ExpoModulesCore + - ZXingObjC/OneD + - ZXingObjC/PDF417 + - ExpoClipboard (7.1.5): + - ExpoModulesCore + - ExpoFileSystem (18.1.11): + - ExpoModulesCore + - ExpoFont (13.3.2): + - ExpoModulesCore + - ExpoHaptics (14.0.1): + - ExpoModulesCore + - ExpoImage (2.4.1): + - ExpoModulesCore + - libavif/libdav1d + - SDWebImage (~> 5.21.0) + - SDWebImageAVIFCoder (~> 0.11.0) + - SDWebImageSVGCoder (~> 1.7.0) + - SDWebImageWebPCoder (~> 0.14.6) + - ExpoKeepAwake (14.1.4): + - ExpoModulesCore + - ExpoLinearGradient (14.1.5): + - ExpoModulesCore + - ExpoLinking (7.1.7): + - ExpoModulesCore + - ExpoLocalAuthentication (16.0.5): + - ExpoModulesCore + - ExpoLocalization (16.1.6): + - ExpoModulesCore + - ExpoModulesCore (2.5.0): + - DoubleConversion + - glog + - hermes-engine + - RCT-Folly (= 2024.11.18.00) + - RCTRequired + - RCTTypeSafety + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-ImageManager + - React-jsi + - React-jsinspector + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - Yoga + - ExpoScreenCapture (7.2.0): + - ExpoModulesCore + - ExpoSecureStore (14.0.1): + - ExpoModulesCore + - ExpoStoreReview (8.1.5): + - ExpoModulesCore + - ExpoWebBrowser (14.2.0): + - ExpoModulesCore + - EXUpdatesInterface (1.1.0): + - ExpoModulesCore + - fast_float (6.1.4) + - FBLazyVector (0.79.5) + - Firebase/Auth (11.2.0): + - Firebase/CoreOnly + - FirebaseAuth (~> 11.2.0) + - Firebase/CoreOnly (11.2.0): + - FirebaseCore (= 11.2.0) + - Firebase/Firestore (11.2.0): + - Firebase/CoreOnly + - FirebaseFirestore (~> 11.2.0) + - FirebaseAppCheckInterop (11.15.0) + - FirebaseAuth (11.2.0): + - FirebaseAppCheckInterop (~> 11.0) + - FirebaseAuthInterop (~> 11.0) + - FirebaseCore (~> 11.0) + - FirebaseCoreExtension (~> 11.0) + - GoogleUtilities/AppDelegateSwizzler (~> 8.0) + - GoogleUtilities/Environment (~> 8.0) + - GTMSessionFetcher/Core (~> 3.4) + - RecaptchaInterop (~> 100.0) + - FirebaseAuthInterop (11.15.0) + - FirebaseCore (11.2.0): + - FirebaseCoreInternal (~> 11.0) + - GoogleUtilities/Environment (~> 8.0) + - GoogleUtilities/Logger (~> 8.0) + - FirebaseCoreExtension (11.4.1): + - FirebaseCore (~> 11.0) + - FirebaseCoreInternal (11.15.0): + - "GoogleUtilities/NSData+zlib (~> 8.1)" + - FirebaseFirestore (11.2.0): + - FirebaseCore (~> 11.0) + - FirebaseCoreExtension (~> 11.0) + - FirebaseFirestoreInternal (= 11.2.0) + - FirebaseSharedSwift (~> 11.0) + - FirebaseFirestoreInternal (11.2.0): + - abseil/algorithm (~> 1.20240116.1) + - abseil/base (~> 1.20240116.1) + - abseil/container/flat_hash_map (~> 1.20240116.1) + - abseil/memory (~> 1.20240116.1) + - abseil/meta (~> 1.20240116.1) + - abseil/strings/strings (~> 1.20240116.1) + - abseil/time (~> 1.20240116.1) + - abseil/types (~> 1.20240116.1) + - FirebaseAppCheckInterop (~> 11.0) + - FirebaseCore (~> 11.0) + - "gRPC-C++ (~> 1.65.0)" + - gRPC-Core (~> 1.65.0) + - leveldb-library (~> 1.22) + - nanopb (~> 3.30910.0) + - FirebaseSharedSwift (11.15.0) + - fmt (11.0.2) + - glog (0.3.5) + - GoogleUtilities/AppDelegateSwizzler (8.1.0): + - GoogleUtilities/Environment + - GoogleUtilities/Logger + - GoogleUtilities/Network + - GoogleUtilities/Privacy + - GoogleUtilities/Environment (8.1.0): + - GoogleUtilities/Privacy + - GoogleUtilities/Logger (8.1.0): + - GoogleUtilities/Environment + - GoogleUtilities/Privacy + - GoogleUtilities/Network (8.1.0): + - GoogleUtilities/Logger + - "GoogleUtilities/NSData+zlib" + - GoogleUtilities/Privacy + - GoogleUtilities/Reachability + - "GoogleUtilities/NSData+zlib (8.1.0)": + - GoogleUtilities/Privacy + - GoogleUtilities/Privacy (8.1.0) + - GoogleUtilities/Reachability (8.1.0): + - GoogleUtilities/Logger + - GoogleUtilities/Privacy + - "gRPC-C++ (1.65.5)": + - "gRPC-C++/Implementation (= 1.65.5)" + - "gRPC-C++/Interface (= 1.65.5)" + - "gRPC-C++/Implementation (1.65.5)": + - abseil/algorithm/container (~> 1.20240116.2) + - abseil/base/base (~> 1.20240116.2) + - abseil/base/config (~> 1.20240116.2) + - abseil/base/core_headers (~> 1.20240116.2) + - abseil/base/log_severity (~> 1.20240116.2) + - abseil/base/no_destructor (~> 1.20240116.2) + - abseil/cleanup/cleanup (~> 1.20240116.2) + - abseil/container/flat_hash_map (~> 1.20240116.2) + - abseil/container/flat_hash_set (~> 1.20240116.2) + - abseil/container/inlined_vector (~> 1.20240116.2) + - abseil/flags/flag (~> 1.20240116.2) + - abseil/flags/marshalling (~> 1.20240116.2) + - abseil/functional/any_invocable (~> 1.20240116.2) + - abseil/functional/bind_front (~> 1.20240116.2) + - abseil/functional/function_ref (~> 1.20240116.2) + - abseil/hash/hash (~> 1.20240116.2) + - abseil/log/absl_check (~> 1.20240116.2) + - abseil/log/absl_log (~> 1.20240116.2) + - abseil/log/check (~> 1.20240116.2) + - abseil/log/globals (~> 1.20240116.2) + - abseil/log/log (~> 1.20240116.2) + - abseil/memory/memory (~> 1.20240116.2) + - abseil/meta/type_traits (~> 1.20240116.2) + - abseil/random/bit_gen_ref (~> 1.20240116.2) + - abseil/random/distributions (~> 1.20240116.2) + - abseil/random/random (~> 1.20240116.2) + - abseil/status/status (~> 1.20240116.2) + - abseil/status/statusor (~> 1.20240116.2) + - abseil/strings/cord (~> 1.20240116.2) + - abseil/strings/str_format (~> 1.20240116.2) + - abseil/strings/strings (~> 1.20240116.2) + - abseil/synchronization/synchronization (~> 1.20240116.2) + - abseil/time/time (~> 1.20240116.2) + - abseil/types/optional (~> 1.20240116.2) + - abseil/types/span (~> 1.20240116.2) + - abseil/types/variant (~> 1.20240116.2) + - abseil/utility/utility (~> 1.20240116.2) + - "gRPC-C++/Interface (= 1.65.5)" + - "gRPC-C++/Privacy (= 1.65.5)" + - gRPC-Core (= 1.65.5) + - "gRPC-C++/Interface (1.65.5)" + - "gRPC-C++/Privacy (1.65.5)" + - gRPC-Core (1.65.5): + - gRPC-Core/Implementation (= 1.65.5) + - gRPC-Core/Interface (= 1.65.5) + - gRPC-Core/Implementation (1.65.5): + - abseil/algorithm/container (~> 1.20240116.2) + - abseil/base/base (~> 1.20240116.2) + - abseil/base/config (~> 1.20240116.2) + - abseil/base/core_headers (~> 1.20240116.2) + - abseil/base/log_severity (~> 1.20240116.2) + - abseil/base/no_destructor (~> 1.20240116.2) + - abseil/cleanup/cleanup (~> 1.20240116.2) + - abseil/container/flat_hash_map (~> 1.20240116.2) + - abseil/container/flat_hash_set (~> 1.20240116.2) + - abseil/container/inlined_vector (~> 1.20240116.2) + - abseil/flags/flag (~> 1.20240116.2) + - abseil/flags/marshalling (~> 1.20240116.2) + - abseil/functional/any_invocable (~> 1.20240116.2) + - abseil/functional/bind_front (~> 1.20240116.2) + - abseil/functional/function_ref (~> 1.20240116.2) + - abseil/hash/hash (~> 1.20240116.2) + - abseil/log/check (~> 1.20240116.2) + - abseil/log/globals (~> 1.20240116.2) + - abseil/log/log (~> 1.20240116.2) + - abseil/memory/memory (~> 1.20240116.2) + - abseil/meta/type_traits (~> 1.20240116.2) + - abseil/random/bit_gen_ref (~> 1.20240116.2) + - abseil/random/distributions (~> 1.20240116.2) + - abseil/random/random (~> 1.20240116.2) + - abseil/status/status (~> 1.20240116.2) + - abseil/status/statusor (~> 1.20240116.2) + - abseil/strings/cord (~> 1.20240116.2) + - abseil/strings/str_format (~> 1.20240116.2) + - abseil/strings/strings (~> 1.20240116.2) + - abseil/synchronization/synchronization (~> 1.20240116.2) + - abseil/time/time (~> 1.20240116.2) + - abseil/types/optional (~> 1.20240116.2) + - abseil/types/span (~> 1.20240116.2) + - abseil/types/variant (~> 1.20240116.2) + - abseil/utility/utility (~> 1.20240116.2) + - BoringSSL-GRPC (= 0.0.36) + - gRPC-Core/Interface (= 1.65.5) + - gRPC-Core/Privacy (= 1.65.5) + - gRPC-Core/Interface (1.65.5) + - gRPC-Core/Privacy (1.65.5) + - GTMSessionFetcher/Core (3.5.0) + - hermes-engine (0.79.5): + - hermes-engine/Pre-built (= 0.79.5) + - hermes-engine/Pre-built (0.79.5) + - leveldb-library (1.22.6) + - libavif/core (1.0.0) + - libavif/libdav1d (1.0.0): + - libavif/core + - libdav1d (>= 0.6.0) + - libdav1d (1.2.0) + - libwebp (1.5.0): + - libwebp/demux (= 1.5.0) + - libwebp/mux (= 1.5.0) + - libwebp/sharpyuv (= 1.5.0) + - libwebp/webp (= 1.5.0) + - libwebp/demux (1.5.0): + - libwebp/webp + - libwebp/mux (1.5.0): + - libwebp/demux + - libwebp/sharpyuv (1.5.0) + - libwebp/webp (1.5.0): + - libwebp/sharpyuv + - MMKV (2.3.0): + - MMKVCore (~> 2.3.0) + - MMKVCore (2.3.0) + - nanopb (3.30910.0): + - nanopb/decode (= 3.30910.0) + - nanopb/encode (= 3.30910.0) + - nanopb/decode (3.30910.0) + - nanopb/encode (3.30910.0) + - NitroHashcashNative (0.0.1): + - DoubleConversion + - glog + - hermes-engine + - NitroModules + - RCT-Folly (= 2024.11.18.00) + - RCTRequired + - RCTTypeSafety + - React-callinvoker + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - Yoga + - NitroModules (0.31.10): + - DoubleConversion + - glog + - hermes-engine + - RCT-Folly (= 2024.11.18.00) + - RCTRequired + - RCTTypeSafety + - React-callinvoker + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - Yoga + - OneSignalXCFramework (5.2.10): + - OneSignalXCFramework/OneSignalComplete (= 5.2.10) + - OneSignalXCFramework/OneSignal (5.2.10): + - OneSignalXCFramework/OneSignalCore + - OneSignalXCFramework/OneSignalExtension + - OneSignalXCFramework/OneSignalLiveActivities + - OneSignalXCFramework/OneSignalNotifications + - OneSignalXCFramework/OneSignalOSCore + - OneSignalXCFramework/OneSignalOutcomes + - OneSignalXCFramework/OneSignalUser + - OneSignalXCFramework/OneSignalComplete (5.2.10): + - OneSignalXCFramework/OneSignal + - OneSignalXCFramework/OneSignalInAppMessages + - OneSignalXCFramework/OneSignalLocation + - OneSignalXCFramework/OneSignalCore (5.2.10) + - OneSignalXCFramework/OneSignalExtension (5.2.10): + - OneSignalXCFramework/OneSignalCore + - OneSignalXCFramework/OneSignalOutcomes + - OneSignalXCFramework/OneSignalInAppMessages (5.2.10): + - OneSignalXCFramework/OneSignalCore + - OneSignalXCFramework/OneSignalNotifications + - OneSignalXCFramework/OneSignalOSCore + - OneSignalXCFramework/OneSignalOutcomes + - OneSignalXCFramework/OneSignalUser + - OneSignalXCFramework/OneSignalLiveActivities (5.2.10): + - OneSignalXCFramework/OneSignalCore + - OneSignalXCFramework/OneSignalOSCore + - OneSignalXCFramework/OneSignalUser + - OneSignalXCFramework/OneSignalLocation (5.2.10): + - OneSignalXCFramework/OneSignalCore + - OneSignalXCFramework/OneSignalNotifications + - OneSignalXCFramework/OneSignalOSCore + - OneSignalXCFramework/OneSignalUser + - OneSignalXCFramework/OneSignalNotifications (5.2.10): + - OneSignalXCFramework/OneSignalCore + - OneSignalXCFramework/OneSignalExtension + - OneSignalXCFramework/OneSignalOutcomes + - OneSignalXCFramework/OneSignalOSCore (5.2.10): + - OneSignalXCFramework/OneSignalCore + - OneSignalXCFramework/OneSignalOutcomes (5.2.10): + - OneSignalXCFramework/OneSignalCore + - OneSignalXCFramework/OneSignalUser (5.2.10): + - OneSignalXCFramework/OneSignalCore + - OneSignalXCFramework/OneSignalNotifications + - OneSignalXCFramework/OneSignalOSCore + - OneSignalXCFramework/OneSignalOutcomes + - OpenTelemetrySwiftApi (1.13.1) + - PLCrashReporter (1.12.0) + - RCT-Folly (2024.11.18.00): + - boost + - DoubleConversion + - fast_float (= 6.1.4) + - fmt (= 11.0.2) + - glog + - RCT-Folly/Default (= 2024.11.18.00) + - RCT-Folly/Default (2024.11.18.00): + - boost + - DoubleConversion + - fast_float (= 6.1.4) + - fmt (= 11.0.2) + - glog + - RCT-Folly/Fabric (2024.11.18.00): + - boost + - DoubleConversion + - fast_float (= 6.1.4) + - fmt (= 11.0.2) + - glog + - RCTDeprecation (0.79.5) + - RCTRequired (0.79.5) + - RCTTypeSafety (0.79.5): + - FBLazyVector (= 0.79.5) + - RCTRequired (= 0.79.5) + - React-Core (= 0.79.5) + - React (0.79.5): + - React-Core (= 0.79.5) + - React-Core/DevSupport (= 0.79.5) + - React-Core/RCTWebSocket (= 0.79.5) + - React-RCTActionSheet (= 0.79.5) + - React-RCTAnimation (= 0.79.5) + - React-RCTBlob (= 0.79.5) + - React-RCTImage (= 0.79.5) + - React-RCTLinking (= 0.79.5) + - React-RCTNetwork (= 0.79.5) + - React-RCTSettings (= 0.79.5) + - React-RCTText (= 0.79.5) + - React-RCTVibration (= 0.79.5) + - React-callinvoker (0.79.5) + - React-Core (0.79.5): + - glog + - hermes-engine + - RCT-Folly (= 2024.11.18.00) + - RCTDeprecation + - React-Core/Default (= 0.79.5) + - React-cxxreact + - React-featureflags + - React-hermes + - React-jsi + - React-jsiexecutor + - React-jsinspector + - React-jsitooling + - React-perflogger + - React-runtimescheduler + - React-utils + - SocketRocket (= 0.7.1) + - Yoga + - React-Core/CoreModulesHeaders (0.79.5): + - glog + - hermes-engine + - RCT-Folly (= 2024.11.18.00) + - RCTDeprecation + - React-Core/Default + - React-cxxreact + - React-featureflags + - React-hermes + - React-jsi + - React-jsiexecutor + - React-jsinspector + - React-jsitooling + - React-perflogger + - React-runtimescheduler + - React-utils + - SocketRocket (= 0.7.1) + - Yoga + - React-Core/Default (0.79.5): + - glog + - hermes-engine + - RCT-Folly (= 2024.11.18.00) + - RCTDeprecation + - React-cxxreact + - React-featureflags + - React-hermes + - React-jsi + - React-jsiexecutor + - React-jsinspector + - React-jsitooling + - React-perflogger + - React-runtimescheduler + - React-utils + - SocketRocket (= 0.7.1) + - Yoga + - React-Core/DevSupport (0.79.5): + - glog + - hermes-engine + - RCT-Folly (= 2024.11.18.00) + - RCTDeprecation + - React-Core/Default (= 0.79.5) + - React-Core/RCTWebSocket (= 0.79.5) + - React-cxxreact + - React-featureflags + - React-hermes + - React-jsi + - React-jsiexecutor + - React-jsinspector + - React-jsitooling + - React-perflogger + - React-runtimescheduler + - React-utils + - SocketRocket (= 0.7.1) + - Yoga + - React-Core/RCTActionSheetHeaders (0.79.5): + - glog + - hermes-engine + - RCT-Folly (= 2024.11.18.00) + - RCTDeprecation + - React-Core/Default + - React-cxxreact + - React-featureflags + - React-hermes + - React-jsi + - React-jsiexecutor + - React-jsinspector + - React-jsitooling + - React-perflogger + - React-runtimescheduler + - React-utils + - SocketRocket (= 0.7.1) + - Yoga + - React-Core/RCTAnimationHeaders (0.79.5): + - glog + - hermes-engine + - RCT-Folly (= 2024.11.18.00) + - RCTDeprecation + - React-Core/Default + - React-cxxreact + - React-featureflags + - React-hermes + - React-jsi + - React-jsiexecutor + - React-jsinspector + - React-jsitooling + - React-perflogger + - React-runtimescheduler + - React-utils + - SocketRocket (= 0.7.1) + - Yoga + - React-Core/RCTBlobHeaders (0.79.5): + - glog + - hermes-engine + - RCT-Folly (= 2024.11.18.00) + - RCTDeprecation + - React-Core/Default + - React-cxxreact + - React-featureflags + - React-hermes + - React-jsi + - React-jsiexecutor + - React-jsinspector + - React-jsitooling + - React-perflogger + - React-runtimescheduler + - React-utils + - SocketRocket (= 0.7.1) + - Yoga + - React-Core/RCTImageHeaders (0.79.5): + - glog + - hermes-engine + - RCT-Folly (= 2024.11.18.00) + - RCTDeprecation + - React-Core/Default + - React-cxxreact + - React-featureflags + - React-hermes + - React-jsi + - React-jsiexecutor + - React-jsinspector + - React-jsitooling + - React-perflogger + - React-runtimescheduler + - React-utils + - SocketRocket (= 0.7.1) + - Yoga + - React-Core/RCTLinkingHeaders (0.79.5): + - glog + - hermes-engine + - RCT-Folly (= 2024.11.18.00) + - RCTDeprecation + - React-Core/Default + - React-cxxreact + - React-featureflags + - React-hermes + - React-jsi + - React-jsiexecutor + - React-jsinspector + - React-jsitooling + - React-perflogger + - React-runtimescheduler + - React-utils + - SocketRocket (= 0.7.1) + - Yoga + - React-Core/RCTNetworkHeaders (0.79.5): + - glog + - hermes-engine + - RCT-Folly (= 2024.11.18.00) + - RCTDeprecation + - React-Core/Default + - React-cxxreact + - React-featureflags + - React-hermes + - React-jsi + - React-jsiexecutor + - React-jsinspector + - React-jsitooling + - React-perflogger + - React-runtimescheduler + - React-utils + - SocketRocket (= 0.7.1) + - Yoga + - React-Core/RCTSettingsHeaders (0.79.5): + - glog + - hermes-engine + - RCT-Folly (= 2024.11.18.00) + - RCTDeprecation + - React-Core/Default + - React-cxxreact + - React-featureflags + - React-hermes + - React-jsi + - React-jsiexecutor + - React-jsinspector + - React-jsitooling + - React-perflogger + - React-runtimescheduler + - React-utils + - SocketRocket (= 0.7.1) + - Yoga + - React-Core/RCTTextHeaders (0.79.5): + - glog + - hermes-engine + - RCT-Folly (= 2024.11.18.00) + - RCTDeprecation + - React-Core/Default + - React-cxxreact + - React-featureflags + - React-hermes + - React-jsi + - React-jsiexecutor + - React-jsinspector + - React-jsitooling + - React-perflogger + - React-runtimescheduler + - React-utils + - SocketRocket (= 0.7.1) + - Yoga + - React-Core/RCTVibrationHeaders (0.79.5): + - glog + - hermes-engine + - RCT-Folly (= 2024.11.18.00) + - RCTDeprecation + - React-Core/Default + - React-cxxreact + - React-featureflags + - React-hermes + - React-jsi + - React-jsiexecutor + - React-jsinspector + - React-jsitooling + - React-perflogger + - React-runtimescheduler + - React-utils + - SocketRocket (= 0.7.1) + - Yoga + - React-Core/RCTWebSocket (0.79.5): + - glog + - hermes-engine + - RCT-Folly (= 2024.11.18.00) + - RCTDeprecation + - React-Core/Default (= 0.79.5) + - React-cxxreact + - React-featureflags + - React-hermes + - React-jsi + - React-jsiexecutor + - React-jsinspector + - React-jsitooling + - React-perflogger + - React-runtimescheduler + - React-utils + - SocketRocket (= 0.7.1) + - Yoga + - React-CoreModules (0.79.5): + - DoubleConversion + - fast_float (= 6.1.4) + - fmt (= 11.0.2) + - RCT-Folly (= 2024.11.18.00) + - RCTTypeSafety (= 0.79.5) + - React-Core/CoreModulesHeaders (= 0.79.5) + - React-jsi (= 0.79.5) + - React-jsinspector + - React-jsinspectortracing + - React-NativeModulesApple + - React-RCTBlob + - React-RCTFBReactNativeSpec + - React-RCTImage (= 0.79.5) + - ReactCommon + - SocketRocket (= 0.7.1) + - React-cxxreact (0.79.5): + - boost + - DoubleConversion + - fast_float (= 6.1.4) + - fmt (= 11.0.2) + - glog + - hermes-engine + - RCT-Folly (= 2024.11.18.00) + - React-callinvoker (= 0.79.5) + - React-debug (= 0.79.5) + - React-jsi (= 0.79.5) + - React-jsinspector + - React-jsinspectortracing + - React-logger (= 0.79.5) + - React-perflogger (= 0.79.5) + - React-runtimeexecutor (= 0.79.5) + - React-timing (= 0.79.5) + - React-debug (0.79.5) + - React-defaultsnativemodule (0.79.5): + - hermes-engine + - RCT-Folly + - React-domnativemodule + - React-featureflagsnativemodule + - React-hermes + - React-idlecallbacksnativemodule + - React-jsi + - React-jsiexecutor + - React-microtasksnativemodule + - React-RCTFBReactNativeSpec + - React-domnativemodule (0.79.5): + - hermes-engine + - RCT-Folly + - React-Fabric + - React-FabricComponents + - React-graphics + - React-hermes + - React-jsi + - React-jsiexecutor + - React-RCTFBReactNativeSpec + - ReactCommon/turbomodule/core + - Yoga + - React-Fabric (0.79.5): + - DoubleConversion + - fast_float (= 6.1.4) + - fmt (= 11.0.2) + - glog + - hermes-engine + - RCT-Folly/Fabric (= 2024.11.18.00) + - RCTRequired + - RCTTypeSafety + - React-Core + - React-cxxreact + - React-debug + - React-Fabric/animations (= 0.79.5) + - React-Fabric/attributedstring (= 0.79.5) + - React-Fabric/componentregistry (= 0.79.5) + - React-Fabric/componentregistrynative (= 0.79.5) + - React-Fabric/components (= 0.79.5) + - React-Fabric/consistency (= 0.79.5) + - React-Fabric/core (= 0.79.5) + - React-Fabric/dom (= 0.79.5) + - React-Fabric/imagemanager (= 0.79.5) + - React-Fabric/leakchecker (= 0.79.5) + - React-Fabric/mounting (= 0.79.5) + - React-Fabric/observers (= 0.79.5) + - React-Fabric/scheduler (= 0.79.5) + - React-Fabric/telemetry (= 0.79.5) + - React-Fabric/templateprocessor (= 0.79.5) + - React-Fabric/uimanager (= 0.79.5) + - React-featureflags + - React-graphics + - React-hermes + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - React-Fabric/animations (0.79.5): + - DoubleConversion + - fast_float (= 6.1.4) + - fmt (= 11.0.2) + - glog + - hermes-engine + - RCT-Folly/Fabric (= 2024.11.18.00) + - RCTRequired + - RCTTypeSafety + - React-Core + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-hermes + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - React-Fabric/attributedstring (0.79.5): + - DoubleConversion + - fast_float (= 6.1.4) + - fmt (= 11.0.2) + - glog + - hermes-engine + - RCT-Folly/Fabric (= 2024.11.18.00) + - RCTRequired + - RCTTypeSafety + - React-Core + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-hermes + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - React-Fabric/componentregistry (0.79.5): + - DoubleConversion + - fast_float (= 6.1.4) + - fmt (= 11.0.2) + - glog + - hermes-engine + - RCT-Folly/Fabric (= 2024.11.18.00) + - RCTRequired + - RCTTypeSafety + - React-Core + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-hermes + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - React-Fabric/componentregistrynative (0.79.5): + - DoubleConversion + - fast_float (= 6.1.4) + - fmt (= 11.0.2) + - glog + - hermes-engine + - RCT-Folly/Fabric (= 2024.11.18.00) + - RCTRequired + - RCTTypeSafety + - React-Core + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-hermes + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - React-Fabric/components (0.79.5): + - DoubleConversion + - fast_float (= 6.1.4) + - fmt (= 11.0.2) + - glog + - hermes-engine + - RCT-Folly/Fabric (= 2024.11.18.00) + - RCTRequired + - RCTTypeSafety + - React-Core + - React-cxxreact + - React-debug + - React-Fabric/components/legacyviewmanagerinterop (= 0.79.5) + - React-Fabric/components/root (= 0.79.5) + - React-Fabric/components/scrollview (= 0.79.5) + - React-Fabric/components/view (= 0.79.5) + - React-featureflags + - React-graphics + - React-hermes + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - React-Fabric/components/legacyviewmanagerinterop (0.79.5): + - DoubleConversion + - fast_float (= 6.1.4) + - fmt (= 11.0.2) + - glog + - hermes-engine + - RCT-Folly/Fabric (= 2024.11.18.00) + - RCTRequired + - RCTTypeSafety + - React-Core + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-hermes + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - React-Fabric/components/root (0.79.5): + - DoubleConversion + - fast_float (= 6.1.4) + - fmt (= 11.0.2) + - glog + - hermes-engine + - RCT-Folly/Fabric (= 2024.11.18.00) + - RCTRequired + - RCTTypeSafety + - React-Core + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-hermes + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - React-Fabric/components/scrollview (0.79.5): + - DoubleConversion + - fast_float (= 6.1.4) + - fmt (= 11.0.2) + - glog + - hermes-engine + - RCT-Folly/Fabric (= 2024.11.18.00) + - RCTRequired + - RCTTypeSafety + - React-Core + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-hermes + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - React-Fabric/components/view (0.79.5): + - DoubleConversion + - fast_float (= 6.1.4) + - fmt (= 11.0.2) + - glog + - hermes-engine + - RCT-Folly/Fabric (= 2024.11.18.00) + - RCTRequired + - RCTTypeSafety + - React-Core + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-hermes + - React-jsi + - React-jsiexecutor + - React-logger + - React-renderercss + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - Yoga + - React-Fabric/consistency (0.79.5): + - DoubleConversion + - fast_float (= 6.1.4) + - fmt (= 11.0.2) + - glog + - hermes-engine + - RCT-Folly/Fabric (= 2024.11.18.00) + - RCTRequired + - RCTTypeSafety + - React-Core + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-hermes + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - React-Fabric/core (0.79.5): + - DoubleConversion + - fast_float (= 6.1.4) + - fmt (= 11.0.2) + - glog + - hermes-engine + - RCT-Folly/Fabric (= 2024.11.18.00) + - RCTRequired + - RCTTypeSafety + - React-Core + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-hermes + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - React-Fabric/dom (0.79.5): + - DoubleConversion + - fast_float (= 6.1.4) + - fmt (= 11.0.2) + - glog + - hermes-engine + - RCT-Folly/Fabric (= 2024.11.18.00) + - RCTRequired + - RCTTypeSafety + - React-Core + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-hermes + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - React-Fabric/imagemanager (0.79.5): + - DoubleConversion + - fast_float (= 6.1.4) + - fmt (= 11.0.2) + - glog + - hermes-engine + - RCT-Folly/Fabric (= 2024.11.18.00) + - RCTRequired + - RCTTypeSafety + - React-Core + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-hermes + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - React-Fabric/leakchecker (0.79.5): + - DoubleConversion + - fast_float (= 6.1.4) + - fmt (= 11.0.2) + - glog + - hermes-engine + - RCT-Folly/Fabric (= 2024.11.18.00) + - RCTRequired + - RCTTypeSafety + - React-Core + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-hermes + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - React-Fabric/mounting (0.79.5): + - DoubleConversion + - fast_float (= 6.1.4) + - fmt (= 11.0.2) + - glog + - hermes-engine + - RCT-Folly/Fabric (= 2024.11.18.00) + - RCTRequired + - RCTTypeSafety + - React-Core + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-hermes + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - React-Fabric/observers (0.79.5): + - DoubleConversion + - fast_float (= 6.1.4) + - fmt (= 11.0.2) + - glog + - hermes-engine + - RCT-Folly/Fabric (= 2024.11.18.00) + - RCTRequired + - RCTTypeSafety + - React-Core + - React-cxxreact + - React-debug + - React-Fabric/observers/events (= 0.79.5) + - React-featureflags + - React-graphics + - React-hermes + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - React-Fabric/observers/events (0.79.5): + - DoubleConversion + - fast_float (= 6.1.4) + - fmt (= 11.0.2) + - glog + - hermes-engine + - RCT-Folly/Fabric (= 2024.11.18.00) + - RCTRequired + - RCTTypeSafety + - React-Core + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-hermes + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - React-Fabric/scheduler (0.79.5): + - DoubleConversion + - fast_float (= 6.1.4) + - fmt (= 11.0.2) + - glog + - hermes-engine + - RCT-Folly/Fabric (= 2024.11.18.00) + - RCTRequired + - RCTTypeSafety + - React-Core + - React-cxxreact + - React-debug + - React-Fabric/observers/events + - React-featureflags + - React-graphics + - React-hermes + - React-jsi + - React-jsiexecutor + - React-logger + - React-performancetimeline + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - React-Fabric/telemetry (0.79.5): + - DoubleConversion + - fast_float (= 6.1.4) + - fmt (= 11.0.2) + - glog + - hermes-engine + - RCT-Folly/Fabric (= 2024.11.18.00) + - RCTRequired + - RCTTypeSafety + - React-Core + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-hermes + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - React-Fabric/templateprocessor (0.79.5): + - DoubleConversion + - fast_float (= 6.1.4) + - fmt (= 11.0.2) + - glog + - hermes-engine + - RCT-Folly/Fabric (= 2024.11.18.00) + - RCTRequired + - RCTTypeSafety + - React-Core + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-hermes + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - React-Fabric/uimanager (0.79.5): + - DoubleConversion + - fast_float (= 6.1.4) + - fmt (= 11.0.2) + - glog + - hermes-engine + - RCT-Folly/Fabric (= 2024.11.18.00) + - RCTRequired + - RCTTypeSafety + - React-Core + - React-cxxreact + - React-debug + - React-Fabric/uimanager/consistency (= 0.79.5) + - React-featureflags + - React-graphics + - React-hermes + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererconsistency + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - React-Fabric/uimanager/consistency (0.79.5): + - DoubleConversion + - fast_float (= 6.1.4) + - fmt (= 11.0.2) + - glog + - hermes-engine + - RCT-Folly/Fabric (= 2024.11.18.00) + - RCTRequired + - RCTTypeSafety + - React-Core + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-hermes + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererconsistency + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - React-FabricComponents (0.79.5): + - DoubleConversion + - fast_float (= 6.1.4) + - fmt (= 11.0.2) + - glog + - hermes-engine + - RCT-Folly/Fabric (= 2024.11.18.00) + - RCTRequired + - RCTTypeSafety + - React-Core + - React-cxxreact + - React-debug + - React-Fabric + - React-FabricComponents/components (= 0.79.5) + - React-FabricComponents/textlayoutmanager (= 0.79.5) + - React-featureflags + - React-graphics + - React-hermes + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - Yoga + - React-FabricComponents/components (0.79.5): + - DoubleConversion + - fast_float (= 6.1.4) + - fmt (= 11.0.2) + - glog + - hermes-engine + - RCT-Folly/Fabric (= 2024.11.18.00) + - RCTRequired + - RCTTypeSafety + - React-Core + - React-cxxreact + - React-debug + - React-Fabric + - React-FabricComponents/components/inputaccessory (= 0.79.5) + - React-FabricComponents/components/iostextinput (= 0.79.5) + - React-FabricComponents/components/modal (= 0.79.5) + - React-FabricComponents/components/rncore (= 0.79.5) + - React-FabricComponents/components/safeareaview (= 0.79.5) + - React-FabricComponents/components/scrollview (= 0.79.5) + - React-FabricComponents/components/text (= 0.79.5) + - React-FabricComponents/components/textinput (= 0.79.5) + - React-FabricComponents/components/unimplementedview (= 0.79.5) + - React-featureflags + - React-graphics + - React-hermes + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - Yoga + - React-FabricComponents/components/inputaccessory (0.79.5): + - DoubleConversion + - fast_float (= 6.1.4) + - fmt (= 11.0.2) + - glog + - hermes-engine + - RCT-Folly/Fabric (= 2024.11.18.00) + - RCTRequired + - RCTTypeSafety + - React-Core + - React-cxxreact + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - Yoga + - React-FabricComponents/components/iostextinput (0.79.5): + - DoubleConversion + - fast_float (= 6.1.4) + - fmt (= 11.0.2) + - glog + - hermes-engine + - RCT-Folly/Fabric (= 2024.11.18.00) + - RCTRequired + - RCTTypeSafety + - React-Core + - React-cxxreact + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - Yoga + - React-FabricComponents/components/modal (0.79.5): + - DoubleConversion + - fast_float (= 6.1.4) + - fmt (= 11.0.2) + - glog + - hermes-engine + - RCT-Folly/Fabric (= 2024.11.18.00) + - RCTRequired + - RCTTypeSafety + - React-Core + - React-cxxreact + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - Yoga + - React-FabricComponents/components/rncore (0.79.5): + - DoubleConversion + - fast_float (= 6.1.4) + - fmt (= 11.0.2) + - glog + - hermes-engine + - RCT-Folly/Fabric (= 2024.11.18.00) + - RCTRequired + - RCTTypeSafety + - React-Core + - React-cxxreact + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - Yoga + - React-FabricComponents/components/safeareaview (0.79.5): + - DoubleConversion + - fast_float (= 6.1.4) + - fmt (= 11.0.2) + - glog + - hermes-engine + - RCT-Folly/Fabric (= 2024.11.18.00) + - RCTRequired + - RCTTypeSafety + - React-Core + - React-cxxreact + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - Yoga + - React-FabricComponents/components/scrollview (0.79.5): + - DoubleConversion + - fast_float (= 6.1.4) + - fmt (= 11.0.2) + - glog + - hermes-engine + - RCT-Folly/Fabric (= 2024.11.18.00) + - RCTRequired + - RCTTypeSafety + - React-Core + - React-cxxreact + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - Yoga + - React-FabricComponents/components/text (0.79.5): + - DoubleConversion + - fast_float (= 6.1.4) + - fmt (= 11.0.2) + - glog + - hermes-engine + - RCT-Folly/Fabric (= 2024.11.18.00) + - RCTRequired + - RCTTypeSafety + - React-Core + - React-cxxreact + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - Yoga + - React-FabricComponents/components/textinput (0.79.5): + - DoubleConversion + - fast_float (= 6.1.4) + - fmt (= 11.0.2) + - glog + - hermes-engine + - RCT-Folly/Fabric (= 2024.11.18.00) + - RCTRequired + - RCTTypeSafety + - React-Core + - React-cxxreact + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - Yoga + - React-FabricComponents/components/unimplementedview (0.79.5): + - DoubleConversion + - fast_float (= 6.1.4) + - fmt (= 11.0.2) + - glog + - hermes-engine + - RCT-Folly/Fabric (= 2024.11.18.00) + - RCTRequired + - RCTTypeSafety + - React-Core + - React-cxxreact + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - Yoga + - React-FabricComponents/textlayoutmanager (0.79.5): + - DoubleConversion + - fast_float (= 6.1.4) + - fmt (= 11.0.2) + - glog + - hermes-engine + - RCT-Folly/Fabric (= 2024.11.18.00) + - RCTRequired + - RCTTypeSafety + - React-Core + - React-cxxreact + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - Yoga + - React-FabricImage (0.79.5): + - DoubleConversion + - fast_float (= 6.1.4) + - fmt (= 11.0.2) + - glog + - hermes-engine + - RCT-Folly/Fabric (= 2024.11.18.00) + - RCTRequired (= 0.79.5) + - RCTTypeSafety (= 0.79.5) + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-ImageManager + - React-jsi + - React-jsiexecutor (= 0.79.5) + - React-logger + - React-rendererdebug + - React-utils + - ReactCommon + - Yoga + - React-featureflags (0.79.5): + - RCT-Folly (= 2024.11.18.00) + - React-featureflagsnativemodule (0.79.5): + - hermes-engine + - RCT-Folly + - React-featureflags + - React-hermes + - React-jsi + - React-jsiexecutor + - React-RCTFBReactNativeSpec + - ReactCommon/turbomodule/core + - React-graphics (0.79.5): + - DoubleConversion + - fast_float (= 6.1.4) + - fmt (= 11.0.2) + - glog + - hermes-engine + - RCT-Folly/Fabric (= 2024.11.18.00) + - React-hermes + - React-jsi + - React-jsiexecutor + - React-utils + - React-hermes (0.79.5): + - DoubleConversion + - fast_float (= 6.1.4) + - fmt (= 11.0.2) + - glog + - hermes-engine + - RCT-Folly (= 2024.11.18.00) + - React-cxxreact (= 0.79.5) + - React-jsi + - React-jsiexecutor (= 0.79.5) + - React-jsinspector + - React-jsinspectortracing + - React-perflogger (= 0.79.5) + - React-runtimeexecutor + - React-idlecallbacksnativemodule (0.79.5): + - glog + - hermes-engine + - RCT-Folly + - React-hermes + - React-jsi + - React-jsiexecutor + - React-RCTFBReactNativeSpec + - React-runtimescheduler + - ReactCommon/turbomodule/core + - React-ImageManager (0.79.5): + - glog + - RCT-Folly/Fabric + - React-Core/Default + - React-debug + - React-Fabric + - React-graphics + - React-rendererdebug + - React-utils + - React-jserrorhandler (0.79.5): + - glog + - hermes-engine + - RCT-Folly/Fabric (= 2024.11.18.00) + - React-cxxreact + - React-debug + - React-featureflags + - React-jsi + - ReactCommon/turbomodule/bridging + - React-jsi (0.79.5): + - boost + - DoubleConversion + - fast_float (= 6.1.4) + - fmt (= 11.0.2) + - glog + - hermes-engine + - RCT-Folly (= 2024.11.18.00) + - React-jsiexecutor (0.79.5): + - DoubleConversion + - fast_float (= 6.1.4) + - fmt (= 11.0.2) + - glog + - hermes-engine + - RCT-Folly (= 2024.11.18.00) + - React-cxxreact (= 0.79.5) + - React-jsi (= 0.79.5) + - React-jsinspector + - React-jsinspectortracing + - React-perflogger (= 0.79.5) + - React-jsinspector (0.79.5): + - DoubleConversion + - glog + - hermes-engine + - RCT-Folly + - React-featureflags + - React-jsi + - React-jsinspectortracing + - React-perflogger (= 0.79.5) + - React-runtimeexecutor (= 0.79.5) + - React-jsinspectortracing (0.79.5): + - RCT-Folly + - React-oscompat + - React-jsitooling (0.79.5): + - DoubleConversion + - fast_float (= 6.1.4) + - fmt (= 11.0.2) + - glog + - RCT-Folly (= 2024.11.18.00) + - React-cxxreact (= 0.79.5) + - React-jsi (= 0.79.5) + - React-jsinspector + - React-jsinspectortracing + - React-jsitracing (0.79.5): + - React-jsi + - React-logger (0.79.5): + - glog + - React-Mapbuffer (0.79.5): + - glog + - React-debug + - React-microtasksnativemodule (0.79.5): + - hermes-engine + - RCT-Folly + - React-hermes + - React-jsi + - React-jsiexecutor + - React-RCTFBReactNativeSpec + - ReactCommon/turbomodule/core + - react-native-appsflyer (6.13.1): + - AppsFlyerFramework (= 6.13.1) + - React + - react-native-compat (2.23.0): + - DoubleConversion + - glog + - hermes-engine + - RCT-Folly (= 2024.11.18.00) + - RCTRequired + - RCTTypeSafety + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - Yoga + - react-native-context-menu-view (1.15.0): + - React + - react-native-get-random-values (1.11.0): + - React-Core + - react-native-image-picker (7.1.0): + - DoubleConversion + - glog + - hermes-engine + - RCT-Folly (= 2024.11.18.00) + - RCTRequired + - RCTTypeSafety + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - Yoga + - react-native-keyboard-controller (1.17.5): + - DoubleConversion + - glog + - hermes-engine + - RCT-Folly (= 2024.11.18.00) + - RCTRequired + - RCTTypeSafety + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - Yoga +<<<<<<< HEAD + - react-native-mmkv (2.10.1): + - MMKV (>= 1.2.13) +======= + - react-native-mmkv (2.12.0): + - MMKV (>= 1.3.3) +>>>>>>> upstream/main + - React-Core + - react-native-netinfo (11.4.1): + - React-Core + - react-native-onesignal (5.2.9): + - OneSignalXCFramework (= 5.2.10) + - React (< 1.0.0, >= 0.13.0) + - react-native-pager-view (6.7.1): + - DoubleConversion + - glog + - hermes-engine + - RCT-Folly (= 2024.11.18.00) + - RCTRequired + - RCTTypeSafety + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - Yoga + - react-native-passkey (3.1.0): + - React-Core + - react-native-restart (0.0.27): + - React-Core + - react-native-safe-area-context (5.4.0): + - React-Core + - react-native-skia (2.2.20): + - DoubleConversion + - glog + - hermes-engine + - RCT-Folly (= 2024.11.18.00) + - RCTRequired + - RCTTypeSafety + - React + - React-callinvoker + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - Yoga + - react-native-slider (4.5.6): + - DoubleConversion + - glog + - hermes-engine + - RCT-Folly (= 2024.11.18.00) + - RCTRequired + - RCTTypeSafety + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - Yoga + - react-native-video (6.13.0): + - DoubleConversion + - glog + - hermes-engine + - RCT-Folly (= 2024.11.18.00) + - RCTRequired + - RCTTypeSafety + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-ImageManager + - React-jsi + - react-native-video/Video (= 6.13.0) + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - Yoga + - react-native-video/Video (6.13.0): + - DoubleConversion + - glog + - hermes-engine + - RCT-Folly (= 2024.11.18.00) + - RCTRequired + - RCTTypeSafety + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - Yoga + - react-native-webview (13.13.5): + - DoubleConversion + - glog + - hermes-engine + - RCT-Folly (= 2024.11.18.00) + - RCTRequired + - RCTTypeSafety + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - Yoga + - react-native-widgetkit (1.0.9): + - React + - React-NativeModulesApple (0.79.5): + - glog + - hermes-engine + - React-callinvoker + - React-Core + - React-cxxreact + - React-featureflags + - React-hermes + - React-jsi + - React-jsinspector + - React-runtimeexecutor + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - React-oscompat (0.79.5) + - React-perflogger (0.79.5): + - DoubleConversion + - RCT-Folly (= 2024.11.18.00) + - React-performancetimeline (0.79.5): + - RCT-Folly (= 2024.11.18.00) + - React-cxxreact + - React-featureflags + - React-jsinspectortracing + - React-perflogger + - React-timing + - React-RCTActionSheet (0.79.5): + - React-Core/RCTActionSheetHeaders (= 0.79.5) + - React-RCTAnimation (0.79.5): + - RCT-Folly (= 2024.11.18.00) + - RCTTypeSafety + - React-Core/RCTAnimationHeaders + - React-jsi + - React-NativeModulesApple + - React-RCTFBReactNativeSpec + - ReactCommon + - React-RCTAppDelegate (0.79.5): + - hermes-engine + - RCT-Folly (= 2024.11.18.00) + - RCTRequired + - RCTTypeSafety + - React-Core + - React-CoreModules + - React-debug + - React-defaultsnativemodule + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-jsitooling + - React-NativeModulesApple + - React-RCTFabric + - React-RCTFBReactNativeSpec + - React-RCTImage + - React-RCTNetwork + - React-RCTRuntime + - React-rendererdebug + - React-RuntimeApple + - React-RuntimeCore + - React-runtimescheduler + - React-utils + - ReactCommon + - React-RCTBlob (0.79.5): + - DoubleConversion + - fast_float (= 6.1.4) + - fmt (= 11.0.2) + - hermes-engine + - RCT-Folly (= 2024.11.18.00) + - React-Core/RCTBlobHeaders + - React-Core/RCTWebSocket + - React-jsi + - React-jsinspector + - React-NativeModulesApple + - React-RCTFBReactNativeSpec + - React-RCTNetwork + - ReactCommon + - React-RCTFabric (0.79.5): + - glog + - hermes-engine + - RCT-Folly/Fabric (= 2024.11.18.00) + - React-Core + - React-debug + - React-Fabric + - React-FabricComponents + - React-FabricImage + - React-featureflags + - React-graphics + - React-hermes + - React-ImageManager + - React-jsi + - React-jsinspector + - React-jsinspectortracing + - React-performancetimeline + - React-RCTAnimation + - React-RCTImage + - React-RCTText + - React-rendererconsistency + - React-renderercss + - React-rendererdebug + - React-runtimescheduler + - React-utils + - Yoga + - React-RCTFBReactNativeSpec (0.79.5): + - hermes-engine + - RCT-Folly + - RCTRequired + - RCTTypeSafety + - React-Core + - React-hermes + - React-jsi + - React-jsiexecutor + - React-NativeModulesApple + - ReactCommon + - React-RCTImage (0.79.5): + - RCT-Folly (= 2024.11.18.00) + - RCTTypeSafety + - React-Core/RCTImageHeaders + - React-jsi + - React-NativeModulesApple + - React-RCTFBReactNativeSpec + - React-RCTNetwork + - ReactCommon + - React-RCTLinking (0.79.5): + - React-Core/RCTLinkingHeaders (= 0.79.5) + - React-jsi (= 0.79.5) + - React-NativeModulesApple + - React-RCTFBReactNativeSpec + - ReactCommon + - ReactCommon/turbomodule/core (= 0.79.5) + - React-RCTNetwork (0.79.5): + - RCT-Folly (= 2024.11.18.00) + - RCTTypeSafety + - React-Core/RCTNetworkHeaders + - React-jsi + - React-NativeModulesApple + - React-RCTFBReactNativeSpec + - ReactCommon + - React-RCTRuntime (0.79.5): + - glog + - hermes-engine + - RCT-Folly/Fabric (= 2024.11.18.00) + - React-Core + - React-hermes + - React-jsi + - React-jsinspector + - React-jsinspectortracing + - React-jsitooling + - React-RuntimeApple + - React-RuntimeCore + - React-RuntimeHermes + - React-RCTSettings (0.79.5): + - RCT-Folly (= 2024.11.18.00) + - RCTTypeSafety + - React-Core/RCTSettingsHeaders + - React-jsi + - React-NativeModulesApple + - React-RCTFBReactNativeSpec + - ReactCommon + - React-RCTText (0.79.5): + - React-Core/RCTTextHeaders (= 0.79.5) + - Yoga + - React-RCTVibration (0.79.5): + - RCT-Folly (= 2024.11.18.00) + - React-Core/RCTVibrationHeaders + - React-jsi + - React-NativeModulesApple + - React-RCTFBReactNativeSpec + - ReactCommon + - React-rendererconsistency (0.79.5) + - React-renderercss (0.79.5): + - React-debug + - React-utils + - React-rendererdebug (0.79.5): + - DoubleConversion + - fast_float (= 6.1.4) + - fmt (= 11.0.2) + - RCT-Folly (= 2024.11.18.00) + - React-debug + - React-rncore (0.79.5) + - React-RuntimeApple (0.79.5): + - hermes-engine + - RCT-Folly/Fabric (= 2024.11.18.00) + - React-callinvoker + - React-Core/Default + - React-CoreModules + - React-cxxreact + - React-featureflags + - React-jserrorhandler + - React-jsi + - React-jsiexecutor + - React-jsinspector + - React-jsitooling + - React-Mapbuffer + - React-NativeModulesApple + - React-RCTFabric + - React-RCTFBReactNativeSpec + - React-RuntimeCore + - React-runtimeexecutor + - React-RuntimeHermes + - React-runtimescheduler + - React-utils + - React-RuntimeCore (0.79.5): + - glog + - hermes-engine + - RCT-Folly/Fabric (= 2024.11.18.00) + - React-cxxreact + - React-Fabric + - React-featureflags + - React-hermes + - React-jserrorhandler + - React-jsi + - React-jsiexecutor + - React-jsinspector + - React-jsitooling + - React-performancetimeline + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - React-runtimeexecutor (0.79.5): + - React-jsi (= 0.79.5) + - React-RuntimeHermes (0.79.5): + - hermes-engine + - RCT-Folly/Fabric (= 2024.11.18.00) + - React-featureflags + - React-hermes + - React-jsi + - React-jsinspector + - React-jsinspectortracing + - React-jsitooling + - React-jsitracing + - React-RuntimeCore + - React-utils + - React-runtimescheduler (0.79.5): + - glog + - hermes-engine + - RCT-Folly (= 2024.11.18.00) + - React-callinvoker + - React-cxxreact + - React-debug + - React-featureflags + - React-hermes + - React-jsi + - React-jsinspectortracing + - React-performancetimeline + - React-rendererconsistency + - React-rendererdebug + - React-runtimeexecutor + - React-timing + - React-utils + - React-timing (0.79.5) + - React-utils (0.79.5): + - glog + - hermes-engine + - RCT-Folly (= 2024.11.18.00) + - React-debug + - React-hermes + - React-jsi (= 0.79.5) + - ReactAppDependencyProvider (0.79.5): + - ReactCodegen + - ReactCodegen (0.79.5): + - DoubleConversion + - glog + - hermes-engine + - RCT-Folly + - RCTRequired + - RCTTypeSafety + - React-Core + - React-debug + - React-Fabric + - React-FabricImage + - React-featureflags + - React-graphics + - React-hermes + - React-jsi + - React-jsiexecutor + - React-NativeModulesApple + - React-RCTAppDelegate + - React-rendererdebug + - React-utils + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - ReactCommon (0.79.5): + - ReactCommon/turbomodule (= 0.79.5) + - ReactCommon/turbomodule (0.79.5): + - DoubleConversion + - fast_float (= 6.1.4) + - fmt (= 11.0.2) + - glog + - hermes-engine + - RCT-Folly (= 2024.11.18.00) + - React-callinvoker (= 0.79.5) + - React-cxxreact (= 0.79.5) + - React-jsi (= 0.79.5) + - React-logger (= 0.79.5) + - React-perflogger (= 0.79.5) + - ReactCommon/turbomodule/bridging (= 0.79.5) + - ReactCommon/turbomodule/core (= 0.79.5) + - ReactCommon/turbomodule/bridging (0.79.5): + - DoubleConversion + - fast_float (= 6.1.4) + - fmt (= 11.0.2) + - glog + - hermes-engine + - RCT-Folly (= 2024.11.18.00) + - React-callinvoker (= 0.79.5) + - React-cxxreact (= 0.79.5) + - React-jsi (= 0.79.5) + - React-logger (= 0.79.5) + - React-perflogger (= 0.79.5) + - ReactCommon/turbomodule/core (0.79.5): + - DoubleConversion + - fast_float (= 6.1.4) + - fmt (= 11.0.2) + - glog + - hermes-engine + - RCT-Folly (= 2024.11.18.00) + - React-callinvoker (= 0.79.5) + - React-cxxreact (= 0.79.5) + - React-debug (= 0.79.5) + - React-featureflags (= 0.79.5) + - React-jsi (= 0.79.5) + - React-logger (= 0.79.5) + - React-perflogger (= 0.79.5) + - React-utils (= 0.79.5) + - ReactNativePerformance (4.1.2): + - React-Core + - RecaptchaInterop (100.0.0) + - RNBootSplash (6.3.10): + - React-Core + - RNCAsyncStorage (2.1.2): + - React-Core + - RNCMaskedView (0.3.2): + - DoubleConversion + - glog + - hermes-engine + - RCT-Folly (= 2024.11.18.00) + - RCTRequired + - RCTTypeSafety + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - Yoga + - RNDateTimePicker (8.4.1): + - React-Core + - RNDeviceInfo (10.11.0): + - React-Core + - RNFBApp (21.0.0): + - Firebase/CoreOnly (= 11.2.0) + - React-Core + - RNFBAuth (21.0.0): + - Firebase/Auth (= 11.2.0) + - React-Core + - RNFBApp + - RNFBFirestore (21.0.0): + - Firebase/Firestore (= 11.2.0) + - React-Core + - RNFBApp + - RNFlashList (1.7.6): + - DoubleConversion + - glog + - hermes-engine + - RCT-Folly (= 2024.11.18.00) + - RCTRequired + - RCTTypeSafety + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - Yoga + - RNGestureHandler (2.24.0): + - DoubleConversion + - glog + - hermes-engine + - RCT-Folly (= 2024.11.18.00) + - RCTRequired + - RCTTypeSafety + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - Yoga + - RNImageColors (1.5.2): + - React-Core + - RNLocalize (2.2.6): + - React-Core + - RNPermissions (4.1.5): + - React-Core + - RNQrGenerator (1.4.3): + - React + - ZXingObjC + - RNReanimated (3.19.3): + - DoubleConversion + - glog + - hermes-engine + - RCT-Folly (= 2024.11.18.00) + - RCTRequired + - RCTTypeSafety + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - RNReanimated/reanimated (= 3.19.3) + - RNReanimated/worklets (= 3.19.3) + - Yoga + - RNReanimated/reanimated (3.19.3): + - DoubleConversion + - glog + - hermes-engine + - RCT-Folly (= 2024.11.18.00) + - RCTRequired + - RCTTypeSafety + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - RNReanimated/reanimated/apple (= 3.19.3) + - Yoga + - RNReanimated/reanimated/apple (3.19.3): + - DoubleConversion + - glog + - hermes-engine + - RCT-Folly (= 2024.11.18.00) + - RCTRequired + - RCTTypeSafety + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - Yoga + - RNReanimated/worklets (3.19.3): + - DoubleConversion + - glog + - hermes-engine + - RCT-Folly (= 2024.11.18.00) + - RCTRequired + - RCTTypeSafety + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - RNReanimated/worklets/apple (= 3.19.3) + - Yoga + - RNReanimated/worklets/apple (3.19.3): + - DoubleConversion + - glog + - hermes-engine + - RCT-Folly (= 2024.11.18.00) + - RCTRequired + - RCTTypeSafety + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - Yoga + - RNScreens (4.11.1): + - DoubleConversion + - glog + - hermes-engine + - RCT-Folly (= 2024.11.18.00) + - RCTRequired + - RCTTypeSafety + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-RCTImage + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - Yoga + - RNSVG (15.13.0): + - React-Core + - SDWebImage (5.21.6): + - SDWebImage/Core (= 5.21.6) + - SDWebImage/Core (5.21.6) + - SDWebImageAVIFCoder (0.11.1): + - libavif/core (>= 0.11.0) + - SDWebImage (~> 5.10) + - SDWebImageSVGCoder (1.7.0): + - SDWebImage/Core (~> 5.6) + - SDWebImageWebPCoder (0.14.6): + - libwebp (~> 1.0) + - SDWebImage/Core (~> 5.17) + - SocketRocket (0.7.1) + - sparkfabrik-react-native-idfa-aaid (1.2.0): + - React + - UIImageColors (2.1.0) + - Yoga (0.0.0) + - ZXingObjC (3.6.9): + - ZXingObjC/All (= 3.6.9) + - ZXingObjC/All (3.6.9) + - ZXingObjC/Core (3.6.9) + - ZXingObjC/OneD (3.6.9): + - ZXingObjC/Core + - ZXingObjC/PDF417 (3.6.9): + - ZXingObjC/Core + +DEPENDENCIES: + - "amplitude-react-native (from `../../../node_modules/@amplitude/analytics-react-native`)" + - Apollo (= 1.2.1) + - Argon2Swift (= 1.0.3) + - boost (from `../../../node_modules/react-native/third-party-podspecs/boost.podspec`) + - "DatadogSDKReactNative (from `../../../node_modules/@datadog/mobile-react-native`)" + - DoubleConversion (from `../../../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec`) +<<<<<<< HEAD + - "EthersRS (from `../../../node_modules/@luxamm/ethers-rs-mobile`)" +======= + - "EthersRS (from `../../../node_modules/@uniswap/ethers-rs-mobile`)" +>>>>>>> upstream/main + - EXConstants (from `../../../node_modules/expo-constants/ios`) + - EXJSONUtils (from `../../../node_modules/expo-json-utils/ios`) + - EXManifests (from `../../../node_modules/expo-manifests/ios`) + - Expo (from `../../../node_modules/expo`) + - expo-dev-client (from `../../../node_modules/expo-dev-client/ios`) + - expo-dev-launcher (from `../../../node_modules/expo-dev-launcher`) + - expo-dev-menu (from `../../../node_modules/expo-dev-menu`) + - expo-dev-menu-interface (from `../../../node_modules/expo-dev-menu-interface/ios`) + - ExpoAsset (from `../../../node_modules/expo-asset/ios`) + - ExpoBlur (from `../../../node_modules/expo-blur/ios`) + - ExpoCamera (from `../../../node_modules/expo-camera/ios`) + - ExpoClipboard (from `../../../node_modules/expo-clipboard/ios`) + - ExpoFileSystem (from `../../../node_modules/expo-file-system/ios`) + - ExpoFont (from `../../../node_modules/expo-font/ios`) + - ExpoHaptics (from `../../../node_modules/expo-haptics/ios`) + - ExpoImage (from `../../../node_modules/expo-image/ios`) + - ExpoKeepAwake (from `../../../node_modules/expo-keep-awake/ios`) + - ExpoLinearGradient (from `../../../node_modules/expo-linear-gradient/ios`) + - ExpoLinking (from `../../../node_modules/expo-linking/ios`) + - ExpoLocalAuthentication (from `../../../node_modules/expo-local-authentication/ios`) + - ExpoLocalization (from `../../../node_modules/expo-localization/ios`) + - ExpoModulesCore (from `../../../node_modules/expo-modules-core`) + - ExpoScreenCapture (from `../../../node_modules/expo-screen-capture/ios`) + - ExpoSecureStore (from `../../../node_modules/expo-secure-store/ios`) + - ExpoStoreReview (from `../../../node_modules/expo-store-review/ios`) + - ExpoWebBrowser (from `../../../node_modules/expo-web-browser/ios`) + - EXUpdatesInterface (from `../../../node_modules/expo-updates-interface/ios`) + - fast_float (from `../../../node_modules/react-native/third-party-podspecs/fast_float.podspec`) + - FBLazyVector (from `../../../node_modules/react-native/Libraries/FBLazyVector`) + - fmt (from `../../../node_modules/react-native/third-party-podspecs/fmt.podspec`) + - glog (from `../../../node_modules/react-native/third-party-podspecs/glog.podspec`) + - hermes-engine (from `../../../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec`) + - "NitroHashcashNative (from `../../../node_modules/@universe/hashcash-native`)" + - NitroModules (from `../../../node_modules/react-native-nitro-modules`) + - OneSignalXCFramework (< 6.0, >= 5.0.0) + - RCT-Folly (from `../../../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec`) + - RCT-Folly/Fabric (from `../../../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec`) + - RCTDeprecation (from `../../../node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation`) + - RCTRequired (from `../../../node_modules/react-native/Libraries/Required`) + - RCTTypeSafety (from `../../../node_modules/react-native/Libraries/TypeSafety`) + - React (from `../../../node_modules/react-native/`) + - React-callinvoker (from `../../../node_modules/react-native/ReactCommon/callinvoker`) + - React-Core (from `../../../node_modules/react-native/`) + - React-Core/RCTWebSocket (from `../../../node_modules/react-native/`) + - React-CoreModules (from `../../../node_modules/react-native/React/CoreModules`) + - React-cxxreact (from `../../../node_modules/react-native/ReactCommon/cxxreact`) + - React-debug (from `../../../node_modules/react-native/ReactCommon/react/debug`) + - React-defaultsnativemodule (from `../../../node_modules/react-native/ReactCommon/react/nativemodule/defaults`) + - React-domnativemodule (from `../../../node_modules/react-native/ReactCommon/react/nativemodule/dom`) + - React-Fabric (from `../../../node_modules/react-native/ReactCommon`) + - React-FabricComponents (from `../../../node_modules/react-native/ReactCommon`) + - React-FabricImage (from `../../../node_modules/react-native/ReactCommon`) + - React-featureflags (from `../../../node_modules/react-native/ReactCommon/react/featureflags`) + - React-featureflagsnativemodule (from `../../../node_modules/react-native/ReactCommon/react/nativemodule/featureflags`) + - React-graphics (from `../../../node_modules/react-native/ReactCommon/react/renderer/graphics`) + - React-hermes (from `../../../node_modules/react-native/ReactCommon/hermes`) + - React-idlecallbacksnativemodule (from `../../../node_modules/react-native/ReactCommon/react/nativemodule/idlecallbacks`) + - React-ImageManager (from `../../../node_modules/react-native/ReactCommon/react/renderer/imagemanager/platform/ios`) + - React-jserrorhandler (from `../../../node_modules/react-native/ReactCommon/jserrorhandler`) + - React-jsi (from `../../../node_modules/react-native/ReactCommon/jsi`) + - React-jsiexecutor (from `../../../node_modules/react-native/ReactCommon/jsiexecutor`) + - React-jsinspector (from `../../../node_modules/react-native/ReactCommon/jsinspector-modern`) + - React-jsinspectortracing (from `../../../node_modules/react-native/ReactCommon/jsinspector-modern/tracing`) + - React-jsitooling (from `../../../node_modules/react-native/ReactCommon/jsitooling`) + - React-jsitracing (from `../../../node_modules/react-native/ReactCommon/hermes/executor/`) + - React-logger (from `../../../node_modules/react-native/ReactCommon/logger`) + - React-Mapbuffer (from `../../../node_modules/react-native/ReactCommon`) + - React-microtasksnativemodule (from `../../../node_modules/react-native/ReactCommon/react/nativemodule/microtasks`) + - react-native-appsflyer (from `../../../node_modules/react-native-appsflyer`) + - "react-native-compat (from `../../../node_modules/@walletconnect/react-native-compat`)" + - react-native-context-menu-view (from `../../../node_modules/react-native-context-menu-view`) + - react-native-get-random-values (from `../../../node_modules/react-native-get-random-values`) + - react-native-image-picker (from `../../../node_modules/react-native-image-picker`) + - react-native-keyboard-controller (from `../../../node_modules/react-native-keyboard-controller`) + - react-native-mmkv (from `../../../node_modules/react-native-mmkv`) + - "react-native-netinfo (from `../../../node_modules/@react-native-community/netinfo`)" + - react-native-onesignal (from `../../../node_modules/react-native-onesignal`) + - react-native-pager-view (from `../../../node_modules/react-native-pager-view`) + - react-native-passkey (from `../../../node_modules/react-native-passkey`) + - react-native-restart (from `../../../node_modules/react-native-restart`) + - react-native-safe-area-context (from `../../../node_modules/react-native-safe-area-context`) + - "react-native-skia (from `../../../node_modules/@shopify/react-native-skia`)" + - "react-native-slider (from `../../../node_modules/@react-native-community/slider`)" + - react-native-video (from `../../../node_modules/react-native-video`) + - react-native-webview (from `../../../node_modules/react-native-webview`) + - react-native-widgetkit (from `../../../node_modules/react-native-widgetkit`) + - React-NativeModulesApple (from `../../../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`) + - React-oscompat (from `../../../node_modules/react-native/ReactCommon/oscompat`) + - React-perflogger (from `../../../node_modules/react-native/ReactCommon/reactperflogger`) + - React-performancetimeline (from `../../../node_modules/react-native/ReactCommon/react/performance/timeline`) + - React-RCTActionSheet (from `../../../node_modules/react-native/Libraries/ActionSheetIOS`) + - React-RCTAnimation (from `../../../node_modules/react-native/Libraries/NativeAnimation`) + - React-RCTAppDelegate (from `../../../node_modules/react-native/Libraries/AppDelegate`) + - React-RCTBlob (from `../../../node_modules/react-native/Libraries/Blob`) + - React-RCTFabric (from `../../../node_modules/react-native/React`) + - React-RCTFBReactNativeSpec (from `../../../node_modules/react-native/React`) + - React-RCTImage (from `../../../node_modules/react-native/Libraries/Image`) + - React-RCTLinking (from `../../../node_modules/react-native/Libraries/LinkingIOS`) + - React-RCTNetwork (from `../../../node_modules/react-native/Libraries/Network`) + - React-RCTRuntime (from `../../../node_modules/react-native/React/Runtime`) + - React-RCTSettings (from `../../../node_modules/react-native/Libraries/Settings`) + - React-RCTText (from `../../../node_modules/react-native/Libraries/Text`) + - React-RCTVibration (from `../../../node_modules/react-native/Libraries/Vibration`) + - React-rendererconsistency (from `../../../node_modules/react-native/ReactCommon/react/renderer/consistency`) + - React-renderercss (from `../../../node_modules/react-native/ReactCommon/react/renderer/css`) + - React-rendererdebug (from `../../../node_modules/react-native/ReactCommon/react/renderer/debug`) + - React-rncore (from `../../../node_modules/react-native/ReactCommon`) + - React-RuntimeApple (from `../../../node_modules/react-native/ReactCommon/react/runtime/platform/ios`) + - React-RuntimeCore (from `../../../node_modules/react-native/ReactCommon/react/runtime`) + - React-runtimeexecutor (from `../../../node_modules/react-native/ReactCommon/runtimeexecutor`) + - React-RuntimeHermes (from `../../../node_modules/react-native/ReactCommon/react/runtime`) + - React-runtimescheduler (from `../../../node_modules/react-native/ReactCommon/react/renderer/runtimescheduler`) + - React-timing (from `../../../node_modules/react-native/ReactCommon/react/timing`) + - React-utils (from `../../../node_modules/react-native/ReactCommon/react/utils`) + - ReactAppDependencyProvider (from `build/generated/ios`) + - ReactCodegen (from `build/generated/ios`) + - ReactCommon/turbomodule/core (from `../../../node_modules/react-native/ReactCommon`) + - "ReactNativePerformance (from `../../../node_modules/@shopify/react-native-performance`)" + - RNBootSplash (from `../../../node_modules/react-native-bootsplash`) + - "RNCAsyncStorage (from `../../../node_modules/@react-native-async-storage/async-storage`)" + - "RNCMaskedView (from `../../../node_modules/@react-native-masked-view/masked-view`)" + - "RNDateTimePicker (from `../../../node_modules/@react-native-community/datetimepicker`)" + - RNDeviceInfo (from `../../../node_modules/react-native-device-info`) + - "RNFBApp (from `../../../node_modules/@react-native-firebase/app`)" + - "RNFBAuth (from `../../../node_modules/@react-native-firebase/auth`)" + - "RNFBFirestore (from `../../../node_modules/@react-native-firebase/firestore`)" + - "RNFlashList (from `../../../node_modules/@shopify/flash-list`)" + - RNGestureHandler (from `../../../node_modules/react-native-gesture-handler`) + - RNImageColors (from `../../../node_modules/react-native-image-colors`) + - RNLocalize (from `../../../node_modules/react-native-localize`) + - RNPermissions (from `../../../node_modules/react-native-permissions`) + - RNQrGenerator (from `../../../node_modules/rn-qr-generator`) + - RNReanimated (from `../../../node_modules/react-native-reanimated`) + - RNScreens (from `../../../node_modules/react-native-screens`) + - RNSVG (from `../../../node_modules/react-native-svg`) + - "sparkfabrik-react-native-idfa-aaid (from `../../../node_modules/@sparkfabrik/react-native-idfa-aaid`)" + - UIImageColors (= 2.1.0) + - Yoga (from `../../../node_modules/react-native/ReactCommon/yoga`) + +SPEC REPOS: + trunk: + - abseil + - Apollo + - AppsFlyerFramework + - Argon2Swift + - BoringSSL-GRPC + - DatadogCore + - DatadogCrashReporting + - DatadogInternal + - DatadogLogs + - DatadogRUM + - DatadogTrace + - DatadogWebViewTracking + - Firebase + - FirebaseAppCheckInterop + - FirebaseAuth + - FirebaseAuthInterop + - FirebaseCore + - FirebaseCoreExtension + - FirebaseCoreInternal + - FirebaseFirestore + - FirebaseFirestoreInternal + - FirebaseSharedSwift + - GoogleUtilities + - "gRPC-C++" + - gRPC-Core + - GTMSessionFetcher + - leveldb-library + - libavif + - libdav1d + - libwebp + - MMKV + - MMKVCore + - nanopb + - OneSignalXCFramework + - OpenTelemetrySwiftApi + - PLCrashReporter + - RecaptchaInterop + - SDWebImage + - SDWebImageAVIFCoder + - SDWebImageSVGCoder + - SDWebImageWebPCoder + - SocketRocket + - UIImageColors + - ZXingObjC + +EXTERNAL SOURCES: + amplitude-react-native: + :path: "../../../node_modules/@amplitude/analytics-react-native" + boost: + :podspec: "../../../node_modules/react-native/third-party-podspecs/boost.podspec" + DatadogSDKReactNative: + :path: "../../../node_modules/@datadog/mobile-react-native" + DoubleConversion: + :podspec: "../../../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec" + EthersRS: +<<<<<<< HEAD + :path: "../../../node_modules/@luxamm/ethers-rs-mobile" +======= + :path: "../../../node_modules/@uniswap/ethers-rs-mobile" +>>>>>>> upstream/main + EXConstants: + :path: "../../../node_modules/expo-constants/ios" + EXJSONUtils: + :path: "../../../node_modules/expo-json-utils/ios" + EXManifests: + :path: "../../../node_modules/expo-manifests/ios" + Expo: + :path: "../../../node_modules/expo" + expo-dev-client: + :path: "../../../node_modules/expo-dev-client/ios" + expo-dev-launcher: + :path: "../../../node_modules/expo-dev-launcher" + expo-dev-menu: + :path: "../../../node_modules/expo-dev-menu" + expo-dev-menu-interface: + :path: "../../../node_modules/expo-dev-menu-interface/ios" + ExpoAsset: + :path: "../../../node_modules/expo-asset/ios" + ExpoBlur: + :path: "../../../node_modules/expo-blur/ios" + ExpoCamera: + :path: "../../../node_modules/expo-camera/ios" + ExpoClipboard: + :path: "../../../node_modules/expo-clipboard/ios" + ExpoFileSystem: + :path: "../../../node_modules/expo-file-system/ios" + ExpoFont: + :path: "../../../node_modules/expo-font/ios" + ExpoHaptics: + :path: "../../../node_modules/expo-haptics/ios" + ExpoImage: + :path: "../../../node_modules/expo-image/ios" + ExpoKeepAwake: + :path: "../../../node_modules/expo-keep-awake/ios" + ExpoLinearGradient: + :path: "../../../node_modules/expo-linear-gradient/ios" + ExpoLinking: + :path: "../../../node_modules/expo-linking/ios" + ExpoLocalAuthentication: + :path: "../../../node_modules/expo-local-authentication/ios" + ExpoLocalization: + :path: "../../../node_modules/expo-localization/ios" + ExpoModulesCore: + :path: "../../../node_modules/expo-modules-core" + ExpoScreenCapture: + :path: "../../../node_modules/expo-screen-capture/ios" + ExpoSecureStore: + :path: "../../../node_modules/expo-secure-store/ios" + ExpoStoreReview: + :path: "../../../node_modules/expo-store-review/ios" + ExpoWebBrowser: + :path: "../../../node_modules/expo-web-browser/ios" + EXUpdatesInterface: + :path: "../../../node_modules/expo-updates-interface/ios" + fast_float: + :podspec: "../../../node_modules/react-native/third-party-podspecs/fast_float.podspec" + FBLazyVector: + :path: "../../../node_modules/react-native/Libraries/FBLazyVector" + fmt: + :podspec: "../../../node_modules/react-native/third-party-podspecs/fmt.podspec" + glog: + :podspec: "../../../node_modules/react-native/third-party-podspecs/glog.podspec" + hermes-engine: + :podspec: "../../../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec" + :tag: hermes-2025-06-04-RNv0.79.3-7f9a871eefeb2c3852365ee80f0b6733ec12ac3b + NitroHashcashNative: + :path: "../../../node_modules/@universe/hashcash-native" + NitroModules: + :path: "../../../node_modules/react-native-nitro-modules" + RCT-Folly: + :podspec: "../../../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec" + RCTDeprecation: + :path: "../../../node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation" + RCTRequired: + :path: "../../../node_modules/react-native/Libraries/Required" + RCTTypeSafety: + :path: "../../../node_modules/react-native/Libraries/TypeSafety" + React: + :path: "../../../node_modules/react-native/" + React-callinvoker: + :path: "../../../node_modules/react-native/ReactCommon/callinvoker" + React-Core: + :path: "../../../node_modules/react-native/" + React-CoreModules: + :path: "../../../node_modules/react-native/React/CoreModules" + React-cxxreact: + :path: "../../../node_modules/react-native/ReactCommon/cxxreact" + React-debug: + :path: "../../../node_modules/react-native/ReactCommon/react/debug" + React-defaultsnativemodule: + :path: "../../../node_modules/react-native/ReactCommon/react/nativemodule/defaults" + React-domnativemodule: + :path: "../../../node_modules/react-native/ReactCommon/react/nativemodule/dom" + React-Fabric: + :path: "../../../node_modules/react-native/ReactCommon" + React-FabricComponents: + :path: "../../../node_modules/react-native/ReactCommon" + React-FabricImage: + :path: "../../../node_modules/react-native/ReactCommon" + React-featureflags: + :path: "../../../node_modules/react-native/ReactCommon/react/featureflags" + React-featureflagsnativemodule: + :path: "../../../node_modules/react-native/ReactCommon/react/nativemodule/featureflags" + React-graphics: + :path: "../../../node_modules/react-native/ReactCommon/react/renderer/graphics" + React-hermes: + :path: "../../../node_modules/react-native/ReactCommon/hermes" + React-idlecallbacksnativemodule: + :path: "../../../node_modules/react-native/ReactCommon/react/nativemodule/idlecallbacks" + React-ImageManager: + :path: "../../../node_modules/react-native/ReactCommon/react/renderer/imagemanager/platform/ios" + React-jserrorhandler: + :path: "../../../node_modules/react-native/ReactCommon/jserrorhandler" + React-jsi: + :path: "../../../node_modules/react-native/ReactCommon/jsi" + React-jsiexecutor: + :path: "../../../node_modules/react-native/ReactCommon/jsiexecutor" + React-jsinspector: + :path: "../../../node_modules/react-native/ReactCommon/jsinspector-modern" + React-jsinspectortracing: + :path: "../../../node_modules/react-native/ReactCommon/jsinspector-modern/tracing" + React-jsitooling: + :path: "../../../node_modules/react-native/ReactCommon/jsitooling" + React-jsitracing: + :path: "../../../node_modules/react-native/ReactCommon/hermes/executor/" + React-logger: + :path: "../../../node_modules/react-native/ReactCommon/logger" + React-Mapbuffer: + :path: "../../../node_modules/react-native/ReactCommon" + React-microtasksnativemodule: + :path: "../../../node_modules/react-native/ReactCommon/react/nativemodule/microtasks" + react-native-appsflyer: + :path: "../../../node_modules/react-native-appsflyer" + react-native-compat: + :path: "../../../node_modules/@walletconnect/react-native-compat" + react-native-context-menu-view: + :path: "../../../node_modules/react-native-context-menu-view" + react-native-get-random-values: + :path: "../../../node_modules/react-native-get-random-values" + react-native-image-picker: + :path: "../../../node_modules/react-native-image-picker" + react-native-keyboard-controller: + :path: "../../../node_modules/react-native-keyboard-controller" + react-native-mmkv: + :path: "../../../node_modules/react-native-mmkv" + react-native-netinfo: + :path: "../../../node_modules/@react-native-community/netinfo" + react-native-onesignal: + :path: "../../../node_modules/react-native-onesignal" + react-native-pager-view: + :path: "../../../node_modules/react-native-pager-view" + react-native-passkey: + :path: "../../../node_modules/react-native-passkey" + react-native-restart: + :path: "../../../node_modules/react-native-restart" + react-native-safe-area-context: + :path: "../../../node_modules/react-native-safe-area-context" + react-native-skia: + :path: "../../../node_modules/@shopify/react-native-skia" + react-native-slider: + :path: "../../../node_modules/@react-native-community/slider" + react-native-video: + :path: "../../../node_modules/react-native-video" + react-native-webview: + :path: "../../../node_modules/react-native-webview" + react-native-widgetkit: + :path: "../../../node_modules/react-native-widgetkit" + React-NativeModulesApple: + :path: "../../../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios" + React-oscompat: + :path: "../../../node_modules/react-native/ReactCommon/oscompat" + React-perflogger: + :path: "../../../node_modules/react-native/ReactCommon/reactperflogger" + React-performancetimeline: + :path: "../../../node_modules/react-native/ReactCommon/react/performance/timeline" + React-RCTActionSheet: + :path: "../../../node_modules/react-native/Libraries/ActionSheetIOS" + React-RCTAnimation: + :path: "../../../node_modules/react-native/Libraries/NativeAnimation" + React-RCTAppDelegate: + :path: "../../../node_modules/react-native/Libraries/AppDelegate" + React-RCTBlob: + :path: "../../../node_modules/react-native/Libraries/Blob" + React-RCTFabric: + :path: "../../../node_modules/react-native/React" + React-RCTFBReactNativeSpec: + :path: "../../../node_modules/react-native/React" + React-RCTImage: + :path: "../../../node_modules/react-native/Libraries/Image" + React-RCTLinking: + :path: "../../../node_modules/react-native/Libraries/LinkingIOS" + React-RCTNetwork: + :path: "../../../node_modules/react-native/Libraries/Network" + React-RCTRuntime: + :path: "../../../node_modules/react-native/React/Runtime" + React-RCTSettings: + :path: "../../../node_modules/react-native/Libraries/Settings" + React-RCTText: + :path: "../../../node_modules/react-native/Libraries/Text" + React-RCTVibration: + :path: "../../../node_modules/react-native/Libraries/Vibration" + React-rendererconsistency: + :path: "../../../node_modules/react-native/ReactCommon/react/renderer/consistency" + React-renderercss: + :path: "../../../node_modules/react-native/ReactCommon/react/renderer/css" + React-rendererdebug: + :path: "../../../node_modules/react-native/ReactCommon/react/renderer/debug" + React-rncore: + :path: "../../../node_modules/react-native/ReactCommon" + React-RuntimeApple: + :path: "../../../node_modules/react-native/ReactCommon/react/runtime/platform/ios" + React-RuntimeCore: + :path: "../../../node_modules/react-native/ReactCommon/react/runtime" + React-runtimeexecutor: + :path: "../../../node_modules/react-native/ReactCommon/runtimeexecutor" + React-RuntimeHermes: + :path: "../../../node_modules/react-native/ReactCommon/react/runtime" + React-runtimescheduler: + :path: "../../../node_modules/react-native/ReactCommon/react/renderer/runtimescheduler" + React-timing: + :path: "../../../node_modules/react-native/ReactCommon/react/timing" + React-utils: + :path: "../../../node_modules/react-native/ReactCommon/react/utils" + ReactAppDependencyProvider: + :path: build/generated/ios + ReactCodegen: + :path: build/generated/ios + ReactCommon: + :path: "../../../node_modules/react-native/ReactCommon" + ReactNativePerformance: + :path: "../../../node_modules/@shopify/react-native-performance" + RNBootSplash: + :path: "../../../node_modules/react-native-bootsplash" + RNCAsyncStorage: + :path: "../../../node_modules/@react-native-async-storage/async-storage" + RNCMaskedView: + :path: "../../../node_modules/@react-native-masked-view/masked-view" + RNDateTimePicker: + :path: "../../../node_modules/@react-native-community/datetimepicker" + RNDeviceInfo: + :path: "../../../node_modules/react-native-device-info" + RNFBApp: + :path: "../../../node_modules/@react-native-firebase/app" + RNFBAuth: + :path: "../../../node_modules/@react-native-firebase/auth" + RNFBFirestore: + :path: "../../../node_modules/@react-native-firebase/firestore" + RNFlashList: + :path: "../../../node_modules/@shopify/flash-list" + RNGestureHandler: + :path: "../../../node_modules/react-native-gesture-handler" + RNImageColors: + :path: "../../../node_modules/react-native-image-colors" + RNLocalize: + :path: "../../../node_modules/react-native-localize" + RNPermissions: + :path: "../../../node_modules/react-native-permissions" + RNQrGenerator: + :path: "../../../node_modules/rn-qr-generator" + RNReanimated: + :path: "../../../node_modules/react-native-reanimated" + RNScreens: + :path: "../../../node_modules/react-native-screens" + RNSVG: + :path: "../../../node_modules/react-native-svg" + sparkfabrik-react-native-idfa-aaid: + :path: "../../../node_modules/@sparkfabrik/react-native-idfa-aaid" + Yoga: + :path: "../../../node_modules/react-native/ReactCommon/yoga" + +SPEC CHECKSUMS: + abseil: d121da9ef7e2ff4cab7666e76c5a3e0915ae08c3 + amplitude-react-native: 9d57e1bcc4175039e36283390aa3daeaea9441a5 + Apollo: fe380f40e55e501a2499dd5885fab0cdf082b2bb + AppsFlyerFramework: 971521cf5b890c2afeab2f2c91734547b8b169ca + Argon2Swift: 99482c1b8122a03524b61e41c4903a9548e7c33b + boost: 7e761d76ca2ce687f7cc98e698152abd03a18f90 + BoringSSL-GRPC: ca6a8e5d04812fce8ffd6437810c2d46f925eaeb + DatadogCore: 5c01290a3b60b27bf49aa958f2e339c738364d9e + DatadogCrashReporting: 11286d48ab61baeb2b41b945c7c0d4ef23db317d + DatadogInternal: 7aeb48e254178a0c462c3953dc0a8a8d64499a93 + DatadogLogs: 4324739de62a6059e07d70bf6ceceed78764edeb + DatadogRUM: f36949a38285f3b240a7be577d425f8518e087d4 + DatadogSDKReactNative: 58d9a3f2005f0f9b47c057929c021fcb3b5201e4 + DatadogTrace: bfea32b6ed2870829629a9296cf526221493cc3e + DatadogWebViewTracking: 78c20d8e5f1ade506f4aadaec5690c1a63283fe2 + DoubleConversion: cb417026b2400c8f53ae97020b2be961b59470cb + EthersRS: 56b70e73d22d4e894b7e762eef1129159bcd3135 + EXConstants: 7b84e6fc99a2bc8c589b647336b6005b419b280b + EXJSONUtils: 1d3e4590438c3ee593684186007028a14b3686cd + EXManifests: f4cc4a62ee4f1c8a9cf2bb79d325eac6cb9f5684 + Expo: 2a8d20c4498052d30c3b198e98cf8c19a137ecd7 + expo-dev-client: f1b99dfea0c9174d2e4ec96c2c5461587dda1e86 + expo-dev-launcher: 73e0cc1a270486501011fd8bed4cb096cc431a43 + expo-dev-menu: b2554d3971b251b2c1f0f5c9c3da50855150f195 + expo-dev-menu-interface: 609c35ae8b97479cdd4c9e23c8cf6adc44beea0e + ExpoAsset: 7bdbbacf4e6752ae6e3cf70555cee076f6229e6e + ExpoBlur: 846780b2c90f59e964b9a50385d4deb67174ebfb + ExpoCamera: fc1ab0e1c665b543a307c577df107e37cc2edc8e + ExpoClipboard: 6b9aae54fd48a579473fb101051ad693435b9294 + ExpoFileSystem: 9681caebda23fa1b38a12a9c68b2bade7072ce20 + ExpoFont: 091a47eeaa1b30b0b760aa1d0a2e7814e8bf6fe6 + ExpoHaptics: e01cce0741d68c281853118eb0267f88d42c6b7a + ExpoImage: b5d08ac8a79f798c8306b53165415c15986f9764 + ExpoKeepAwake: e8dedc115d9f6f24b153ccd2d1d8efcdfd68a527 + ExpoLinearGradient: ce334cff9859da4635c1d8eff6e291b11b04ccbb + ExpoLinking: 343a89ea864a851831fd4495e8aea01cf0f6a36f + ExpoLocalAuthentication: 78f74d187ee51126e1a789d73fee32d6d7e60f1f + ExpoLocalization: 677e45c2536bf918119962f78d7ffeeea317e07d + ExpoModulesCore: 8030601b6028c50a3adf8864dabf43c84c913f43 + ExpoScreenCapture: 329c26be22741077b81612de1edaee8648fb209e + ExpoSecureStore: d006eea5e316283099d46f80a6b10055b89a6008 + ExpoStoreReview: bed43bea90a5876a6a480504f95fea1521dacaa7 + ExpoWebBrowser: eeb47f52e85b2686b56178749675cf90d0822f86 + EXUpdatesInterface: 64f35449b8ef89ce08cdd8952a4d119b5de6821d + fast_float: 06eeec4fe712a76acc9376682e4808b05ce978b6 + FBLazyVector: d2a9cd223302b6c9aa4aa34c1a775e9db609eb52 + Firebase: 98e6bf5278170668a7983e12971a66b2cd57fc8c + FirebaseAppCheckInterop: 06fe5a3799278ae4667e6c432edd86b1030fa3df + FirebaseAuth: 2a198b8cdbbbd457f08d74df7040feb0a0e7777a + FirebaseAuthInterop: 7087d7a4ee4bc4de019b2d0c240974ed5d89e2fd + FirebaseCore: a282032ae9295c795714ded2ec9c522fc237f8da + FirebaseCoreExtension: f1bc67a4702931a7caa097d8e4ac0a1b0d16720e + FirebaseCoreInternal: 9afa45b1159304c963da48addb78275ef701c6b4 + FirebaseFirestore: 62708adbc1dfcd6d165a7c0a202067b441912dc9 + FirebaseFirestoreInternal: ad9b9ee2d3d430c8f31333a69b3b6737a7206232 + FirebaseSharedSwift: e17c654ef1f1a616b0b33054e663ad1035c8fd40 + fmt: a40bb5bd0294ea969aaaba240a927bd33d878cdd + glog: 5683914934d5b6e4240e497e0f4a3b42d1854183 + GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 + "gRPC-C++": 2fa52b3141e7789a28a737f251e0c45b4cb20a87 + gRPC-Core: a27c294d6149e1c39a7d173527119cfbc3375ce4 + GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6 + hermes-engine: f03b0e06d3882d71e67e45b073bb827da1a21aae + leveldb-library: cc8b8f8e013647a295ad3f8cd2ddf49a6f19be19 + libavif: 5f8e715bea24debec477006f21ef9e95432e254d + libdav1d: 23581a4d8ec811ff171ed5e2e05cd27bad64c39f + libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8 + MMKV: c953dbaac0da392c24b005e763c03ce2638b4ed7 + MMKVCore: d078dce7d6586a888b2c2ef5343b6242678e3ee8 + nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 + NitroHashcashNative: cc2cb81a1c6ef47ba928e1b217c670ca33c7c56c + NitroModules: 3657718d49e12e1954f7c4bb82f6f2a3ee567253 + OneSignalXCFramework: 1a3b28dfbff23aabce585796d23c1bef37772774 + OpenTelemetrySwiftApi: aaee576ed961e0c348af78df58b61300e95bd104 + PLCrashReporter: db59ef96fa3d25f3650040d02ec2798cffee75f2 + RCT-Folly: 36fe2295e44b10d831836cc0d1daec5f8abcf809 + RCTDeprecation: 5f638f65935e273753b1f31a365db6a8d6dc53b5 + RCTRequired: 8b46a520ea9071e2bc47d474aa9ca31b4a935bd8 + RCTTypeSafety: cc4740278c2a52cbf740592b0a0a40df1587c9ab + React: 6393ae1807614f017a84805bf2417e3497f518a6 + React-callinvoker: c34f666f551f05a325b87e7e3e6df0e082fa3d99 + React-Core: fc07a4b69a963880b25142c51178f4cb75628c7d + React-CoreModules: 94d39315cfa791f6c477712fea47c34f8ecb26c6 + React-cxxreact: 628c28cdb3fdef93ee3bfc2bec8e2d776e81ae49 + React-debug: c1b10e5982b961738eab5b1d66fa31572ca28b5e + React-defaultsnativemodule: dd13932a4a4b0f1d556c9a4e76cc00fa05207126 + React-domnativemodule: 44bd8074cffa6a8ed298a45e2f7d1b519fb15a23 + React-Fabric: 5efe9e2171352089ded33d73e177345e8eb74e00 + React-FabricComponents: 359ea9205fc116ec52c90186ace048391ba477f7 + React-FabricImage: e536ff5e1c1f081dc5953696287e33d4d3b543bd + React-featureflags: 1e3a098a98c63a339a8b5ef4014ba4c4b43fb1f6 + React-featureflagsnativemodule: c926c24fda31daab491e25f4003413b49a5dfaec + React-graphics: 1476634e2deaf13dad3ab7ddfa33f78933445e07 + React-hermes: af1b3d79491295abc9d1b11f84e77d5dc00095b6 + React-idlecallbacksnativemodule: 43fc456e78c7dd7a342a9f185ef7c931d8c44ab0 + React-ImageManager: bd97427edf2df85e7e162e2161c9c981a6185915 + React-jserrorhandler: 44ebfb576a9ce098205b246a4bb81c9ad55ffbb6 + React-jsi: e9c3019e00db5d144e0a660616a52a605e12c39a + React-jsiexecutor: 3ed70a394b76f33e6c4ec4b382a457df7309d96c + React-jsinspector: d0c7ef76573b8e2b362ebc570aecd43b0c88a282 + React-jsinspectortracing: 551b7981d2a0b6a7829fd8c8c310ca51b5b323f8 + React-jsitooling: 5d06fc7c61ac1d260a553a9a9cffcd78865e430c + React-jsitracing: 2ecfa3ccc58e876a8c4f76a2cbdd920fc1ccfbb0 + React-logger: e6e6164f1753e46d1b7e2c8f0949cd7937eaf31b + React-Mapbuffer: a83853bd80bb31a4451e64af91a86c02f7df4f80 + React-microtasksnativemodule: 964a2c1213bb39fa5cc8d5ee4f55846d67747f32 + react-native-appsflyer: 2cc1f96348065fc23e976fc7a27e371789fb349e + react-native-compat: 1e09d1a14355b6a0383fb371fe98509fa8c3f4fd + react-native-context-menu-view: dcec18eb8882e20596dbb75802e7d19cb87dac02 + react-native-get-random-values: 21325b2244dfa6b58878f51f9aa42821e7ba3d06 + react-native-image-picker: 75cc6db21e264e573456725c71ad21828a82c455 + react-native-keyboard-controller: 8698f5ff79ab0a4a8f0259a40b4c3c7f23cae7fd +<<<<<<< HEAD + react-native-mmkv: dea675cf9697ad35940f1687e98e133e1358ef9f +======= + react-native-mmkv: d881794b39391fb831de1b6993ecb3c9c57bae51 +>>>>>>> upstream/main + react-native-netinfo: f0a9899081c185db1de5bb2fdc1c88c202a059ac + react-native-onesignal: 33ade92bd91578374c31c5a5a91f45f49c2d6614 + react-native-pager-view: d6f91626b36fcca51d28a9c5ec109a9309242089 + react-native-passkey: 69bede03f6bb35fad8117cad73155231cc31066c + react-native-restart: 7595693413fe3ca15893702f2c8306c62a708162 + react-native-safe-area-context: 8870dc3e45c8d241336cd8ee3fa3fc76f3a040ac + react-native-skia: ada93c6118964447045cb8a0c04bacc7400373d3 + react-native-slider: d3ddeb61d8c4c4d99f19194338d8d2c33957e717 + react-native-video: a6a2ad5d778133dee45875faf44c6ce0d61cac0e + react-native-webview: 94294e5a5d8cf4f53793aee7ef69ebd14c79e397 + react-native-widgetkit: efb6680df237463bbe1be3a4d1a1578a1b0bb08f + React-NativeModulesApple: d2b9bd7d55dfd864e56c76592777ad32e8ab1f3d + React-oscompat: 0592889a9fcf0eacb205532028e4a364e22907dd + React-perflogger: 634408a9a0f5753faa577dfa81bc009edca01062 + React-performancetimeline: b58a6e65c9fbe1aa02b97152edd6ad5275aef36b + React-RCTActionSheet: ce67bdc050cc1d9ef673c7a93e9799288a183f24 + React-RCTAnimation: 12193c2092a78012c7f77457806dcc822cc40d2c + React-RCTAppDelegate: b0a8aa38e4791915673a7a3ae80b2840a81ec255 + React-RCTBlob: 923cf9b0098b9a641cb1e454c30a444d9d3cda70 + React-RCTFabric: 3f2f2980ad1d426f3a03c9183521b835e0bbbe83 + React-RCTFBReactNativeSpec: b7671d70d65f61326805725b24c7855aab0befb2 + React-RCTImage: 580a5d0a6fdf9b69629d0582e5fb5a173e152099 + React-RCTLinking: 4ed7c5667709099bfd6b2b6246b1dfd79c89f7cb + React-RCTNetwork: 06a22dd0088392694df4fd098634811aa0b3e166 + React-RCTRuntime: 38591d6246389f4f8b93f0f94f565f8448805581 + React-RCTSettings: 9dbf433f302c8ebe43b280453e74624098fbc706 + React-RCTText: 92fcd78d6c44dbe64d147bb63f53698bcba7c971 + React-RCTVibration: 513659394c92491e6c749e981424f6e1e0abdb3c + React-rendererconsistency: c9c28e3b0834d9be2e6aa0ba2d1fd77c76441658 + React-renderercss: 700c57db7fcb36a1e5a9b3645f4cc22f4de43899 + React-rendererdebug: 8ce5f50fd01160e1d1bfc9ec34dac0ca5411afc2 + React-rncore: 289894dda4ebcca06104070f1a9c9283f37dd123 + React-RuntimeApple: 5e5315e698c4fc4e5a3e6b610e4e0ae135f3718c + React-RuntimeCore: 23b6e0e4e2b1cb8a81a14db68814ade8998a5fb0 + React-runtimeexecutor: ebfd71307b3166c73ac0c441c1ea42e0f17f821d + React-RuntimeHermes: baef54b36a6623ea8cd7442027744b08ad06d01b + React-runtimescheduler: 4d9a1afaa16d7dd11a909a9103b18b63995c5683 + React-timing: 0f749e1c5ca1147b699b25ec79003950e6366056 + React-utils: 52ce70a20366bb7f1b416c57281f40ffafeb8a8a + ReactAppDependencyProvider: c42e7abdd2228ae583bdabc3dcd8e5cda6bef944 + ReactCodegen: 0851536ada69d85536f54a7239956a50b6dcd42e + ReactCommon: 3dbed0a44e9e5d7b77b0f35983633aa5d33c9694 + ReactNativePerformance: ab7dee4c4862623d72c1530a9fc71b55458edf71 + RecaptchaInterop: 7d1a4a01a6b2cb1610a47ef3f85f0c411434cb21 + RNBootSplash: 2c6b226e3ad3c97d16b6d53bd75d0cd281646bfb + RNCAsyncStorage: addfc2cb6511dbe199c56c6b26ede383b6c38919 + RNCMaskedView: 42f3684c136239957b410dbfa81978b25f2c0e18 + RNDateTimePicker: 279bad2682d9ebdd4151cb71d88f3b460f818fc8 + RNDeviceInfo: bf8a32acbcb875f568217285d1793b0e8588c974 + RNFBApp: 4122dd41d8d7ff017b6ecf777a6224f5b349ca04 + RNFBAuth: 1632cefd787a43ba952fa52ff016e7b69fe355cb + RNFBFirestore: 5f110e37b7f7f3d6e03c85044dd4cf3ebacec38b + RNFlashList: 7c43eac420e04bfa7798d40c7246c7067e8a4c2c + RNGestureHandler: eb5ad44465a546182d05aebae304e45c881d2f22 + RNImageColors: 9ac05083b52d5c350e6972650ae3ba0e556466c1 + RNLocalize: d4b8af4e442d4bcca54e68fc687a2129b4d71a81 + RNPermissions: 87aac13521bea6dcb6dfd60b03ac69741ccef2b4 + RNQrGenerator: ac6a6c766e80dd3625038929ed2b13e2f3edcafb + RNReanimated: ad46062e119fcf93712dfe9dcf72b45ea16892e4 + RNScreens: edd4795b025d94f879e20cc346b844176d938f0c + RNSVG: 204b068da3a7416d22840cd3233bcf745443a455 + SDWebImage: 1bb6a1b84b6fe87b972a102bdc77dd589df33477 + SDWebImageAVIFCoder: afe194a084e851f70228e4be35ef651df0fc5c57 + SDWebImageSVGCoder: 15a300a97ec1c8ac958f009c02220ac0402e936c + SDWebImageWebPCoder: e38c0a70396191361d60c092933e22c20d5b1380 + SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 + sparkfabrik-react-native-idfa-aaid: 1b72a6264a2175473e309ffa6434db87c58af264 + UIImageColors: d2ef3b0877d203cbb06489eeb78ea8b7788caabe + Yoga: bfcce202dba74007f8974ee9c5f903a9a286c445 + ZXingObjC: 8898711ab495761b2dbbdec76d90164a6d7e14c5 + +<<<<<<< HEAD +PODFILE CHECKSUM: 5af828fda56d810e895841aeec7a1c668ef74466 +======= +PODFILE CHECKSUM: 407b7412f08ef053b16b402cacac69b31d41ef80 +>>>>>>> upstream/main + +COCOAPODS: 1.16.2 diff --git a/apps/mobile/ios/ReactFlatHeaders/React/AppleEventBeat.h b/apps/mobile/ios/ReactFlatHeaders/React/AppleEventBeat.h new file mode 120000 index 00000000..37d2f9c1 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/AppleEventBeat.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Fabric/AppleEventBeat.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/CoreModulesPlugins.h b/apps/mobile/ios/ReactFlatHeaders/React/CoreModulesPlugins.h new file mode 120000 index 00000000..007ffe60 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/CoreModulesPlugins.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/CoreModules/CoreModulesPlugins.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/DispatchMessageQueueThread.h b/apps/mobile/ios/ReactFlatHeaders/React/DispatchMessageQueueThread.h new file mode 120000 index 00000000..bfcc5db2 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/DispatchMessageQueueThread.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/CxxModule/DispatchMessageQueueThread.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/FBReactNativeSpec.h b/apps/mobile/ios/ReactFlatHeaders/React/FBReactNativeSpec.h new file mode 120000 index 00000000..dfe4ad26 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/FBReactNativeSpec.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/FBReactNativeSpec/FBReactNativeSpec/FBReactNativeSpec.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/FBReactNativeSpecJSI.h b/apps/mobile/ios/ReactFlatHeaders/React/FBReactNativeSpecJSI.h new file mode 120000 index 00000000..8cf10f16 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/FBReactNativeSpecJSI.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/FBReactNativeSpec/FBReactNativeSpecJSI.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/FBXXHashUtils.h b/apps/mobile/ios/ReactFlatHeaders/React/FBXXHashUtils.h new file mode 120000 index 00000000..a183a86e --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/FBXXHashUtils.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/I18n/FBXXHashUtils.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/JSCExecutorFactory.h b/apps/mobile/ios/ReactFlatHeaders/React/JSCExecutorFactory.h new file mode 120000 index 00000000..ef2e1343 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/JSCExecutorFactory.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/CxxBridge/JSCExecutorFactory.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/NSDataBigString.h b/apps/mobile/ios/ReactFlatHeaders/React/NSDataBigString.h new file mode 120000 index 00000000..dbaa4c41 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/NSDataBigString.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/CxxBridge/NSDataBigString.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/PlatformRunLoopObserver.h b/apps/mobile/ios/ReactFlatHeaders/React/PlatformRunLoopObserver.h new file mode 120000 index 00000000..5b3f03f9 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/PlatformRunLoopObserver.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Fabric/Utils/PlatformRunLoopObserver.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTAccessibilityElement.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTAccessibilityElement.h new file mode 120000 index 00000000..0af1081b --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTAccessibilityElement.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTAccessibilityElement.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTAccessibilityManager+Internal.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTAccessibilityManager+Internal.h new file mode 120000 index 00000000..a86eb53f --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTAccessibilityManager+Internal.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/CoreModules/RCTAccessibilityManager+Internal.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTAccessibilityManager.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTAccessibilityManager.h new file mode 120000 index 00000000..10e462f1 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTAccessibilityManager.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/CoreModules/RCTAccessibilityManager.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTActionSheetManager.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTActionSheetManager.h new file mode 120000 index 00000000..aa686f8b --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTActionSheetManager.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/CoreModules/RCTActionSheetManager.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTActivityIndicatorView.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTActivityIndicatorView.h new file mode 120000 index 00000000..b8e03f76 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTActivityIndicatorView.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Views/RCTActivityIndicatorView.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTActivityIndicatorViewComponentView.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTActivityIndicatorViewComponentView.h new file mode 120000 index 00000000..3488724d --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTActivityIndicatorViewComponentView.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Fabric/Mounting/ComponentViews/ActivityIndicator/RCTActivityIndicatorViewComponentView.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTActivityIndicatorViewManager.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTActivityIndicatorViewManager.h new file mode 120000 index 00000000..20c8ec45 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTActivityIndicatorViewManager.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Views/RCTActivityIndicatorViewManager.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTAlertController.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTAlertController.h new file mode 120000 index 00000000..bf81469b --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTAlertController.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/CoreModules/RCTAlertController.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTAlertManager.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTAlertManager.h new file mode 120000 index 00000000..9f8db63d --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTAlertManager.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/CoreModules/RCTAlertManager.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTAnimationType.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTAnimationType.h new file mode 120000 index 00000000..5d385626 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTAnimationType.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Views/RCTAnimationType.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTAppState.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTAppState.h new file mode 120000 index 00000000..a1d14697 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTAppState.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/CoreModules/RCTAppState.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTAppearance.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTAppearance.h new file mode 120000 index 00000000..7aaf2e12 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTAppearance.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/CoreModules/RCTAppearance.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTAssert.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTAssert.h new file mode 120000 index 00000000..f53ec48f --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTAssert.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Base/RCTAssert.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTAutoInsetsProtocol.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTAutoInsetsProtocol.h new file mode 120000 index 00000000..7fa21cec --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTAutoInsetsProtocol.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Views/RCTAutoInsetsProtocol.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTBorderCurve.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTBorderCurve.h new file mode 120000 index 00000000..88d49bc1 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTBorderCurve.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Views/RCTBorderCurve.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTBorderDrawing.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTBorderDrawing.h new file mode 120000 index 00000000..e079caad --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTBorderDrawing.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Views/RCTBorderDrawing.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTBorderStyle.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTBorderStyle.h new file mode 120000 index 00000000..e5d4bd3e --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTBorderStyle.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Views/RCTBorderStyle.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTBoxShadow.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTBoxShadow.h new file mode 120000 index 00000000..f5af210c --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTBoxShadow.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Fabric/Utils/RCTBoxShadow.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTBridge+Inspector.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTBridge+Inspector.h new file mode 120000 index 00000000..5e05f1d9 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTBridge+Inspector.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Base/RCTBridge+Inspector.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTBridge+Private.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTBridge+Private.h new file mode 120000 index 00000000..23f7efae --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTBridge+Private.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Base/RCTBridge+Private.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTBridge.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTBridge.h new file mode 120000 index 00000000..94dd3037 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTBridge.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Base/RCTBridge.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTBridgeConstants.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTBridgeConstants.h new file mode 120000 index 00000000..75478240 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTBridgeConstants.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Base/RCTBridgeConstants.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTBridgeDelegate.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTBridgeDelegate.h new file mode 120000 index 00000000..8979cc56 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTBridgeDelegate.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Base/RCTBridgeDelegate.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTBridgeMethod.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTBridgeMethod.h new file mode 120000 index 00000000..65bc18f4 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTBridgeMethod.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Base/RCTBridgeMethod.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTBridgeModule.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTBridgeModule.h new file mode 120000 index 00000000..bd09fff0 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTBridgeModule.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Base/RCTBridgeModule.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTBridgeModuleDecorator.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTBridgeModuleDecorator.h new file mode 120000 index 00000000..17daf67e --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTBridgeModuleDecorator.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Base/RCTBridgeModuleDecorator.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTBridgeProxy+Cxx.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTBridgeProxy+Cxx.h new file mode 120000 index 00000000..318573aa --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTBridgeProxy+Cxx.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Base/RCTBridgeProxy+Cxx.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTBridgeProxy.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTBridgeProxy.h new file mode 120000 index 00000000..8fce6f58 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTBridgeProxy.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Base/RCTBridgeProxy.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTBundleManager.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTBundleManager.h new file mode 120000 index 00000000..af7e3f8f --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTBundleManager.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Base/RCTBundleManager.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTBundleURLProvider.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTBundleURLProvider.h new file mode 120000 index 00000000..80050554 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTBundleURLProvider.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Base/RCTBundleURLProvider.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTCallInvoker.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTCallInvoker.h new file mode 120000 index 00000000..62e089cd --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTCallInvoker.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Base/RCTCallInvoker.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTCallInvokerModule.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTCallInvokerModule.h new file mode 120000 index 00000000..ca6605d8 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTCallInvokerModule.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Base/RCTCallInvokerModule.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTClipboard.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTClipboard.h new file mode 120000 index 00000000..efac870f --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTClipboard.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/CoreModules/RCTClipboard.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTColorSpaceUtils.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTColorSpaceUtils.h new file mode 120000 index 00000000..3b813012 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTColorSpaceUtils.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Fabric/Utils/RCTColorSpaceUtils.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTComponent.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTComponent.h new file mode 120000 index 00000000..39a6df95 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTComponent.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Views/RCTComponent.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTComponentData.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTComponentData.h new file mode 120000 index 00000000..ccaa508d --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTComponentData.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Views/RCTComponentData.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTComponentEvent.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTComponentEvent.h new file mode 120000 index 00000000..c21c339b --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTComponentEvent.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Base/RCTComponentEvent.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTComponentViewClassDescriptor.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTComponentViewClassDescriptor.h new file mode 120000 index 00000000..3b2aa74c --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTComponentViewClassDescriptor.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Fabric/Mounting/RCTComponentViewClassDescriptor.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTComponentViewDescriptor.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTComponentViewDescriptor.h new file mode 120000 index 00000000..8d079064 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTComponentViewDescriptor.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Fabric/Mounting/RCTComponentViewDescriptor.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTComponentViewFactory.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTComponentViewFactory.h new file mode 120000 index 00000000..29940df9 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTComponentViewFactory.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Fabric/Mounting/RCTComponentViewFactory.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTComponentViewProtocol.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTComponentViewProtocol.h new file mode 120000 index 00000000..c4f4bd9a --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTComponentViewProtocol.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Fabric/Mounting/RCTComponentViewProtocol.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTComponentViewRegistry.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTComponentViewRegistry.h new file mode 120000 index 00000000..f09c6f18 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTComponentViewRegistry.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Fabric/Mounting/RCTComponentViewRegistry.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTConstants.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTConstants.h new file mode 120000 index 00000000..0afa68fe --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTConstants.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Base/RCTConstants.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTConversions.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTConversions.h new file mode 120000 index 00000000..14637a48 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTConversions.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Fabric/RCTConversions.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTConvert+CoreLocation.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTConvert+CoreLocation.h new file mode 120000 index 00000000..8ac27cf2 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTConvert+CoreLocation.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Views/RCTConvert+CoreLocation.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTConvert+Transform.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTConvert+Transform.h new file mode 120000 index 00000000..05a998cd --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTConvert+Transform.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Views/RCTConvert+Transform.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTConvert.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTConvert.h new file mode 120000 index 00000000..e9249b58 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTConvert.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Base/RCTConvert.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTCursor.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTCursor.h new file mode 120000 index 00000000..7fb32559 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTCursor.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Views/RCTCursor.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTCustomPullToRefreshViewProtocol.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTCustomPullToRefreshViewProtocol.h new file mode 120000 index 00000000..d80cfca3 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTCustomPullToRefreshViewProtocol.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTCustomPullToRefreshViewProtocol.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTCxxBridgeDelegate.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTCxxBridgeDelegate.h new file mode 120000 index 00000000..8e83a443 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTCxxBridgeDelegate.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/CxxBridge/RCTCxxBridgeDelegate.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTCxxConvert.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTCxxConvert.h new file mode 120000 index 00000000..bf382486 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTCxxConvert.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Base/RCTCxxConvert.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTCxxInspectorPackagerConnection.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTCxxInspectorPackagerConnection.h new file mode 120000 index 00000000..c3b01d68 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTCxxInspectorPackagerConnection.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Inspector/RCTCxxInspectorPackagerConnection.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTCxxInspectorPackagerConnectionDelegate.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTCxxInspectorPackagerConnectionDelegate.h new file mode 120000 index 00000000..ee0eb84f --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTCxxInspectorPackagerConnectionDelegate.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Inspector/RCTCxxInspectorPackagerConnectionDelegate.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTCxxInspectorWebSocketAdapter.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTCxxInspectorWebSocketAdapter.h new file mode 120000 index 00000000..89f41b17 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTCxxInspectorWebSocketAdapter.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Inspector/RCTCxxInspectorWebSocketAdapter.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTCxxMethod.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTCxxMethod.h new file mode 120000 index 00000000..83ad72a8 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTCxxMethod.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/CxxModule/RCTCxxMethod.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTCxxModule.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTCxxModule.h new file mode 120000 index 00000000..5b4e596c --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTCxxModule.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/CxxModule/RCTCxxModule.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTCxxUtils.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTCxxUtils.h new file mode 120000 index 00000000..68e084d5 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTCxxUtils.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/CxxModule/RCTCxxUtils.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTDebuggingOverlay.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTDebuggingOverlay.h new file mode 120000 index 00000000..41fd3203 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTDebuggingOverlay.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Views/RCTDebuggingOverlay.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTDebuggingOverlayComponentView.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTDebuggingOverlayComponentView.h new file mode 120000 index 00000000..456db2be --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTDebuggingOverlayComponentView.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Fabric/Mounting/ComponentViews/DebuggingOverlay/RCTDebuggingOverlayComponentView.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTDebuggingOverlayManager.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTDebuggingOverlayManager.h new file mode 120000 index 00000000..62dbb4ee --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTDebuggingOverlayManager.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Views/RCTDebuggingOverlayManager.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTDefaultCxxLogFunction.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTDefaultCxxLogFunction.h new file mode 120000 index 00000000..8d1700c9 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTDefaultCxxLogFunction.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/CxxLogUtils/RCTDefaultCxxLogFunction.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTDefines.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTDefines.h new file mode 120000 index 00000000..c110f184 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTDefines.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Base/RCTDefines.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTDevLoadingView.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTDevLoadingView.h new file mode 120000 index 00000000..6262d19d --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTDevLoadingView.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/CoreModules/RCTDevLoadingView.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTDevLoadingViewProtocol.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTDevLoadingViewProtocol.h new file mode 120000 index 00000000..4abf72f4 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTDevLoadingViewProtocol.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/DevSupport/RCTDevLoadingViewProtocol.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTDevLoadingViewSetEnabled.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTDevLoadingViewSetEnabled.h new file mode 120000 index 00000000..b2aa6834 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTDevLoadingViewSetEnabled.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/DevSupport/RCTDevLoadingViewSetEnabled.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTDevMenu.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTDevMenu.h new file mode 120000 index 00000000..96ccda37 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTDevMenu.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/CoreModules/RCTDevMenu.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTDevSettings.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTDevSettings.h new file mode 120000 index 00000000..4a07766b --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTDevSettings.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/CoreModules/RCTDevSettings.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTDevToolsRuntimeSettingsModule.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTDevToolsRuntimeSettingsModule.h new file mode 120000 index 00000000..f2b17060 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTDevToolsRuntimeSettingsModule.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/CoreModules/RCTDevToolsRuntimeSettingsModule.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTDeviceInfo.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTDeviceInfo.h new file mode 120000 index 00000000..50d9f3c1 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTDeviceInfo.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/CoreModules/RCTDeviceInfo.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTDisplayLink.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTDisplayLink.h new file mode 120000 index 00000000..97be7a74 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTDisplayLink.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Base/RCTDisplayLink.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTEnhancedScrollView.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTEnhancedScrollView.h new file mode 120000 index 00000000..e0d864ef --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTEnhancedScrollView.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTEnhancedScrollView.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTErrorCustomizer.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTErrorCustomizer.h new file mode 120000 index 00000000..9e8d919d --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTErrorCustomizer.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Base/RCTErrorCustomizer.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTErrorInfo.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTErrorInfo.h new file mode 120000 index 00000000..a27e528d --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTErrorInfo.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Base/RCTErrorInfo.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTEventDispatcher.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTEventDispatcher.h new file mode 120000 index 00000000..9696cdea --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTEventDispatcher.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/CoreModules/RCTEventDispatcher.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTEventDispatcherProtocol.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTEventDispatcherProtocol.h new file mode 120000 index 00000000..d9f6051b --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTEventDispatcherProtocol.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Base/RCTEventDispatcherProtocol.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTEventEmitter.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTEventEmitter.h new file mode 120000 index 00000000..b0f633eb --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTEventEmitter.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Modules/RCTEventEmitter.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTExceptionsManager.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTExceptionsManager.h new file mode 120000 index 00000000..52d78ec4 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTExceptionsManager.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/CoreModules/RCTExceptionsManager.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTFPSGraph.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTFPSGraph.h new file mode 120000 index 00000000..d2d1af1b --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTFPSGraph.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/CoreModules/RCTFPSGraph.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTFabricComponentsPlugins.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTFabricComponentsPlugins.h new file mode 120000 index 00000000..23d62a71 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTFabricComponentsPlugins.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Fabric/Mounting/ComponentViews/RCTFabricComponentsPlugins.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTFabricModalHostViewController.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTFabricModalHostViewController.h new file mode 120000 index 00000000..1e82378b --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTFabricModalHostViewController.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Fabric/Mounting/ComponentViews/Modal/RCTFabricModalHostViewController.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTFabricSurface.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTFabricSurface.h new file mode 120000 index 00000000..086d1195 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTFabricSurface.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Fabric/Surface/RCTFabricSurface.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTFollyConvert.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTFollyConvert.h new file mode 120000 index 00000000..59cc05e3 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTFollyConvert.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/CxxUtils/RCTFollyConvert.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTFont.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTFont.h new file mode 120000 index 00000000..fd02418a --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTFont.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Views/RCTFont.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTFrameUpdate.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTFrameUpdate.h new file mode 120000 index 00000000..adf3db6e --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTFrameUpdate.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Base/RCTFrameUpdate.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTGenericDelegateSplitter.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTGenericDelegateSplitter.h new file mode 120000 index 00000000..f1b58f3d --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTGenericDelegateSplitter.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Fabric/Utils/RCTGenericDelegateSplitter.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTHermesInstanceFactory.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTHermesInstanceFactory.h new file mode 120000 index 00000000..793378fe --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTHermesInstanceFactory.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Runtime/RCTHermesInstanceFactory.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTI18nManager.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTI18nManager.h new file mode 120000 index 00000000..ef3b23ee --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTI18nManager.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/CoreModules/RCTI18nManager.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTI18nUtil.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTI18nUtil.h new file mode 120000 index 00000000..99a852f8 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTI18nUtil.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Modules/RCTI18nUtil.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTIdentifierPool.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTIdentifierPool.h new file mode 120000 index 00000000..ab3096a9 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTIdentifierPool.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Fabric/Utils/RCTIdentifierPool.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTImageComponentView.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTImageComponentView.h new file mode 120000 index 00000000..76439343 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTImageComponentView.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Fabric/Mounting/ComponentViews/Image/RCTImageComponentView.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTImageResponseDelegate.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTImageResponseDelegate.h new file mode 120000 index 00000000..15803a3a --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTImageResponseDelegate.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Fabric/RCTImageResponseDelegate.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTImageResponseObserverProxy.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTImageResponseObserverProxy.h new file mode 120000 index 00000000..44f3699d --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTImageResponseObserverProxy.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Fabric/RCTImageResponseObserverProxy.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTImageSource.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTImageSource.h new file mode 120000 index 00000000..d8afa9ce --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTImageSource.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Base/RCTImageSource.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTInitialAccessibilityValuesProxy.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTInitialAccessibilityValuesProxy.h new file mode 120000 index 00000000..1947ec4e --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTInitialAccessibilityValuesProxy.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Base/UIKitProxies/RCTInitialAccessibilityValuesProxy.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTInitializeUIKitProxies.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTInitializeUIKitProxies.h new file mode 120000 index 00000000..2b6cfff9 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTInitializeUIKitProxies.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Base/UIKitProxies/RCTInitializeUIKitProxies.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTInitializing.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTInitializing.h new file mode 120000 index 00000000..20e31e1b --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTInitializing.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Base/RCTInitializing.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTInputAccessoryComponentView.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTInputAccessoryComponentView.h new file mode 120000 index 00000000..040b979e --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTInputAccessoryComponentView.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Fabric/Mounting/ComponentViews/InputAccessory/RCTInputAccessoryComponentView.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTInputAccessoryContentView.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTInputAccessoryContentView.h new file mode 120000 index 00000000..8fa0b9c9 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTInputAccessoryContentView.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Fabric/Mounting/ComponentViews/InputAccessory/RCTInputAccessoryContentView.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTInspector.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTInspector.h new file mode 120000 index 00000000..9adf3d58 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTInspector.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Inspector/RCTInspector.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTInspectorDevServerHelper.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTInspectorDevServerHelper.h new file mode 120000 index 00000000..9f0caed6 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTInspectorDevServerHelper.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/DevSupport/RCTInspectorDevServerHelper.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTInspectorNetworkHelper.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTInspectorNetworkHelper.h new file mode 120000 index 00000000..accb397f --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTInspectorNetworkHelper.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/DevSupport/RCTInspectorNetworkHelper.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTInspectorPackagerConnection.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTInspectorPackagerConnection.h new file mode 120000 index 00000000..57f67234 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTInspectorPackagerConnection.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Inspector/RCTInspectorPackagerConnection.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTInspectorUtils.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTInspectorUtils.h new file mode 120000 index 00000000..5ace0f8e --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTInspectorUtils.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/DevSupport/RCTInspectorUtils.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTInvalidating.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTInvalidating.h new file mode 120000 index 00000000..f37c021d --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTInvalidating.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Base/RCTInvalidating.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTJSIExecutorRuntimeInstaller.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTJSIExecutorRuntimeInstaller.h new file mode 120000 index 00000000..7e590b5e --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTJSIExecutorRuntimeInstaller.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/CxxBridge/RCTJSIExecutorRuntimeInstaller.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTJSStackFrame.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTJSStackFrame.h new file mode 120000 index 00000000..fc68f36a --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTJSStackFrame.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Base/RCTJSStackFrame.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTJSThread.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTJSThread.h new file mode 120000 index 00000000..6efe2fda --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTJSThread.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Base/RCTJSThread.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTJavaScriptExecutor.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTJavaScriptExecutor.h new file mode 120000 index 00000000..001eea3e --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTJavaScriptExecutor.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Base/RCTJavaScriptExecutor.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTJavaScriptLoader.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTJavaScriptLoader.h new file mode 120000 index 00000000..1765eccd --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTJavaScriptLoader.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Base/RCTJavaScriptLoader.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTJscInstanceFactory.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTJscInstanceFactory.h new file mode 120000 index 00000000..01ea11e0 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTJscInstanceFactory.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Runtime/RCTJscInstanceFactory.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTKeyCommands.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTKeyCommands.h new file mode 120000 index 00000000..c0cb097e --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTKeyCommands.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Base/RCTKeyCommands.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTKeyWindowValuesProxy.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTKeyWindowValuesProxy.h new file mode 120000 index 00000000..a49b038f --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTKeyWindowValuesProxy.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Base/UIKitProxies/RCTKeyWindowValuesProxy.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTKeyboardObserver.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTKeyboardObserver.h new file mode 120000 index 00000000..54d6ad2a --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTKeyboardObserver.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/CoreModules/RCTKeyboardObserver.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTLayout.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTLayout.h new file mode 120000 index 00000000..37be2b15 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTLayout.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Views/RCTLayout.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTLayoutAnimation.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTLayoutAnimation.h new file mode 120000 index 00000000..9a8df757 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTLayoutAnimation.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Modules/RCTLayoutAnimation.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTLayoutAnimationGroup.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTLayoutAnimationGroup.h new file mode 120000 index 00000000..3f5c1bfe --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTLayoutAnimationGroup.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Modules/RCTLayoutAnimationGroup.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTLegacyViewManagerInteropComponentView.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTLegacyViewManagerInteropComponentView.h new file mode 120000 index 00000000..01af3385 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTLegacyViewManagerInteropComponentView.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Fabric/Mounting/ComponentViews/LegacyViewManagerInterop/RCTLegacyViewManagerInteropComponentView.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTLegacyViewManagerInteropCoordinatorAdapter.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTLegacyViewManagerInteropCoordinatorAdapter.h new file mode 120000 index 00000000..8294e00a --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTLegacyViewManagerInteropCoordinatorAdapter.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Fabric/Mounting/ComponentViews/LegacyViewManagerInterop/RCTLegacyViewManagerInteropCoordinatorAdapter.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTLinearGradient.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTLinearGradient.h new file mode 120000 index 00000000..28f1a635 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTLinearGradient.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Fabric/Utils/RCTLinearGradient.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTLocalizationProvider.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTLocalizationProvider.h new file mode 120000 index 00000000..4adce8f1 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTLocalizationProvider.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Fabric/RCTLocalizationProvider.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTLocalizedString.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTLocalizedString.h new file mode 120000 index 00000000..e4bac855 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTLocalizedString.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/I18n/RCTLocalizedString.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTLog.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTLog.h new file mode 120000 index 00000000..267190ea --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTLog.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Base/RCTLog.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTLogBox.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTLogBox.h new file mode 120000 index 00000000..6668153b --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTLogBox.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/CoreModules/RCTLogBox.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTLogBoxView.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTLogBoxView.h new file mode 120000 index 00000000..832a5d52 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTLogBoxView.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/CoreModules/RCTLogBoxView.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTMacros.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTMacros.h new file mode 120000 index 00000000..46bc7b19 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTMacros.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Profiler/RCTMacros.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTManagedPointer.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTManagedPointer.h new file mode 120000 index 00000000..df653dfb --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTManagedPointer.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Base/RCTManagedPointer.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTMessageThread.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTMessageThread.h new file mode 120000 index 00000000..2004327b --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTMessageThread.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/CxxBridge/RCTMessageThread.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTMockDef.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTMockDef.h new file mode 120000 index 00000000..1a96d073 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTMockDef.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Base/RCTMockDef.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTModalHostView.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTModalHostView.h new file mode 120000 index 00000000..806762e1 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTModalHostView.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Views/RCTModalHostView.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTModalHostViewComponentView.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTModalHostViewComponentView.h new file mode 120000 index 00000000..a3999681 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTModalHostViewComponentView.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Fabric/Mounting/ComponentViews/Modal/RCTModalHostViewComponentView.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTModalHostViewController.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTModalHostViewController.h new file mode 120000 index 00000000..960fd9e8 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTModalHostViewController.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Views/RCTModalHostViewController.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTModalHostViewManager.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTModalHostViewManager.h new file mode 120000 index 00000000..c4e74eb5 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTModalHostViewManager.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Views/RCTModalHostViewManager.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTModalManager.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTModalManager.h new file mode 120000 index 00000000..8efc3b75 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTModalManager.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Views/RCTModalManager.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTModuleData.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTModuleData.h new file mode 120000 index 00000000..ad55da71 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTModuleData.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Base/RCTModuleData.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTModuleMethod.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTModuleMethod.h new file mode 120000 index 00000000..d9e2a3a3 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTModuleMethod.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Base/RCTModuleMethod.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTMountingManager.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTMountingManager.h new file mode 120000 index 00000000..6cee924b --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTMountingManager.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Fabric/Mounting/RCTMountingManager.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTMountingManagerDelegate.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTMountingManagerDelegate.h new file mode 120000 index 00000000..06b15677 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTMountingManagerDelegate.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Fabric/Mounting/RCTMountingManagerDelegate.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTMountingTransactionObserverCoordinator.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTMountingTransactionObserverCoordinator.h new file mode 120000 index 00000000..8f180636 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTMountingTransactionObserverCoordinator.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Fabric/Mounting/RCTMountingTransactionObserverCoordinator.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTMountingTransactionObserving.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTMountingTransactionObserving.h new file mode 120000 index 00000000..eef4591b --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTMountingTransactionObserving.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Fabric/Mounting/RCTMountingTransactionObserving.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTMultipartDataTask.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTMultipartDataTask.h new file mode 120000 index 00000000..1e83b285 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTMultipartDataTask.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Base/RCTMultipartDataTask.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTMultipartStreamReader.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTMultipartStreamReader.h new file mode 120000 index 00000000..c0cf2ce0 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTMultipartStreamReader.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Base/RCTMultipartStreamReader.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTNativeModule.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTNativeModule.h new file mode 120000 index 00000000..aee11e40 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTNativeModule.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/CxxModule/RCTNativeModule.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTNullability.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTNullability.h new file mode 120000 index 00000000..1a2839a4 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTNullability.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Base/RCTNullability.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTObjcExecutor.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTObjcExecutor.h new file mode 120000 index 00000000..4b4c4f33 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTObjcExecutor.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/CxxBridge/RCTObjcExecutor.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTPLTag.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTPLTag.h new file mode 120000 index 00000000..9bc0a1fc --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTPLTag.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Base/RCTPLTag.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTPackagerClient.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTPackagerClient.h new file mode 120000 index 00000000..9bb4396f --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTPackagerClient.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/DevSupport/RCTPackagerClient.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTPackagerConnection.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTPackagerConnection.h new file mode 120000 index 00000000..50cfd7a0 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTPackagerConnection.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/DevSupport/RCTPackagerConnection.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTParagraphComponentAccessibilityProvider.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTParagraphComponentAccessibilityProvider.h new file mode 120000 index 00000000..13bb343d --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTParagraphComponentAccessibilityProvider.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentAccessibilityProvider.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTParagraphComponentView.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTParagraphComponentView.h new file mode 120000 index 00000000..1e6d9742 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTParagraphComponentView.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTParserUtils.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTParserUtils.h new file mode 120000 index 00000000..4462568a --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTParserUtils.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Base/RCTParserUtils.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTPausedInDebuggerOverlayController.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTPausedInDebuggerOverlayController.h new file mode 120000 index 00000000..c39ecbc6 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTPausedInDebuggerOverlayController.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/DevSupport/RCTPausedInDebuggerOverlayController.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTPerformanceLogger.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTPerformanceLogger.h new file mode 120000 index 00000000..af1d2be7 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTPerformanceLogger.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Base/RCTPerformanceLogger.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTPerformanceLoggerLabels.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTPerformanceLoggerLabels.h new file mode 120000 index 00000000..d7aa72e4 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTPerformanceLoggerLabels.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Base/RCTPerformanceLoggerLabels.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTPlatform.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTPlatform.h new file mode 120000 index 00000000..b9b46d6f --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTPlatform.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/CoreModules/RCTPlatform.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTPointerEvents.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTPointerEvents.h new file mode 120000 index 00000000..c4990bb4 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTPointerEvents.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Views/RCTPointerEvents.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTPrimitives.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTPrimitives.h new file mode 120000 index 00000000..2e6c2b47 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTPrimitives.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Fabric/RCTPrimitives.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTProfile.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTProfile.h new file mode 120000 index 00000000..fa87538b --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTProfile.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Profiler/RCTProfile.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTPullToRefreshViewComponentView.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTPullToRefreshViewComponentView.h new file mode 120000 index 00000000..bafe25d2 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTPullToRefreshViewComponentView.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTPullToRefreshViewComponentView.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTReactTaggedView.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTReactTaggedView.h new file mode 120000 index 00000000..4613e63c --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTReactTaggedView.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Fabric/Utils/RCTReactTaggedView.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTRedBox.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTRedBox.h new file mode 120000 index 00000000..e946e5ea --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTRedBox.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/CoreModules/RCTRedBox.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTRedBoxExtraDataViewController.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTRedBoxExtraDataViewController.h new file mode 120000 index 00000000..60fcd20d --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTRedBoxExtraDataViewController.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Modules/RCTRedBoxExtraDataViewController.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTRedBoxSetEnabled.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTRedBoxSetEnabled.h new file mode 120000 index 00000000..e2893e3d --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTRedBoxSetEnabled.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Base/RCTRedBoxSetEnabled.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTRefreshControl.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTRefreshControl.h new file mode 120000 index 00000000..0b512b2c --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTRefreshControl.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTRefreshControlManager.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTRefreshControlManager.h new file mode 120000 index 00000000..37c35a09 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTRefreshControlManager.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControlManager.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTRefreshableProtocol.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTRefreshableProtocol.h new file mode 120000 index 00000000..1f1c4adc --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTRefreshableProtocol.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Views/RefreshControl/RCTRefreshableProtocol.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTReloadCommand.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTReloadCommand.h new file mode 120000 index 00000000..7023154a --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTReloadCommand.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Base/RCTReloadCommand.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTRootComponentView.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTRootComponentView.h new file mode 120000 index 00000000..4bc7b787 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTRootComponentView.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Fabric/Mounting/ComponentViews/Root/RCTRootComponentView.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTRootContentView.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTRootContentView.h new file mode 120000 index 00000000..544e18e1 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTRootContentView.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Base/RCTRootContentView.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTRootShadowView.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTRootShadowView.h new file mode 120000 index 00000000..c3a170f6 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTRootShadowView.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Views/RCTRootShadowView.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTRootView.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTRootView.h new file mode 120000 index 00000000..872aa192 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTRootView.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Base/RCTRootView.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTRootViewDelegate.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTRootViewDelegate.h new file mode 120000 index 00000000..1b87f136 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTRootViewDelegate.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Base/RCTRootViewDelegate.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTRootViewInternal.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTRootViewInternal.h new file mode 120000 index 00000000..e955816f --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTRootViewInternal.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Base/RCTRootViewInternal.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTSafeAreaShadowView.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTSafeAreaShadowView.h new file mode 120000 index 00000000..ae2ffe75 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTSafeAreaShadowView.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Views/SafeAreaView/RCTSafeAreaShadowView.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTSafeAreaView.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTSafeAreaView.h new file mode 120000 index 00000000..a7f1578f --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTSafeAreaView.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Views/SafeAreaView/RCTSafeAreaView.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTSafeAreaViewComponentView.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTSafeAreaViewComponentView.h new file mode 120000 index 00000000..1d85d075 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTSafeAreaViewComponentView.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Fabric/Mounting/ComponentViews/SafeAreaView/RCTSafeAreaViewComponentView.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTSafeAreaViewLocalData.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTSafeAreaViewLocalData.h new file mode 120000 index 00000000..c0f43c89 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTSafeAreaViewLocalData.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Views/SafeAreaView/RCTSafeAreaViewLocalData.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTSafeAreaViewManager.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTSafeAreaViewManager.h new file mode 120000 index 00000000..eae9f878 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTSafeAreaViewManager.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Views/SafeAreaView/RCTSafeAreaViewManager.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTScheduler.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTScheduler.h new file mode 120000 index 00000000..75e82c5b --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTScheduler.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Fabric/RCTScheduler.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTScrollContentShadowView.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTScrollContentShadowView.h new file mode 120000 index 00000000..bd993ec3 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTScrollContentShadowView.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Views/ScrollView/RCTScrollContentShadowView.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTScrollContentView.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTScrollContentView.h new file mode 120000 index 00000000..3ccf4fdb --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTScrollContentView.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Views/ScrollView/RCTScrollContentView.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTScrollContentViewManager.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTScrollContentViewManager.h new file mode 120000 index 00000000..1411bd94 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTScrollContentViewManager.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Views/ScrollView/RCTScrollContentViewManager.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTScrollEvent.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTScrollEvent.h new file mode 120000 index 00000000..2c8f74a5 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTScrollEvent.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Views/ScrollView/RCTScrollEvent.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTScrollView.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTScrollView.h new file mode 120000 index 00000000..9718afe5 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTScrollView.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Views/ScrollView/RCTScrollView.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTScrollViewComponentView.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTScrollViewComponentView.h new file mode 120000 index 00000000..783a3f8b --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTScrollViewComponentView.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTScrollViewManager.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTScrollViewManager.h new file mode 120000 index 00000000..cc4f6fb6 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTScrollViewManager.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Views/ScrollView/RCTScrollViewManager.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTScrollableProtocol.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTScrollableProtocol.h new file mode 120000 index 00000000..a76b3df9 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTScrollableProtocol.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Views/ScrollView/RCTScrollableProtocol.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTShadowView+Internal.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTShadowView+Internal.h new file mode 120000 index 00000000..41a6cf7a --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTShadowView+Internal.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Views/RCTShadowView+Internal.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTShadowView+Layout.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTShadowView+Layout.h new file mode 120000 index 00000000..faf9c6f6 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTShadowView+Layout.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Views/RCTShadowView+Layout.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTShadowView.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTShadowView.h new file mode 120000 index 00000000..424125b9 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTShadowView.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Views/RCTShadowView.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTSourceCode.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTSourceCode.h new file mode 120000 index 00000000..9bef10c0 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTSourceCode.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/CoreModules/RCTSourceCode.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTStatusBarManager.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTStatusBarManager.h new file mode 120000 index 00000000..1a693650 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTStatusBarManager.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/CoreModules/RCTStatusBarManager.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTSurface.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTSurface.h new file mode 120000 index 00000000..c40e3cc6 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTSurface.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Base/Surface/RCTSurface.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTSurfaceDelegate.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTSurfaceDelegate.h new file mode 120000 index 00000000..0e4ea911 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTSurfaceDelegate.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Base/Surface/RCTSurfaceDelegate.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTSurfaceHostingProxyRootView.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTSurfaceHostingProxyRootView.h new file mode 120000 index 00000000..96caacd1 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTSurfaceHostingProxyRootView.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Base/Surface/SurfaceHostingView/RCTSurfaceHostingProxyRootView.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTSurfaceHostingView.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTSurfaceHostingView.h new file mode 120000 index 00000000..bdbe7dad --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTSurfaceHostingView.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Base/Surface/SurfaceHostingView/RCTSurfaceHostingView.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTSurfacePointerHandler.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTSurfacePointerHandler.h new file mode 120000 index 00000000..6cf8f17d --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTSurfacePointerHandler.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Fabric/RCTSurfacePointerHandler.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTSurfacePresenter.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTSurfacePresenter.h new file mode 120000 index 00000000..25dd6296 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTSurfacePresenter.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Fabric/RCTSurfacePresenter.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTSurfacePresenterBridgeAdapter.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTSurfacePresenterBridgeAdapter.h new file mode 120000 index 00000000..25961c08 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTSurfacePresenterBridgeAdapter.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Fabric/RCTSurfacePresenterBridgeAdapter.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTSurfacePresenterStub.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTSurfacePresenterStub.h new file mode 120000 index 00000000..b7f99e7a --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTSurfacePresenterStub.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Modules/RCTSurfacePresenterStub.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTSurfaceProtocol.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTSurfaceProtocol.h new file mode 120000 index 00000000..5ad3a8bb --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTSurfaceProtocol.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Base/Surface/RCTSurfaceProtocol.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTSurfaceRegistry.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTSurfaceRegistry.h new file mode 120000 index 00000000..bea044e0 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTSurfaceRegistry.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Fabric/RCTSurfaceRegistry.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTSurfaceRootShadowView.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTSurfaceRootShadowView.h new file mode 120000 index 00000000..630049d2 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTSurfaceRootShadowView.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Base/Surface/RCTSurfaceRootShadowView.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTSurfaceRootShadowViewDelegate.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTSurfaceRootShadowViewDelegate.h new file mode 120000 index 00000000..9317f36e --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTSurfaceRootShadowViewDelegate.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Base/Surface/RCTSurfaceRootShadowViewDelegate.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTSurfaceRootView.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTSurfaceRootView.h new file mode 120000 index 00000000..beffdb74 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTSurfaceRootView.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Base/Surface/RCTSurfaceRootView.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTSurfaceSizeMeasureMode.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTSurfaceSizeMeasureMode.h new file mode 120000 index 00000000..ecfe0dc3 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTSurfaceSizeMeasureMode.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Base/Surface/SurfaceHostingView/RCTSurfaceSizeMeasureMode.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTSurfaceStage.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTSurfaceStage.h new file mode 120000 index 00000000..cc68a6e0 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTSurfaceStage.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Base/Surface/RCTSurfaceStage.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTSurfaceTouchHandler.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTSurfaceTouchHandler.h new file mode 120000 index 00000000..513b7eb0 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTSurfaceTouchHandler.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Fabric/RCTSurfaceTouchHandler.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTSurfaceView+Internal.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTSurfaceView+Internal.h new file mode 120000 index 00000000..e74785cf --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTSurfaceView+Internal.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Base/Surface/RCTSurfaceView+Internal.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTSurfaceView.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTSurfaceView.h new file mode 120000 index 00000000..97dcb5f0 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTSurfaceView.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Base/Surface/RCTSurfaceView.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTSwitch.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTSwitch.h new file mode 120000 index 00000000..e58e8693 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTSwitch.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Views/RCTSwitch.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTSwitchComponentView.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTSwitchComponentView.h new file mode 120000 index 00000000..879ff741 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTSwitchComponentView.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Fabric/Mounting/ComponentViews/Switch/RCTSwitchComponentView.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTSwitchManager.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTSwitchManager.h new file mode 120000 index 00000000..d8de0940 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTSwitchManager.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Views/RCTSwitchManager.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTTextDecorationLineType.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTTextDecorationLineType.h new file mode 120000 index 00000000..6900c5ec --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTTextDecorationLineType.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Views/RCTTextDecorationLineType.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTTextInputComponentView.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTTextInputComponentView.h new file mode 120000 index 00000000..7d193529 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTTextInputComponentView.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTTextInputNativeCommands.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTTextInputNativeCommands.h new file mode 120000 index 00000000..ee47980c --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTTextInputNativeCommands.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputNativeCommands.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTTextInputUtils.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTTextInputUtils.h new file mode 120000 index 00000000..2e71f247 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTTextInputUtils.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputUtils.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTTiming.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTTiming.h new file mode 120000 index 00000000..c5bf1fd7 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTTiming.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/CoreModules/RCTTiming.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTTouchEvent.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTTouchEvent.h new file mode 120000 index 00000000..f69602b5 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTTouchEvent.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Base/RCTTouchEvent.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTTouchHandler.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTTouchHandler.h new file mode 120000 index 00000000..6c7133f2 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTTouchHandler.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Base/RCTTouchHandler.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTTouchableComponentViewProtocol.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTTouchableComponentViewProtocol.h new file mode 120000 index 00000000..74df4630 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTTouchableComponentViewProtocol.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Fabric/RCTTouchableComponentViewProtocol.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTTraitCollectionProxy.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTTraitCollectionProxy.h new file mode 120000 index 00000000..de03ecea --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTTraitCollectionProxy.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Base/UIKitProxies/RCTTraitCollectionProxy.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTTurboModuleRegistry.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTTurboModuleRegistry.h new file mode 120000 index 00000000..882c61a6 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTTurboModuleRegistry.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Base/RCTTurboModuleRegistry.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTUIManager.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTUIManager.h new file mode 120000 index 00000000..0acbd4a8 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTUIManager.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Modules/RCTUIManager.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTUIManagerObserverCoordinator.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTUIManagerObserverCoordinator.h new file mode 120000 index 00000000..3fc372f1 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTUIManagerObserverCoordinator.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Modules/RCTUIManagerObserverCoordinator.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTUIManagerUtils.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTUIManagerUtils.h new file mode 120000 index 00000000..1cd05ab6 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTUIManagerUtils.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Modules/RCTUIManagerUtils.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTURLRequestDelegate.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTURLRequestDelegate.h new file mode 120000 index 00000000..92814c21 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTURLRequestDelegate.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Base/RCTURLRequestDelegate.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTURLRequestHandler.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTURLRequestHandler.h new file mode 120000 index 00000000..3f4d9c9c --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTURLRequestHandler.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Base/RCTURLRequestHandler.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTUnimplementedNativeComponentView.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTUnimplementedNativeComponentView.h new file mode 120000 index 00000000..895cdc1c --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTUnimplementedNativeComponentView.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Fabric/Mounting/ComponentViews/UnimplementedComponent/RCTUnimplementedNativeComponentView.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTUnimplementedViewComponentView.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTUnimplementedViewComponentView.h new file mode 120000 index 00000000..e795a0e8 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTUnimplementedViewComponentView.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Fabric/Mounting/ComponentViews/UnimplementedView/RCTUnimplementedViewComponentView.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTUtils.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTUtils.h new file mode 120000 index 00000000..99322be6 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTUtils.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Base/RCTUtils.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTUtilsUIOverride.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTUtilsUIOverride.h new file mode 120000 index 00000000..2e3a08d6 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTUtilsUIOverride.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Base/RCTUtilsUIOverride.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTVersion.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTVersion.h new file mode 120000 index 00000000..8debcde0 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTVersion.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Base/RCTVersion.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTView.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTView.h new file mode 120000 index 00000000..5f7f8c1f --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTView.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Views/RCTView.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTViewComponentView.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTViewComponentView.h new file mode 120000 index 00000000..35f9e356 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTViewComponentView.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTViewFinder.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTViewFinder.h new file mode 120000 index 00000000..c7bc3490 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTViewFinder.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Fabric/Utils/RCTViewFinder.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTViewManager.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTViewManager.h new file mode 120000 index 00000000..161f6c7f --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTViewManager.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Views/RCTViewManager.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTViewUtils.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTViewUtils.h new file mode 120000 index 00000000..f1305838 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTViewUtils.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Views/RCTViewUtils.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTWebSocketModule.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTWebSocketModule.h new file mode 120000 index 00000000..e2da3f08 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTWebSocketModule.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/CoreModules/RCTWebSocketModule.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTWindowSafeAreaProxy.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTWindowSafeAreaProxy.h new file mode 120000 index 00000000..96631441 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTWindowSafeAreaProxy.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Base/UIKitProxies/RCTWindowSafeAreaProxy.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/RCTWrapperViewController.h b/apps/mobile/ios/ReactFlatHeaders/React/RCTWrapperViewController.h new file mode 120000 index 00000000..27b58c7a --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/RCTWrapperViewController.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Views/RCTWrapperViewController.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/UIView+ComponentViewProtocol.h b/apps/mobile/ios/ReactFlatHeaders/React/UIView+ComponentViewProtocol.h new file mode 120000 index 00000000..941cef79 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/UIView+ComponentViewProtocol.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Fabric/Mounting/UIView+ComponentViewProtocol.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/UIView+Private.h b/apps/mobile/ios/ReactFlatHeaders/React/UIView+Private.h new file mode 120000 index 00000000..ac073d12 --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/UIView+Private.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Views/UIView+Private.h \ No newline at end of file diff --git a/apps/mobile/ios/ReactFlatHeaders/React/UIView+React.h b/apps/mobile/ios/ReactFlatHeaders/React/UIView+React.h new file mode 120000 index 00000000..79aa303e --- /dev/null +++ b/apps/mobile/ios/ReactFlatHeaders/React/UIView+React.h @@ -0,0 +1 @@ +/Users/z/work/lux/exchange/node_modules/react-native/React/Views/UIView+React.h \ No newline at end of file diff --git a/apps/mobile/ios/Shared/ActionButtons.swift b/apps/mobile/ios/Shared/ActionButtons.swift new file mode 100644 index 00000000..3fc90067 --- /dev/null +++ b/apps/mobile/ios/Shared/ActionButtons.swift @@ -0,0 +1,143 @@ +// +// ActionButtons.swift +<<<<<<< HEAD +// Lux +======= +// Uniswap +>>>>>>> upstream/main +// +// Created by Mateusz Łopaciński on 05/06/2024. +// + +import SwiftUI + +enum ActionButtonStatus { + case success, neutral +} + +struct ActionButton: View { + var action: () -> Void + var text: String + var icon: Icon? + var status: ActionButtonStatus = .neutral + + var body: some View { + var iconColor: Color + var textColor: Color + + switch status { + case .success: + iconColor = Colors.statusSuccess + textColor = Colors.statusSuccess + case .neutral: + iconColor = Colors.neutral2 + textColor = Colors.neutral1 + } + + return Button(action: action) { + HStack(alignment: .center, spacing: 4) { + if let icon = icon { + icon + .fill(iconColor) + .frame(width: 20, height: 20) + } + Text(text) + .foregroundColor(textColor) + .font(Font(UIFont(name: "BaselGrotesk-Book", size: 16)!)) + } + } + .padding(EdgeInsets(top: 8, leading: icon != nil ? 12 : 16, bottom: 8, trailing: 16)) + .background(Colors.surface1) + .cornerRadius(16) + .overlay( + RoundedRectangle(cornerRadius: 16) + .stroke(Colors.surface3, lineWidth: 1) + ) + .shadow(color: Colors.surface3.opacity(0.04), radius: 10) + } +} + +struct CopyButton: View { + // Time after which the button reverts to the copy state + private let copyTimeout: TimeInterval = 2.0 + + @State private var isCopied = false + @State private var copyTimer: Timer? + + var copyButtonText: String + var copiedButtonText: String + var textToCopy: String + + var body: some View { + ActionButton( + action: { + UIPasteboard.general.string = textToCopy + isCopied = true + + // Invalidate the previous timer if it exists + copyTimer?.invalidate() + + // Start a new timer + copyTimer = Timer.scheduledTimer(withTimeInterval: copyTimeout, repeats: false) { _ in + isCopied = false + } + }, + text: isCopied ? copiedButtonText : copyButtonText, + icon: CopyIcon(), + status: isCopied ? .success : .neutral + ) + } +} + +// Similar to CopyButton, but without the text and background. It's just an icon. +struct CopyIconButton: View { + private let copyTimeout: TimeInterval = 2.0 + + @State private var isCopied = false + @State private var copyTimer: Timer? + + var textToCopy: String + + var body: some View { + let iconColor = isCopied ? Colors.statusSuccess : Colors.neutral2 + + return Button(action: { + UIPasteboard.general.string = textToCopy + isCopied = true + copyTimer?.invalidate() + copyTimer = Timer.scheduledTimer(withTimeInterval: copyTimeout, repeats: false) { _ in + isCopied = false + } + }) { + CopyIconOutline() + .fill(iconColor) + .frame(width: 21, height: 20) + } + } +} + +struct PasteButton: View { + var pasteButtonText: String + var onPaste: (String) -> Void + var onPasteStart: () -> Void + var onPasteEnd: () -> Void + + var body: some View { + ActionButton( + action: { + let debounceTime = 0.1 + onPasteStart() // Call onPasteStart just before accessing the clipboard + DispatchQueue.main.asyncAfter(deadline: .now() + debounceTime) { + if let pastedText = UIPasteboard.general.string { + onPaste(pastedText) + } + DispatchQueue.main.asyncAfter(deadline: .now() + debounceTime) { + onPasteEnd() // Call onPasteEnd after attempting to paste + } + } + }, + text: pasteButtonText, + icon: PasteIcon() + ) + } +} diff --git a/apps/mobile/ios/Shared/RelativeOffsetView.swift b/apps/mobile/ios/Shared/RelativeOffsetView.swift new file mode 100644 index 00000000..2937afe0 --- /dev/null +++ b/apps/mobile/ios/Shared/RelativeOffsetView.swift @@ -0,0 +1,50 @@ +// +// RelativeOffsetView.swift +<<<<<<< HEAD +// Lux +======= +// Uniswap +>>>>>>> upstream/main +// +// Created by Mateusz Łopaciński on 18/06/2024. +// + +import SwiftUI + +struct RelativeOffsetView: View { + var x: CGFloat + var y: CGFloat + var onOffsetCalculated: (CGFloat, CGFloat) -> Void + var content: Content + + @State private var contentSize: CGSize = .zero + + init(x: CGFloat = 0, y: CGFloat = 0, onOffsetCalculated: @escaping (CGFloat, CGFloat) -> Void = { _, _ in }, @ViewBuilder content: () -> Content) { + self.x = x + self.y = y + self.onOffsetCalculated = onOffsetCalculated + self.content = content() + } + + var body: some View { + content + .background( + GeometryReader { geometry in + Color.clear + .onAppear { + let offsetX = x * geometry.size.width + let offsetY = y * geometry.size.height + contentSize = geometry.size + onOffsetCalculated(offsetX, offsetY) + } + .onChange(of: geometry.size) { newSize in + let offsetX = x * newSize.width + let offsetY = y * newSize.height + contentSize = newSize + onOffsetCalculated(offsetX, offsetY) + } + } + ) + .offset(x: x * contentSize.width, y: y * contentSize.height) + } +} diff --git a/apps/mobile/ios/Uniswap.xcodeproj/project.pbxproj b/apps/mobile/ios/Uniswap.xcodeproj/project.pbxproj new file mode 100644 index 00000000..397a58d2 --- /dev/null +++ b/apps/mobile/ios/Uniswap.xcodeproj/project.pbxproj @@ -0,0 +1,4328 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 00E356F31AD99517003FC87E /* UniswapTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 00E356F21AD99517003FC87E /* UniswapTests.m */; }; + 037C5AAA2C04970B00B1D808 /* CopyIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = 037C5AA92C04970B00B1D808 /* CopyIcon.swift */; }; + 038FC04C9385A04F3EA5EBF4 /* TokenQuery.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16F706375DFAFB58F8440CF9 /* TokenQuery.graphql.swift */; }; + 03C788232C10E7390011E5DC /* ActionButtons.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03C788222C10E7390011E5DC /* ActionButtons.swift */; }; + 03D2F3182C218D390030D987 /* RelativeOffsetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03D2F3172C218D380030D987 /* RelativeOffsetView.swift */; }; + 0703EE032A5734A600AED1DA /* UserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0703EE022A5734A600AED1DA /* UserDefaults.swift */; }; + 0703EE052A57351800AED1DA /* Logging.swift in Sources */ = {isa = PBXBuildFile; fileRef = 072F6C372A44BECC00DA720A /* Logging.swift */; }; + 072E238E2A44D5BD006AD6C9 /* WidgetsCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 072E23862A44D5BC006AD6C9 /* WidgetsCore.framework */; }; + 072E23952A44D5BD006AD6C9 /* WidgetsCoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 072E23942A44D5BD006AD6C9 /* WidgetsCoreTests.swift */; }; + 072E23962A44D5BD006AD6C9 /* WidgetsCore.h in Headers */ = {isa = PBXBuildFile; fileRef = 072E23882A44D5BD006AD6C9 /* WidgetsCore.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 072F6C212A44A32E00DA720A /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 072F6C202A44A32E00DA720A /* WidgetKit.framework */; }; + 072F6C262A44A32E00DA720A /* WidgetsBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 072F6C252A44A32E00DA720A /* WidgetsBundle.swift */; }; + 072F6C282A44A32E00DA720A /* TokenPriceWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 072F6C272A44A32E00DA720A /* TokenPriceWidget.swift */; }; + 072F6C2B2A44A32F00DA720A /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 072F6C2A2A44A32F00DA720A /* Assets.xcassets */; }; + 072F6C2D2A44A32F00DA720A /* TokenPriceWidget.intentdefinition in Sources */ = {isa = PBXBuildFile; fileRef = 072F6C292A44A32E00DA720A /* TokenPriceWidget.intentdefinition */; }; + 072F6C2E2A44A32F00DA720A /* TokenPriceWidget.intentdefinition in Sources */ = {isa = PBXBuildFile; fileRef = 072F6C292A44A32E00DA720A /* TokenPriceWidget.intentdefinition */; }; + 072F6C312A44A32F00DA720A /* Widgets.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 072F6C1F2A44A32E00DA720A /* Widgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 07378F492A5C83ED00D26D3E /* IntentHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 078E79492A55EB3300F59CF2 /* IntentHandler.swift */; }; + 074086FA2A703B76006E3053 /* FormatTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 074086F92A703B76006E3053 /* FormatTests.swift */; }; + 0741433E2A588CCC00A157D3 /* TokenPriceWidget.intentdefinition in Sources */ = {isa = PBXBuildFile; fileRef = 072F6C292A44A32E00DA720A /* TokenPriceWidget.intentdefinition */; }; + 074143402A588F5800A157D3 /* Structs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0741433F2A588F5800A157D3 /* Structs.swift */; }; + 074322342A83E3CA00F8518D /* SchemaConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 074321DE2A83E3C900F8518D /* SchemaConfiguration.swift */; }; + 074322402A841BBD00F8518D /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0743223F2A841BBD00F8518D /* Constants.swift */; }; + 0767E0382A65C8330042ADA2 /* Colors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0767E0372A65C8330042ADA2 /* Colors.swift */; }; + 0767E03B2A65D2550042ADA2 /* Styling.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0767E03A2A65D2550042ADA2 /* Styling.swift */; }; + 0781032B76A7F58B5776207B /* FeedTransactionListQuery.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = E21B5EF6BC4582DD2BB0DA13 /* FeedTransactionListQuery.graphql.swift */; }; + 0783F7B42A619E7C009ED617 /* UIComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0783F7B32A619E7C009ED617 /* UIComponents.swift */; }; + 078E79472A55EB3300F59CF2 /* Intents.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 078E79462A55EB3300F59CF2 /* Intents.framework */; }; + 078E794E2A55EB3300F59CF2 /* WidgetIntentExtension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 078E79452A55EB3300F59CF2 /* WidgetIntentExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 07B0676C2A7D6EC8001DD9B9 /* RNWidgets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07B0676A2A7D6EC8001DD9B9 /* RNWidgets.swift */; }; + 07B0676D2A7D6EC8001DD9B9 /* RNWidgets.m in Sources */ = {isa = PBXBuildFile; fileRef = 07B0676B2A7D6EC8001DD9B9 /* RNWidgets.m */; }; + 07F0C28F2A5F3E2E00D5353E /* Env.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07F0C28E2A5F3E2E00D5353E /* Env.swift */; }; + 07F136402A575EC00067004F /* DataQueries.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07F1363F2A575EC00067004F /* DataQueries.swift */; }; + 07F136422A5763480067004F /* Network.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07F136412A5763480067004F /* Network.swift */; }; + 07F5CF712A6AD97D00C648A5 /* Chart.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07F5CF702A6AD97D00C648A5 /* Chart.swift */; }; + 07F5CF752A7020FD00C648A5 /* Format.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07F5CF742A7020FD00C648A5 /* Format.swift */; }; + 0901AAD2DCDD615889B6680A /* TransactionDetails.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47148657FBA8DCC92DD69573 /* TransactionDetails.graphql.swift */; }; + 0DB282262CDADB260014CF77 /* EmbeddedWallet.m in Sources */ = {isa = PBXBuildFile; fileRef = 0DB282242CDADB260014CF77 /* EmbeddedWallet.m */; }; + 0DB282272CDADB260014CF77 /* EmbeddedWallet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0DB282252CDADB260014CF77 /* EmbeddedWallet.swift */; }; + 0DF7B81A4727A8CA063CD5B5 /* SwapOrderDetails.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32764E27B5D9FCF0AA4217CF /* SwapOrderDetails.graphql.swift */; }; + 100A336AFEC40D106F257664 /* OnRampTransfer.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60195493B9C71EDEECA46E80 /* OnRampTransfer.graphql.swift */; }; + 121CD8F92A5E382AD1A84AA9 /* NftBalanceAssetInput.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = C394AC5DB58BDF1C33CC0927 /* NftBalanceAssetInput.graphql.swift */; }; + 12A7E560842C954098B3A558 /* NftsTabQuery.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EA8FE9029A1E7C4EAD7F547 /* NftsTabQuery.graphql.swift */; }; + 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; }; + 1440B371A1C9A42F3E91DAAE /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DF5F26A06553EFDD4D99214 /* ExpoModulesProvider.swift */; }; + 19AB4E8D176A5656BBB2D797 /* TokenProjectsQuery.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1B3A0C152B683215291E3BD /* TokenProjectsQuery.graphql.swift */; }; + 1A4145B8CC5F5F5B9297B9D6 /* NftAsset.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD3573B4E6FD61F5DDBD9C75 /* NftAsset.graphql.swift */; }; + 1B9BE681A85AE55F18054F37 /* NftTransfer.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 792EF7A7820987EA9E99D792 /* NftTransfer.graphql.swift */; }; + 1E01B5593B54A53D822972C1 /* HomeScreenTokenParts.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB3EEA677984132330D8D279 /* HomeScreenTokenParts.graphql.swift */; }; + 1F9BD005762B8BFA302E1B0C /* NftBalanceConnection.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56093E216B82288E1A4018F0 /* NftBalanceConnection.graphql.swift */; }; + 1FB2F652907A72BD28CF6DD4 /* OffRampTransfer.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3063061F7C203A1DA47B8FBB /* OffRampTransfer.graphql.swift */; }; + 213BE982C7E2CCE304B88A6E /* Image.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 284C5150E9B50FB5BE531572 /* Image.graphql.swift */; }; + 23FC1CDFE7B5E25CEDF338F2 /* TokenProjectMarketsParts.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BA467FB84503BB53B081C73 /* TokenProjectMarketsParts.graphql.swift */; }; + 29BC0E8E68D0365EB77456AF /* PortfolioBalancesQuery.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8604CAA8F7E44D704A6F4AAF /* PortfolioBalancesQuery.graphql.swift */; }; + 2C7ED9DF09914F82D85DE7C9 /* NftBalanceEdge.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = F182567B705FC0E15DA8F34A /* NftBalanceEdge.graphql.swift */; }; + 2DE0A41ECCCE673BAE68F715 /* FeeData.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 290B66F38FEC486AF1709BE4 /* FeeData.graphql.swift */; }; + 2FF904EA65F2A7B960547609 /* NftBalancesFilterInput.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = F70F4D05A45BC18EA88868A0 /* NftBalancesFilterInput.graphql.swift */; }; + 333C6D9C267BB7783F9CA56E /* TokenBalanceMainParts.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F0C115796D33739746778E3 /* TokenBalanceMainParts.graphql.swift */; }; + 340A73A44EC57C9376FF24E9 /* SwapOrderType.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A7D41CB7AA0496123E2153D /* SwapOrderType.graphql.swift */; }; + 37A47FF4EEEB8E9D839D5DD6 /* TokenMarket.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = A02D64B698D203A7F3643C18 /* TokenMarket.graphql.swift */; }; + 3B27BBB18E152F19B68D6FAB /* TokenMarketParts.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6754D4BD9CE76AAC915F814 /* TokenMarketParts.graphql.swift */; }; + 44B612C45F986F8ACB66D366 /* Query.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58641E30674C4C4241702902 /* Query.graphql.swift */; }; + 45FFF7DF2E8C2A8100362570 /* SilentPushEventEmitter.m in Sources */ = {isa = PBXBuildFile; fileRef = 45FFF7DE2E8C2A6400362570 /* SilentPushEventEmitter.m */; }; + 45FFF7E12E8C2E6900362570 /* SilentPushEventEmitter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45FFF7E02E8C2E6100362570 /* SilentPushEventEmitter.swift */; }; + 462433873C56042BDAA3D8E1 /* MultiplePortfolioBalancesQuery.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3AF9944E853669C2BC9B5AB /* MultiplePortfolioBalancesQuery.graphql.swift */; }; + 463BA791004B1B7AC1773914 /* Pods_Uniswap.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2226DF79BEAFECEE11A51347 /* Pods_Uniswap.framework */; }; + 498F01286C660E8241774216 /* TokenTransfer.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93783F96FA74CB9A1BEB32EE /* TokenTransfer.graphql.swift */; }; + 4AA5D71F770AA5E8A5AB8D6A /* TokenBalanceParts.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = B97D41BC2760803FD1C7CB3C /* TokenBalanceParts.graphql.swift */; }; + 4D846E92BAEED7A4F5B72919 /* TokenStandard.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE5E0F71E23968DEFCEB7A4B /* TokenStandard.graphql.swift */; }; + 4ECB63464100262E5A163345 /* TokenBalanceQuantityParts.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D5FF4316461BD4FB5847D62 /* TokenBalanceQuantityParts.graphql.swift */; }; + 50F87796A463D0224875F0F4 /* TransactionHistoryUpdaterQuery.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AE6A0CAEDE1DC1D2FA14DEA /* TransactionHistoryUpdaterQuery.graphql.swift */; }; + 530FBF9EB2190828460BD496 /* AmountChange.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43AB42F3ED903CF49D7D4DE7 /* AmountChange.graphql.swift */; }; + 554AB3FB0D09B2DBC870E044 /* TokenDetailsScreenQuery.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7ED83FFCB410FCCA2C6B97B /* TokenDetailsScreenQuery.graphql.swift */; }; + 57D4FACDC13E20AD220D7AEC /* Currency.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5DDE9BCC6A539F6E9AE1664 /* Currency.graphql.swift */; }; + 5A9BA87096096C8BDC5FF210 /* SchemaMetadata.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73D981A2D31B6F8822C19324 /* SchemaMetadata.graphql.swift */; }; + 5B4398EC2DD3B22C00F6BE08 /* PrivateKeyDisplayManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 5B4398E82DD3B22C00F6BE08 /* PrivateKeyDisplayManager.m */; }; + 5B4398ED2DD3B22C00F6BE08 /* PrivateKeyDisplayManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B4398E92DD3B22C00F6BE08 /* PrivateKeyDisplayManager.swift */; }; + 5B4398EE2DD3B22C00F6BE08 /* PrivateKeyDisplayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B4398EA2DD3B22C00F6BE08 /* PrivateKeyDisplayView.swift */; }; + 5B4CEC5F2DD65DD4009F082B /* CopyIconOutline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B4CEC5E2DD65DD4009F082B /* CopyIconOutline.swift */; }; + 5CC5D4B276CC1E3BA1906120 /* NftContract.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A2AA83B4F9F4290A2BC7CCB /* NftContract.graphql.swift */; }; + 5E5E0A632D380F5800E166AA /* Env.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E5E0A622D380F5700E166AA /* Env.swift */; }; + 5F14564B8F6814A85EF53C46 /* TokenSortableField.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D96819BA563CB218D29C657 /* TokenSortableField.graphql.swift */; }; + 5F581541EFD85236EF98D3F3 /* TokenApproval.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 009F4258CE111989223E99A4 /* TokenApproval.graphql.swift */; }; + 5FED383DD705AEFE8B7DA8DC /* TransactionListQuery.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = C27225607FACF638581E25B5 /* TransactionListQuery.graphql.swift */; }; + 63E84504F6D89EFBF97F4ACF /* SelectWalletScreenQuery.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB3CBF221D5D2E3C036A1BA0 /* SelectWalletScreenQuery.graphql.swift */; }; + 649A7A782D9AE70B00B53589 /* KeychainUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649A7A772D9AE70B00B53589 /* KeychainUtils.swift */; }; + 649A7A792D9AE70B00B53589 /* KeychainConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649A7A762D9AE70B00B53589 /* KeychainConstants.swift */; }; + 6602483D8F6C6481FB8128E9 /* HistoryDuration.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC6517FF8972DA448CDD2DFC /* HistoryDuration.graphql.swift */; }; + 6682BC9B8E38430BE82FADDA /* NftBalance.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8BAFEAABF78617095D44E32 /* NftBalance.graphql.swift */; }; + 6B8E2BD6B9A12FF05F826BDB /* TopTokensQuery.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = C050FFEC067334CF05C17EA0 /* TopTokensQuery.graphql.swift */; }; + 6BC7D07E2B5FF02400617C95 /* ScantasticEncryption.m in Sources */ = {isa = PBXBuildFile; fileRef = 6BC7D07B2B5FF02400617C95 /* ScantasticEncryption.m */; }; + 6BC7D07F2B5FF02400617C95 /* ScantasticEncryption.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6BC7D07C2B5FF02400617C95 /* ScantasticEncryption.swift */; }; + 6BC7D0802B5FF02400617C95 /* EncryptionUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6BC7D07D2B5FF02400617C95 /* EncryptionUtils.swift */; }; + 6C8EFC2D2891B99100FBD8EB /* EncryptionHelperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C8EFC2C2891B99100FBD8EB /* EncryptionHelperTests.swift */; }; + 6CA91BDB2A95223C00C4063E /* RNEthersRS.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CA91BD92A95223C00C4063E /* RNEthersRS.swift */; }; + 6CA91BDC2A95223C00C4063E /* RnEthersRS.m in Sources */ = {isa = PBXBuildFile; fileRef = 6CA91BDA2A95223C00C4063E /* RnEthersRS.m */; }; + 6CA91BE12A95226200C4063E /* RNCloudStorageBackupsManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 6CA91BDE2A95226200C4063E /* RNCloudStorageBackupsManager.m */; }; + 6CA91BE22A95226200C4063E /* EncryptionHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CA91BDF2A95226200C4063E /* EncryptionHelper.swift */; }; + 6CA91BE32A95226200C4063E /* RNCloudStorageBackupsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CA91BE02A95226200C4063E /* RNCloudStorageBackupsManager.swift */; }; + 6E2E4593B2C40DBBA7C861BF /* IContract.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D88F29D12AB0D8B4C00D065 /* IContract.graphql.swift */; }; + 6FAEE2539C8AFB99445F29BE /* TokenFeeDataParts.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 413CEB2443D6DD1DA7CC7231 /* TokenFeeDataParts.graphql.swift */; }; + 70EB8338CA39744B7DBD553E /* Pods_WidgetIntentExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1064E23E366D0C2C2B20C30E /* Pods_WidgetIntentExtension.framework */; }; + 7154698A2773F05CAD639211 /* TokenParts.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A266B733FD2FA9C1C1A7731 /* TokenParts.graphql.swift */; }; + 72272D14F0DB24D05F1162FE /* NftCollection.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2ECFE86020AB7A252CDC9BB1 /* NftCollection.graphql.swift */; }; + 722E7F2ECE654320C2452CC8 /* TokenProtectionInfoParts.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93D1BA7F635F61FC7E62C6C1 /* TokenProtectionInfoParts.graphql.swift */; }; + 72567EB83861EBC04FAA0941 /* TokenProjectDescriptionQuery.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C66DD1D03A824EB6A231DCE /* TokenProjectDescriptionQuery.graphql.swift */; }; + 74D0262298BD1BE5363444B5 /* TokenBasicProjectParts.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB9FB14C8B68CE1D05643256 /* TokenBasicProjectParts.graphql.swift */; }; + 75FA3F2F18047B759472AEA8 /* NetworkFee.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FB70424D7C456B9B72536C6 /* NetworkFee.graphql.swift */; }; + 77206ACF669BD9DAAC096227 /* PageInfo.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F2898E709490CC198B98228 /* PageInfo.graphql.swift */; }; + 77CF6065C8A24FE48204A2C1 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = BF9176E944C84910B1C0B057 /* SplashScreen.storyboard */; }; + 7B49698C356931577828B41E /* TimestampedAmount.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C9E8E39C13967E447FC193F /* TimestampedAmount.graphql.swift */; }; + 7BDC2CBE10DC34F9BCFF86EA /* TokenProjectTokensTvlParts.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC2EE4B5A1180F03030E80A9 /* TokenProjectTokensTvlParts.graphql.swift */; }; + 8273FC23FB1AE47B80C5E09F /* Pods_OneSignalNotificationServiceExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 15092E550A1C78508ABA3280 /* Pods_OneSignalNotificationServiceExtension.framework */; }; + 8385A47D3C765B841F450090 /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = C26D739993D5C939C6FBB58A /* ExpoModulesProvider.swift */; }; + 890E98060D98F2F5617D1914 /* HomeScreenTokensQuery.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2240891823DAE7BF8BB88FF4 /* HomeScreenTokensQuery.graphql.swift */; }; + 8989D182DEC9682661D588F3 /* NftApproval.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42158AF500DDBEEF6B7E98E3 /* NftApproval.graphql.swift */; }; + 8B2A92172EB3E78E00990413 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B2A92162EB3E78E00990413 /* AppDelegate.swift */; }; + 8C19BAD465EA9DFEB20EFB24 /* ActivityDetails.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = B68E391195D4587F61A33380 /* ActivityDetails.graphql.swift */; }; + 8D90B4306573344A1FFC4832 /* OnRampTransactionDetails.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 614A491FAB9659FA08CD9AE4 /* OnRampTransactionDetails.graphql.swift */; }; + 8E89C3AE2AB8AAA400C84DE5 /* MnemonicConfirmationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E89C3A62AB8AAA400C84DE5 /* MnemonicConfirmationView.swift */; }; + 8E89C3AF2AB8AAA400C84DE5 /* MnemonicDisplayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E89C3A72AB8AAA400C84DE5 /* MnemonicDisplayView.swift */; }; + 8E89C3B12AB8AAA400C84DE5 /* MnemonicConfirmationWordBankView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E89C3A92AB8AAA400C84DE5 /* MnemonicConfirmationWordBankView.swift */; }; + 8E89C3B22AB8AAA400C84DE5 /* MnemonicTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E89C3AA2AB8AAA400C84DE5 /* MnemonicTextField.swift */; }; + 8E89C3B32AB8AAA400C84DE5 /* MnemonicDisplayManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 8E89C3AB2AB8AAA400C84DE5 /* MnemonicDisplayManager.m */; }; + 8E89C3B42AB8AAA400C84DE5 /* MnemonicConfirmationManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 8E89C3AC2AB8AAA400C84DE5 /* MnemonicConfirmationManager.m */; }; + 8EA8AB3B2AB7ED3C004E7EF3 /* SeedPhraseInputManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 8EA8AB302AB7ED3C004E7EF3 /* SeedPhraseInputManager.m */; }; + 8EA8AB3C2AB7ED3C004E7EF3 /* SeedPhraseInputViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8EA8AB312AB7ED3C004E7EF3 /* SeedPhraseInputViewModel.swift */; }; + 8EA8AB3D2AB7ED3C004E7EF3 /* SeedPhraseInputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8EA8AB322AB7ED3C004E7EF3 /* SeedPhraseInputView.swift */; }; + 8EA8AB3E2AB7ED3C004E7EF3 /* SeedPhraseInputManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8EA8AB332AB7ED3C004E7EF3 /* SeedPhraseInputManager.swift */; }; + 8EA8AB412AB7ED76004E7EF3 /* AlertTriangleIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8EA8AB402AB7ED76004E7EF3 /* AlertTriangleIcon.swift */; }; + 8EBFB1552ABA6AA6006B32A8 /* PasteIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8EBFB1542ABA6AA6006B32A8 /* PasteIcon.swift */; }; + 8ED0562C2AA78E2C009BD5A2 /* ScrollFadeExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8ED0562B2AA78E2C009BD5A2 /* ScrollFadeExtensions.swift */; }; + 90B93D7970186C8FC83F9292 /* TokenBasicInfoParts.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 319AAC5377C60EB6579CC5C6 /* TokenBasicInfoParts.graphql.swift */; }; + 914DFEC13BB1853D25D23E34 /* ProtectionAttackType.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE4AB3FCC91BE6C73557E0E3 /* ProtectionAttackType.graphql.swift */; }; + 939256080FE6141E53B49E90 /* Dimensions.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DCC7E203AB163C9C2D3D789 /* Dimensions.graphql.swift */; }; + 95AA27A2056B51265EE643F1 /* AssetActivity.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BB8B6395E3C86412FA793CB /* AssetActivity.graphql.swift */; }; + 99ED3ACCFE04709CFA7FD490 /* WidgetTokensQuery.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0C956C520AAB54BCE8C5CCC /* WidgetTokensQuery.graphql.swift */; }; + 9F1AE0C43E80AE592CA4AD7E /* ProtectionInfo.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 346B304DE3207797FD1FDE71 /* ProtectionInfo.graphql.swift */; }; + 9F78980B2A819CC4004D5A98 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 072F6C222A44A32E00DA720A /* SwiftUI.framework */; }; + 9F78980E2A819D2B004D5A98 /* WidgetsCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 072E23862A44D5BC006AD6C9 /* WidgetsCore.framework */; }; + 9F7898112A819D32004D5A98 /* WidgetsCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 072E23862A44D5BC006AD6C9 /* WidgetsCore.framework */; }; + 9F7898142A819D62004D5A98 /* WidgetsCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 072E23862A44D5BC006AD6C9 /* WidgetsCore.framework */; }; + 9F7898152A819D62004D5A98 /* WidgetsCore.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 072E23862A44D5BC006AD6C9 /* WidgetsCore.framework */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 9FCEBF002A95A8E00079EDDB /* RNWalletConnect.m in Sources */ = {isa = PBXBuildFile; fileRef = 9FCEBEFE2A95A8E00079EDDB /* RNWalletConnect.m */; }; + 9FCEBF012A95A8E00079EDDB /* RNWalletConnect.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FCEBEFF2A95A8E00079EDDB /* RNWalletConnect.swift */; }; + 9FCEBF042A95A99C0079EDDB /* RCTThemeModule.m in Sources */ = {isa = PBXBuildFile; fileRef = 9FCEBF032A95A99B0079EDDB /* RCTThemeModule.m */; }; + A0ACC9C3ABF174616E0CBCA4 /* OffRampTransactionDetails.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AFC48B9A573915BFA6FC6E2 /* OffRampTransactionDetails.graphql.swift */; }; + A32F9FBD272343C9002CFCDB /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = A32F9FBC272343C8002CFCDB /* GoogleService-Info.plist */; }; + A3551F2CAC134AD49D40927F /* Basel-Grotesk-Book.otf in Resources */ = {isa = PBXBuildFile; fileRef = 6F33E8069B7B40AFB313B8B0 /* Basel-Grotesk-Book.otf */; }; + A3EEE7EE3CC93DDBA3CE6A5C /* PortfolioValueModifier.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13150074BB66C20E7EA271A /* PortfolioValueModifier.graphql.swift */; }; + A3F0A5B1272B1DFA00895B25 /* KeychainSwiftDistrib.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3F0A5B0272B1DFA00895B25 /* KeychainSwiftDistrib.swift */; }; + AC0EE0982BD826E700BCCF07 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = AC0EE0972BD826E700BCCF07 /* PrivacyInfo.xcprivacy */; }; + AC2EF4032C914B1600EEEFDB /* fonts in Resources */ = {isa = PBXBuildFile; fileRef = AC2EF4022C914B1600EEEFDB /* fonts */; }; + ADD898CA4B87F6E7C990E268 /* NftCollectionMarket.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66697C913CF365C9460F42E6 /* NftCollectionMarket.graphql.swift */; }; + AEA0B1AC57BB6F11F5861BCE /* Token.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88B481513A2E780E6489DC4F /* Token.graphql.swift */; }; + AF467A9F1C200706537B24E5 /* TokenBalance.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AF8EE05291534B82ACC1105 /* TokenBalance.graphql.swift */; }; + B009D229E81544EA0F47C6DD /* TransactionStatus.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = A182D5FF8C0919083F2E333C /* TransactionStatus.graphql.swift */; }; + B193AD315CF844A3BDC3D11D /* Basel-Grotesk-Medium.otf in Resources */ = {isa = PBXBuildFile; fileRef = 3C606D2C81014A0A8898F38E /* Basel-Grotesk-Medium.otf */; }; + B2692B7521F4E7767DECE974 /* NftsQuery.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42013D777BAA40652AB28985 /* NftsQuery.graphql.swift */; }; + B4F84A94618C5A8D4F12007E /* ContractInput.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 957594D21A42B9D77F5AD685 /* ContractInput.graphql.swift */; }; + B5EF58A67FC42D684B96C0F0 /* TokenProjectMarket.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3E638BE80543E905D072D7F /* TokenProjectMarket.graphql.swift */; }; + B83B63F900E565F5C3F11589 /* FavoriteTokenCardQuery.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48DE72B77CD2D89B75BB934D /* FavoriteTokenCardQuery.graphql.swift */; }; + B8ADB9BF8BB2D4E456D80B23 /* NftStandard.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D973A66C23859D34E6FE175 /* NftStandard.graphql.swift */; }; + BA8FC9627A40644259D9E2F9 /* Pods_WidgetsCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CB29AC0C0907A833F23D2C30 /* Pods_WidgetsCore.framework */; }; + BD07375602C71B48961CD5A0 /* Portfolio.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = A34E3A6E13A4066767AA6757 /* Portfolio.graphql.swift */; }; + C403639465231038D196D7B5 /* BridgedWithdrawalInfo.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44D824154584CD39C82904E9 /* BridgedWithdrawalInfo.graphql.swift */; }; + CA4994AA5EF5F36F42117ECA /* TopTokenParts.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43D7F54953D864DC067FADD1 /* TopTokenParts.graphql.swift */; }; + CBFF07DDFB9C638D6E383290 /* SchemaConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D25B69F4A69D733A5DAEC42 /* SchemaConfiguration.swift */; }; + CFA408C9A92DB1F808BA57CD /* MobileSchema.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = A24882D069B34B7DC9E8B4CE /* MobileSchema.graphql.swift */; }; + D0AC45D21734567F720877AD /* TokenProjectUrlsParts.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F1055EFA8467996754739A7 /* TokenProjectUrlsParts.graphql.swift */; }; + D1642682124F702EE2454A64 /* IAmount.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = A372929BEDB706B6144334F0 /* IAmount.graphql.swift */; }; + D179B805D7A77EA127F41F13 /* DescriptionTranslations.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F02DBC2C51EFC6B1AA9C1CD /* DescriptionTranslations.graphql.swift */; }; + D3B63ACA9B0C42F68080B080 /* InputMono-Regular.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 1834199AFFB04D91B05FFB64 /* InputMono-Regular.ttf */; }; + D420E10F9BA8C789530E70F4 /* ApplicationContract.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BF540E1110D0FD1C219C45C /* ApplicationContract.graphql.swift */; }; + D6B69EFB74ACEF485C289266 /* ConvertQuery.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D3112654E5722A2F862D3FC /* ConvertQuery.graphql.swift */; }; + D7926D4A878B2237137B300F /* Pods_WidgetsCoreTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 021E59CE7ECBD4FE0F3BFCFD /* Pods_WidgetsCoreTests.framework */; }; + D86DB22D27B00DA71F968324 /* TransactionType.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37A6D5D76D31F8C5CDD29814 /* TransactionType.graphql.swift */; }; + DE2F24512E7204C2CA255C50 /* Pods_Widgets.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2E8B7D36D2E14D9488F351EB /* Pods_Widgets.framework */; }; + DEBB37600A7C5C9A273DA38E /* BlockaidFees.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 201FF7E30EC98ABC121BC8BF /* BlockaidFees.graphql.swift */; }; + DFBC904E6C0B818152912819 /* OnRampServiceProvider.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E5478334318BD5D6F8366D6 /* OnRampServiceProvider.graphql.swift */; }; + E4B3067A930D2E57558E5229 /* Pods_Uniswap_UniswapTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0929C0B4AE1570B8C0B45D4D /* Pods_Uniswap_UniswapTests.framework */; }; + E6CC238CB9AF565DE89087B7 /* SafetyLevel.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41FFD02A227ACFB661A44813 /* SafetyLevel.graphql.swift */; }; + E774B10E7FB502BE97D3895B /* ProtectionResult.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA586C522ABE5DA5363D27C7 /* ProtectionResult.graphql.swift */; }; + ECB6546D9AA307163172BEA3 /* NftApproveForAll.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = F99987D29B574C20584CE11A /* NftApproveForAll.graphql.swift */; }; + EEEE88236C7EBC4B67BBE858 /* TokenProject.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = A424FA93DB720DD7684C7674 /* TokenProject.graphql.swift */; }; + F35AFD3E27EE49990011A725 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F35AFD3D27EE49990011A725 /* NotificationService.swift */; }; + F35AFD4227EE49990011A725 /* OneSignalNotificationServiceExtension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = F35AFD3B27EE49990011A725 /* OneSignalNotificationServiceExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + F36073C9BDFF611771D04683 /* TokenPriceHistoryQuery.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDA02DA46906E4CDF827104A /* TokenPriceHistoryQuery.graphql.swift */; }; + F3E5BB84916B1F496A0C740D /* TokensQuery.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5442480B3C91EDC515728606 /* TokensQuery.graphql.swift */; }; + F5FD688A33BCADBF601A2D7F /* TransactionDirection.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EC9CABC0B4E73B25C7A2128 /* TransactionDirection.graphql.swift */; }; + F6EA6445BF4E0DEEDD076C7A /* Chain.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25DDE9DCAFA0EE2838508994 /* Chain.graphql.swift */; }; + F784F1CA9FCEB5FFE4C1DD62 /* NftProfile.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = E21EB4FB53B7B6B5848946D9 /* NftProfile.graphql.swift */; }; + F7B8B2FAEB30B3343CFF6F29 /* SwapOrderStatus.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 132FC1B05AE3D510C3B71EBA /* SwapOrderStatus.graphql.swift */; }; + F7CA47B62C91F3B0DA4106C0 /* AssetChange.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = F38DFE921F0467FF51A3D717 /* AssetChange.graphql.swift */; }; + F8E54B79B2742008CF1C0AA9 /* OnRampTransactionsAuth.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38EAF64134981DC76B6C7E90 /* OnRampTransactionsAuth.graphql.swift */; }; + FAB057109F187E5375D137FD /* Amount.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74673620CE906C9AF34C6750 /* Amount.graphql.swift */; }; + FD54D51D296C79A4007A37E9 /* GoogleServiceInfo in Resources */ = {isa = PBXBuildFile; fileRef = FD54D51C296C79A4007A37E9 /* GoogleServiceInfo */; }; + FD7304CE28A364FC0085BDEA /* Colors.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = FD7304CD28A364FC0085BDEA /* Colors.xcassets */; }; + FD7304D028A3650A0085BDEA /* Colors.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD7304CF28A3650A0085BDEA /* Colors.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 00E356F41AD99517003FC87E /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 83CBB9F71A601CBA00E9B192 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 13B07F861A680F5B00A75B9A; + remoteInfo = Uniswap; + }; + 0703EE082A57355400AED1DA /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 83CBB9F71A601CBA00E9B192 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 072E23852A44D5BC006AD6C9; + remoteInfo = UniswapWidgetsCore; + }; + 072E238F2A44D5BD006AD6C9 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 83CBB9F71A601CBA00E9B192 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 072E23852A44D5BC006AD6C9; + remoteInfo = UniswapWidgetsCore; + }; + 072E23912A44D5BD006AD6C9 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 83CBB9F71A601CBA00E9B192 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 13B07F861A680F5B00A75B9A; + remoteInfo = Uniswap; + }; + 072F6C2F2A44A32F00DA720A /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 83CBB9F71A601CBA00E9B192 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 072F6C1E2A44A32E00DA720A; + remoteInfo = UniswapWidgetExtension; + }; + 078E794C2A55EB3300F59CF2 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 83CBB9F71A601CBA00E9B192 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 078E79442A55EB3300F59CF2; + remoteInfo = UniswapWidgetsIntentExtension; + }; + 9F7898052A819AA2004D5A98 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 83CBB9F71A601CBA00E9B192 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 072E23852A44D5BC006AD6C9; + remoteInfo = WidgetsCore; + }; + 9F7898162A819D62004D5A98 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 83CBB9F71A601CBA00E9B192 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 072E23852A44D5BC006AD6C9; + remoteInfo = WidgetsCore; + }; + F35AFD4027EE49990011A725 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 83CBB9F71A601CBA00E9B192 /* Project object */; + proxyType = 1; + remoteGlobalIDString = F35AFD3A27EE49990011A725; + remoteInfo = OneSignalNotificationServiceExtension; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9F7898182A819D62004D5A98 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + 9F7898152A819D62004D5A98 /* WidgetsCore.framework in Embed Frameworks */, + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; + F35AFD4627EE49990011A725 /* Embed App Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + 072F6C312A44A32F00DA720A /* Widgets.appex in Embed App Extensions */, + 078E794E2A55EB3300F59CF2 /* WidgetIntentExtension.appex in Embed App Extensions */, + F35AFD4227EE49990011A725 /* OneSignalNotificationServiceExtension.appex in Embed App Extensions */, + ); + name = "Embed App Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 009F4258CE111989223E99A4 /* TokenApproval.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = TokenApproval.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Objects/TokenApproval.graphql.swift; sourceTree = ""; }; + 00E356EE1AD99517003FC87E /* UniswapTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = UniswapTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 00E356F11AD99517003FC87E /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 00E356F21AD99517003FC87E /* UniswapTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = UniswapTests.m; sourceTree = ""; }; + 021E59CE7ECBD4FE0F3BFCFD /* Pods_WidgetsCoreTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_WidgetsCoreTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 037C5AA92C04970B00B1D808 /* CopyIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CopyIcon.swift; sourceTree = ""; }; + 03C788222C10E7390011E5DC /* ActionButtons.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionButtons.swift; sourceTree = ""; }; + 03D2F3172C218D380030D987 /* RelativeOffsetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelativeOffsetView.swift; sourceTree = ""; }; + 065A981F892F7A06A900FCD5 /* Pods-WidgetsCoreTests.dev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WidgetsCoreTests.dev.xcconfig"; path = "Target Support Files/Pods-WidgetsCoreTests/Pods-WidgetsCoreTests.dev.xcconfig"; sourceTree = ""; }; + 0703EE022A5734A600AED1DA /* UserDefaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefaults.swift; sourceTree = ""; }; + 070480372A58A507009006CE /* WidgetIntentExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = WidgetIntentExtension.entitlements; sourceTree = ""; }; + 0712B3629C74D1F958DF35FB /* Pods-Uniswap-UniswapTests.dev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Uniswap-UniswapTests.dev.xcconfig"; path = "Target Support Files/Pods-Uniswap-UniswapTests/Pods-Uniswap-UniswapTests.dev.xcconfig"; sourceTree = ""; }; + 072E23862A44D5BC006AD6C9 /* WidgetsCore.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = WidgetsCore.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 072E23882A44D5BD006AD6C9 /* WidgetsCore.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = WidgetsCore.h; sourceTree = ""; }; + 072E238D2A44D5BD006AD6C9 /* WidgetsCoreTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = WidgetsCoreTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 072E23942A44D5BD006AD6C9 /* WidgetsCoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetsCoreTests.swift; sourceTree = ""; }; + 072E23A62A44D61A006AD6C9 /* Widgets.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Widgets.entitlements; sourceTree = ""; }; + 072F6C1F2A44A32E00DA720A /* Widgets.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = Widgets.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + 072F6C202A44A32E00DA720A /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; + 072F6C222A44A32E00DA720A /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; }; + 072F6C252A44A32E00DA720A /* WidgetsBundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetsBundle.swift; sourceTree = ""; }; + 072F6C272A44A32E00DA720A /* TokenPriceWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenPriceWidget.swift; sourceTree = ""; }; + 072F6C292A44A32E00DA720A /* TokenPriceWidget.intentdefinition */ = {isa = PBXFileReference; lastKnownFileType = file.intentdefinition; path = TokenPriceWidget.intentdefinition; sourceTree = ""; }; + 072F6C2A2A44A32F00DA720A /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 072F6C2C2A44A32F00DA720A /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 072F6C372A44BECC00DA720A /* Logging.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logging.swift; sourceTree = ""; }; + 074086F92A703B76006E3053 /* FormatTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormatTests.swift; sourceTree = ""; }; + 0741433F2A588F5800A157D3 /* Structs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Structs.swift; sourceTree = ""; }; + 074321DE2A83E3C900F8518D /* SchemaConfiguration.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SchemaConfiguration.swift; sourceTree = ""; }; + 0743223F2A841BBD00F8518D /* Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Constants.swift; sourceTree = ""; }; + 0767E0372A65C8330042ADA2 /* Colors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Colors.swift; sourceTree = ""; }; + 0767E03A2A65D2550042ADA2 /* Styling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Styling.swift; sourceTree = ""; }; + 0783F7B32A619E7C009ED617 /* UIComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIComponents.swift; sourceTree = ""; }; + 078E79452A55EB3300F59CF2 /* WidgetIntentExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = WidgetIntentExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + 078E79462A55EB3300F59CF2 /* Intents.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Intents.framework; path = System/Library/Frameworks/Intents.framework; sourceTree = SDKROOT; }; + 078E79492A55EB3300F59CF2 /* IntentHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntentHandler.swift; sourceTree = ""; }; + 078E794B2A55EB3300F59CF2 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 07B0676A2A7D6EC8001DD9B9 /* RNWidgets.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RNWidgets.swift; sourceTree = ""; }; + 07B0676B2A7D6EC8001DD9B9 /* RNWidgets.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RNWidgets.m; sourceTree = ""; }; + 07F0C28E2A5F3E2E00D5353E /* Env.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Env.swift; sourceTree = ""; }; + 07F1363F2A575EC00067004F /* DataQueries.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataQueries.swift; sourceTree = ""; }; + 07F136412A5763480067004F /* Network.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Network.swift; sourceTree = ""; }; + 07F5CF702A6AD97D00C648A5 /* Chart.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Chart.swift; sourceTree = ""; }; + 07F5CF742A7020FD00C648A5 /* Format.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Format.swift; sourceTree = ""; }; + 08C60D53AB82A6D0D31D0F78 /* Pods-WidgetIntentExtension.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WidgetIntentExtension.release.xcconfig"; path = "Target Support Files/Pods-WidgetIntentExtension/Pods-WidgetIntentExtension.release.xcconfig"; sourceTree = ""; }; + 08EBF075A4482F701892270B /* Pods-Widgets.dev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Widgets.dev.xcconfig"; path = "Target Support Files/Pods-Widgets/Pods-Widgets.dev.xcconfig"; sourceTree = ""; }; + 0929C0B4AE1570B8C0B45D4D /* Pods_Uniswap_UniswapTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Uniswap_UniswapTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 0A266B733FD2FA9C1C1A7731 /* TokenParts.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = TokenParts.graphql.swift; path = WidgetsCore/MobileSchema/Fragments/TokenParts.graphql.swift; sourceTree = ""; }; + 0A7D41CB7AA0496123E2153D /* SwapOrderType.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SwapOrderType.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Enums/SwapOrderType.graphql.swift; sourceTree = ""; }; + 0B7E5D62E11408EB5F0F5A80 /* Pods-WidgetsCore.beta.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WidgetsCore.beta.xcconfig"; path = "Target Support Files/Pods-WidgetsCore/Pods-WidgetsCore.beta.xcconfig"; sourceTree = ""; }; + 0C19DE44A750FB17647FF2B6 /* Pods-Widgets.beta.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Widgets.beta.xcconfig"; path = "Target Support Files/Pods-Widgets/Pods-Widgets.beta.xcconfig"; sourceTree = ""; }; + 0D25B69F4A69D733A5DAEC42 /* SchemaConfiguration.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SchemaConfiguration.swift; path = WidgetsCore/MobileSchema/Schema/SchemaConfiguration.swift; sourceTree = ""; }; + 0DB282242CDADB260014CF77 /* EmbeddedWallet.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = EmbeddedWallet.m; sourceTree = ""; }; + 0DB282252CDADB260014CF77 /* EmbeddedWallet.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EmbeddedWallet.swift; sourceTree = ""; }; + 0E5478334318BD5D6F8366D6 /* OnRampServiceProvider.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = OnRampServiceProvider.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Objects/OnRampServiceProvider.graphql.swift; sourceTree = ""; }; + 0EA8FE9029A1E7C4EAD7F547 /* NftsTabQuery.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = NftsTabQuery.graphql.swift; path = WidgetsCore/MobileSchema/Operations/Queries/NftsTabQuery.graphql.swift; sourceTree = ""; }; + 1064E23E366D0C2C2B20C30E /* Pods_WidgetIntentExtension.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_WidgetIntentExtension.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 1193B3A845BC3BE8CAA00D01 /* Pods-OneSignalNotificationServiceExtension.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-OneSignalNotificationServiceExtension.release.xcconfig"; path = "Target Support Files/Pods-OneSignalNotificationServiceExtension/Pods-OneSignalNotificationServiceExtension.release.xcconfig"; sourceTree = ""; }; + 132FC1B05AE3D510C3B71EBA /* SwapOrderStatus.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SwapOrderStatus.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Enums/SwapOrderStatus.graphql.swift; sourceTree = ""; }; + 13B07F961A680F5B00A75B9A /* Uniswap.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Uniswap.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = Uniswap/Images.xcassets; sourceTree = ""; }; + 13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = Uniswap/Info.plist; sourceTree = ""; }; + 15092E550A1C78508ABA3280 /* Pods_OneSignalNotificationServiceExtension.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_OneSignalNotificationServiceExtension.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 16F706375DFAFB58F8440CF9 /* TokenQuery.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = TokenQuery.graphql.swift; path = WidgetsCore/MobileSchema/Operations/Queries/TokenQuery.graphql.swift; sourceTree = ""; }; + 178644A78AB62609EFDB66B3 /* Pods-Uniswap.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Uniswap.release.xcconfig"; path = "Target Support Files/Pods-Uniswap/Pods-Uniswap.release.xcconfig"; sourceTree = ""; }; + 1834199AFFB04D91B05FFB64 /* InputMono-Regular.ttf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; name = "InputMono-Regular.ttf"; path = "../src/assets/fonts/InputMono-Regular.ttf"; sourceTree = ""; }; + 1CC6ADAADCA38FDAEB181E86 /* Pods-WidgetIntentExtension.dev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WidgetIntentExtension.dev.xcconfig"; path = "Target Support Files/Pods-WidgetIntentExtension/Pods-WidgetIntentExtension.dev.xcconfig"; sourceTree = ""; }; + 1D88F29D12AB0D8B4C00D065 /* IContract.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = IContract.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Interfaces/IContract.graphql.swift; sourceTree = ""; }; + 201FF7E30EC98ABC121BC8BF /* BlockaidFees.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = BlockaidFees.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Objects/BlockaidFees.graphql.swift; sourceTree = ""; }; + 2226DF79BEAFECEE11A51347 /* Pods_Uniswap.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Uniswap.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 2240891823DAE7BF8BB88FF4 /* HomeScreenTokensQuery.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = HomeScreenTokensQuery.graphql.swift; path = WidgetsCore/MobileSchema/Operations/Queries/HomeScreenTokensQuery.graphql.swift; sourceTree = ""; }; + 24343423F6B8A16CB14F1E4C /* Pods-WidgetsCore.debugoptimized.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WidgetsCore.debugoptimized.xcconfig"; path = "Target Support Files/Pods-WidgetsCore/Pods-WidgetsCore.debugoptimized.xcconfig"; sourceTree = ""; }; + 2590691EDB017AE6FB6C10AC /* Pods-WidgetIntentExtension.debugoptimized.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WidgetIntentExtension.debugoptimized.xcconfig"; path = "Target Support Files/Pods-WidgetIntentExtension/Pods-WidgetIntentExtension.debugoptimized.xcconfig"; sourceTree = ""; }; + 25DDE9DCAFA0EE2838508994 /* Chain.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = Chain.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Enums/Chain.graphql.swift; sourceTree = ""; }; + 284C5150E9B50FB5BE531572 /* Image.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = Image.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Objects/Image.graphql.swift; sourceTree = ""; }; + 290B66F38FEC486AF1709BE4 /* FeeData.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = FeeData.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Objects/FeeData.graphql.swift; sourceTree = ""; }; + 2C9E8E39C13967E447FC193F /* TimestampedAmount.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = TimestampedAmount.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Objects/TimestampedAmount.graphql.swift; sourceTree = ""; }; + 2E8B7D36D2E14D9488F351EB /* Pods_Widgets.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Widgets.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 2ECFE86020AB7A252CDC9BB1 /* NftCollection.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = NftCollection.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Objects/NftCollection.graphql.swift; sourceTree = ""; }; + 2FB70424D7C456B9B72536C6 /* NetworkFee.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = NetworkFee.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Objects/NetworkFee.graphql.swift; sourceTree = ""; }; + 3063061F7C203A1DA47B8FBB /* OffRampTransfer.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = OffRampTransfer.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Objects/OffRampTransfer.graphql.swift; sourceTree = ""; }; + 319AAC5377C60EB6579CC5C6 /* TokenBasicInfoParts.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = TokenBasicInfoParts.graphql.swift; path = WidgetsCore/MobileSchema/Fragments/TokenBasicInfoParts.graphql.swift; sourceTree = ""; }; + 31CB18D02DE297D6DADB87C0 /* Pods-Uniswap.debugoptimized.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Uniswap.debugoptimized.xcconfig"; path = "Target Support Files/Pods-Uniswap/Pods-Uniswap.debugoptimized.xcconfig"; sourceTree = ""; }; + 32764E27B5D9FCF0AA4217CF /* SwapOrderDetails.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SwapOrderDetails.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Objects/SwapOrderDetails.graphql.swift; sourceTree = ""; }; + 346B304DE3207797FD1FDE71 /* ProtectionInfo.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ProtectionInfo.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Objects/ProtectionInfo.graphql.swift; sourceTree = ""; }; + 36F66895EB592B9F61AE658C /* Pods-Uniswap-UniswapTests.debugoptimized.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Uniswap-UniswapTests.debugoptimized.xcconfig"; path = "Target Support Files/Pods-Uniswap-UniswapTests/Pods-Uniswap-UniswapTests.debugoptimized.xcconfig"; sourceTree = ""; }; + 37A6D5D76D31F8C5CDD29814 /* TransactionType.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = TransactionType.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Enums/TransactionType.graphql.swift; sourceTree = ""; }; + 38EAF64134981DC76B6C7E90 /* OnRampTransactionsAuth.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = OnRampTransactionsAuth.graphql.swift; path = WidgetsCore/MobileSchema/Schema/InputObjects/OnRampTransactionsAuth.graphql.swift; sourceTree = ""; }; + 3A2186B1FF7FB85663D96EA9 /* Pods-OneSignalNotificationServiceExtension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-OneSignalNotificationServiceExtension.debug.xcconfig"; path = "Target Support Files/Pods-OneSignalNotificationServiceExtension/Pods-OneSignalNotificationServiceExtension.debug.xcconfig"; sourceTree = ""; }; + 3AE6A0CAEDE1DC1D2FA14DEA /* TransactionHistoryUpdaterQuery.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = TransactionHistoryUpdaterQuery.graphql.swift; path = WidgetsCore/MobileSchema/Operations/Queries/TransactionHistoryUpdaterQuery.graphql.swift; sourceTree = ""; }; + 3BA467FB84503BB53B081C73 /* TokenProjectMarketsParts.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = TokenProjectMarketsParts.graphql.swift; path = WidgetsCore/MobileSchema/Fragments/TokenProjectMarketsParts.graphql.swift; sourceTree = ""; }; + 3C606D2C81014A0A8898F38E /* Basel-Grotesk-Medium.otf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; name = "Basel-Grotesk-Medium.otf"; path = "../src/assets/fonts/Basel-Grotesk-Medium.otf"; sourceTree = ""; }; + 3D3112654E5722A2F862D3FC /* ConvertQuery.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ConvertQuery.graphql.swift; path = WidgetsCore/MobileSchema/Operations/Queries/ConvertQuery.graphql.swift; sourceTree = ""; }; + 3D8FCE4CD401350CA74DCC89 /* Pods-WidgetsCoreTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WidgetsCoreTests.debug.xcconfig"; path = "Target Support Files/Pods-WidgetsCoreTests/Pods-WidgetsCoreTests.debug.xcconfig"; sourceTree = ""; }; + 3D96819BA563CB218D29C657 /* TokenSortableField.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = TokenSortableField.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Enums/TokenSortableField.graphql.swift; sourceTree = ""; }; + 3E279F675B02CBC50D3B57D5 /* Pods-WidgetsCore.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WidgetsCore.release.xcconfig"; path = "Target Support Files/Pods-WidgetsCore/Pods-WidgetsCore.release.xcconfig"; sourceTree = ""; }; + 413CEB2443D6DD1DA7CC7231 /* TokenFeeDataParts.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = TokenFeeDataParts.graphql.swift; path = WidgetsCore/MobileSchema/Fragments/TokenFeeDataParts.graphql.swift; sourceTree = ""; }; + 41FFD02A227ACFB661A44813 /* SafetyLevel.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SafetyLevel.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Enums/SafetyLevel.graphql.swift; sourceTree = ""; }; + 42013D777BAA40652AB28985 /* NftsQuery.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = NftsQuery.graphql.swift; path = WidgetsCore/MobileSchema/Operations/Queries/NftsQuery.graphql.swift; sourceTree = ""; }; + 42158AF500DDBEEF6B7E98E3 /* NftApproval.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = NftApproval.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Objects/NftApproval.graphql.swift; sourceTree = ""; }; + 43AB42F3ED903CF49D7D4DE7 /* AmountChange.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AmountChange.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Objects/AmountChange.graphql.swift; sourceTree = ""; }; + 43D7F54953D864DC067FADD1 /* TopTokenParts.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = TopTokenParts.graphql.swift; path = WidgetsCore/MobileSchema/Fragments/TopTokenParts.graphql.swift; sourceTree = ""; }; + 44D824154584CD39C82904E9 /* BridgedWithdrawalInfo.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = BridgedWithdrawalInfo.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Objects/BridgedWithdrawalInfo.graphql.swift; sourceTree = ""; }; + 45FFF7DE2E8C2A6400362570 /* SilentPushEventEmitter.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SilentPushEventEmitter.m; sourceTree = ""; }; + 45FFF7E02E8C2E6100362570 /* SilentPushEventEmitter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SilentPushEventEmitter.swift; sourceTree = ""; }; + 47148657FBA8DCC92DD69573 /* TransactionDetails.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = TransactionDetails.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Objects/TransactionDetails.graphql.swift; sourceTree = ""; }; + 4781CD4CDD95B5792B793F75 /* Pods-Uniswap-UniswapTests.beta.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Uniswap-UniswapTests.beta.xcconfig"; path = "Target Support Files/Pods-Uniswap-UniswapTests/Pods-Uniswap-UniswapTests.beta.xcconfig"; sourceTree = ""; }; + 48DE72B77CD2D89B75BB934D /* FavoriteTokenCardQuery.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = FavoriteTokenCardQuery.graphql.swift; path = WidgetsCore/MobileSchema/Operations/Queries/FavoriteTokenCardQuery.graphql.swift; sourceTree = ""; }; + 4AF8EE05291534B82ACC1105 /* TokenBalance.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = TokenBalance.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Objects/TokenBalance.graphql.swift; sourceTree = ""; }; + 4C445DB9798210862C34D0E0 /* Pods-WidgetsCoreTests.beta.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WidgetsCoreTests.beta.xcconfig"; path = "Target Support Files/Pods-WidgetsCoreTests/Pods-WidgetsCoreTests.beta.xcconfig"; sourceTree = ""; }; + 4DF5F26A06553EFDD4D99214 /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-Uniswap/ExpoModulesProvider.swift"; sourceTree = ""; }; + 4EC9CABC0B4E73B25C7A2128 /* TransactionDirection.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = TransactionDirection.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Enums/TransactionDirection.graphql.swift; sourceTree = ""; }; + 5442480B3C91EDC515728606 /* TokensQuery.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = TokensQuery.graphql.swift; path = WidgetsCore/MobileSchema/Operations/Queries/TokensQuery.graphql.swift; sourceTree = ""; }; + 56093E216B82288E1A4018F0 /* NftBalanceConnection.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = NftBalanceConnection.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Objects/NftBalanceConnection.graphql.swift; sourceTree = ""; }; + 56FE9C9AF785221B7E3F4C04 /* Pods-Uniswap.dev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Uniswap.dev.xcconfig"; path = "Target Support Files/Pods-Uniswap/Pods-Uniswap.dev.xcconfig"; sourceTree = ""; }; + 58641E30674C4C4241702902 /* Query.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = Query.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Objects/Query.graphql.swift; sourceTree = ""; }; + 5B4398E82DD3B22C00F6BE08 /* PrivateKeyDisplayManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = PrivateKeyDisplayManager.m; sourceTree = ""; }; + 5B4398E92DD3B22C00F6BE08 /* PrivateKeyDisplayManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivateKeyDisplayManager.swift; sourceTree = ""; }; + 5B4398EA2DD3B22C00F6BE08 /* PrivateKeyDisplayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivateKeyDisplayView.swift; sourceTree = ""; }; + 5B4CEC5E2DD65DD4009F082B /* CopyIconOutline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CopyIconOutline.swift; sourceTree = ""; }; + 5E5E0A622D380F5700E166AA /* Env.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Env.swift; sourceTree = ""; }; + 5F2898E709490CC198B98228 /* PageInfo.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PageInfo.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Objects/PageInfo.graphql.swift; sourceTree = ""; }; + 60195493B9C71EDEECA46E80 /* OnRampTransfer.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = OnRampTransfer.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Objects/OnRampTransfer.graphql.swift; sourceTree = ""; }; + 614A491FAB9659FA08CD9AE4 /* OnRampTransactionDetails.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = OnRampTransactionDetails.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Objects/OnRampTransactionDetails.graphql.swift; sourceTree = ""; }; + 62CEA9F2D5176D20A6402A3E /* Pods-Uniswap.beta.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Uniswap.beta.xcconfig"; path = "Target Support Files/Pods-Uniswap/Pods-Uniswap.beta.xcconfig"; sourceTree = ""; }; + 649A7A762D9AE70B00B53589 /* KeychainConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainConstants.swift; sourceTree = ""; }; + 649A7A772D9AE70B00B53589 /* KeychainUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainUtils.swift; sourceTree = ""; }; + 66697C913CF365C9460F42E6 /* NftCollectionMarket.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = NftCollectionMarket.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Objects/NftCollectionMarket.graphql.swift; sourceTree = ""; }; + 6AFC48B9A573915BFA6FC6E2 /* OffRampTransactionDetails.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = OffRampTransactionDetails.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Objects/OffRampTransactionDetails.graphql.swift; sourceTree = ""; }; + 6BC7D07B2B5FF02400617C95 /* ScantasticEncryption.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ScantasticEncryption.m; sourceTree = ""; }; + 6BC7D07C2B5FF02400617C95 /* ScantasticEncryption.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ScantasticEncryption.swift; sourceTree = ""; }; + 6BC7D07D2B5FF02400617C95 /* EncryptionUtils.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EncryptionUtils.swift; sourceTree = ""; }; + 6C8EFC2C2891B99100FBD8EB /* EncryptionHelperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncryptionHelperTests.swift; sourceTree = ""; }; + 6CA91BD82A95223C00C4063E /* RNEthersRS-Bridging-Header.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "RNEthersRS-Bridging-Header.h"; sourceTree = ""; }; + 6CA91BD92A95223C00C4063E /* RNEthersRS.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RNEthersRS.swift; sourceTree = ""; }; + 6CA91BDA2A95223C00C4063E /* RnEthersRS.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RnEthersRS.m; sourceTree = ""; }; + 6CA91BDE2A95226200C4063E /* RNCloudStorageBackupsManager.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RNCloudStorageBackupsManager.m; sourceTree = ""; }; + 6CA91BDF2A95226200C4063E /* EncryptionHelper.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EncryptionHelper.swift; sourceTree = ""; }; + 6CA91BE02A95226200C4063E /* RNCloudStorageBackupsManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RNCloudStorageBackupsManager.swift; sourceTree = ""; }; + 6DCC7E203AB163C9C2D3D789 /* Dimensions.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = Dimensions.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Objects/Dimensions.graphql.swift; sourceTree = ""; }; + 6F02DBC2C51EFC6B1AA9C1CD /* DescriptionTranslations.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = DescriptionTranslations.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Objects/DescriptionTranslations.graphql.swift; sourceTree = ""; }; + 6F33E8069B7B40AFB313B8B0 /* Basel-Grotesk-Book.otf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; name = "Basel-Grotesk-Book.otf"; path = "../src/assets/fonts/Basel-Grotesk-Book.otf"; sourceTree = ""; }; + 6F3DC921A65D749C0852B10C /* Pods-Uniswap-UniswapTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Uniswap-UniswapTests.debug.xcconfig"; path = "Target Support Files/Pods-Uniswap-UniswapTests/Pods-Uniswap-UniswapTests.debug.xcconfig"; sourceTree = ""; }; + 6F7814C6D40D9C348EA1F1C7 /* Pods-OneSignalNotificationServiceExtension.dev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-OneSignalNotificationServiceExtension.dev.xcconfig"; path = "Target Support Files/Pods-OneSignalNotificationServiceExtension/Pods-OneSignalNotificationServiceExtension.dev.xcconfig"; sourceTree = ""; }; + 73D981A2D31B6F8822C19324 /* SchemaMetadata.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SchemaMetadata.graphql.swift; path = WidgetsCore/MobileSchema/Schema/SchemaMetadata.graphql.swift; sourceTree = ""; }; + 74673620CE906C9AF34C6750 /* Amount.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = Amount.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Objects/Amount.graphql.swift; sourceTree = ""; }; + 792EF7A7820987EA9E99D792 /* NftTransfer.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = NftTransfer.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Objects/NftTransfer.graphql.swift; sourceTree = ""; }; + 7A7637BBC9B3A68E0338D96E /* Pods-WidgetIntentExtension.beta.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WidgetIntentExtension.beta.xcconfig"; path = "Target Support Files/Pods-WidgetIntentExtension/Pods-WidgetIntentExtension.beta.xcconfig"; sourceTree = ""; }; + 7F0C115796D33739746778E3 /* TokenBalanceMainParts.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = TokenBalanceMainParts.graphql.swift; path = WidgetsCore/MobileSchema/Fragments/TokenBalanceMainParts.graphql.swift; sourceTree = ""; }; + 82C9871585F60F92D079FB95 /* Pods-Widgets.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Widgets.release.xcconfig"; path = "Target Support Files/Pods-Widgets/Pods-Widgets.release.xcconfig"; sourceTree = ""; }; + 8604CAA8F7E44D704A6F4AAF /* PortfolioBalancesQuery.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PortfolioBalancesQuery.graphql.swift; path = WidgetsCore/MobileSchema/Operations/Queries/PortfolioBalancesQuery.graphql.swift; sourceTree = ""; }; + 8719E5872CC41AB64503E903 /* Pods-WidgetIntentExtension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WidgetIntentExtension.debug.xcconfig"; path = "Target Support Files/Pods-WidgetIntentExtension/Pods-WidgetIntentExtension.debug.xcconfig"; sourceTree = ""; }; + 88B481513A2E780E6489DC4F /* Token.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = Token.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Objects/Token.graphql.swift; sourceTree = ""; }; + 8B2A92162EB3E78E00990413 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = Uniswap/AppDelegate.swift; sourceTree = ""; }; + 8B2A92182EB3E79500990413 /* Uniswap-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = "Uniswap-Bridging-Header.h"; path = "Uniswap/Uniswap-Bridging-Header.h"; sourceTree = ""; }; + 8BB8B6395E3C86412FA793CB /* AssetActivity.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AssetActivity.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Objects/AssetActivity.graphql.swift; sourceTree = ""; }; + 8C66DD1D03A824EB6A231DCE /* TokenProjectDescriptionQuery.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = TokenProjectDescriptionQuery.graphql.swift; path = WidgetsCore/MobileSchema/Operations/Queries/TokenProjectDescriptionQuery.graphql.swift; sourceTree = ""; }; + 8CBD584CCF002F58176DA863 /* Pods-OneSignalNotificationServiceExtension.debugoptimized.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-OneSignalNotificationServiceExtension.debugoptimized.xcconfig"; path = "Target Support Files/Pods-OneSignalNotificationServiceExtension/Pods-OneSignalNotificationServiceExtension.debugoptimized.xcconfig"; sourceTree = ""; }; + 8D5FF4316461BD4FB5847D62 /* TokenBalanceQuantityParts.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = TokenBalanceQuantityParts.graphql.swift; path = WidgetsCore/MobileSchema/Fragments/TokenBalanceQuantityParts.graphql.swift; sourceTree = ""; }; + 8D973A66C23859D34E6FE175 /* NftStandard.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = NftStandard.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Enums/NftStandard.graphql.swift; sourceTree = ""; }; + 8E89C3A62AB8AAA400C84DE5 /* MnemonicConfirmationView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MnemonicConfirmationView.swift; sourceTree = ""; }; + 8E89C3A72AB8AAA400C84DE5 /* MnemonicDisplayView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MnemonicDisplayView.swift; sourceTree = ""; }; + 8E89C3A92AB8AAA400C84DE5 /* MnemonicConfirmationWordBankView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MnemonicConfirmationWordBankView.swift; sourceTree = ""; }; + 8E89C3AA2AB8AAA400C84DE5 /* MnemonicTextField.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MnemonicTextField.swift; sourceTree = ""; }; + 8E89C3AB2AB8AAA400C84DE5 /* MnemonicDisplayManager.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MnemonicDisplayManager.m; sourceTree = ""; }; + 8E89C3AC2AB8AAA400C84DE5 /* MnemonicConfirmationManager.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MnemonicConfirmationManager.m; sourceTree = ""; }; + 8E89C3AD2AB8AAA400C84DE5 /* RNSwiftUI-Bridging-Header.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "RNSwiftUI-Bridging-Header.h"; sourceTree = ""; }; + 8EA8AB302AB7ED3C004E7EF3 /* SeedPhraseInputManager.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = SeedPhraseInputManager.m; sourceTree = ""; }; + 8EA8AB312AB7ED3C004E7EF3 /* SeedPhraseInputViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SeedPhraseInputViewModel.swift; sourceTree = ""; }; + 8EA8AB322AB7ED3C004E7EF3 /* SeedPhraseInputView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SeedPhraseInputView.swift; sourceTree = ""; }; + 8EA8AB332AB7ED3C004E7EF3 /* SeedPhraseInputManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SeedPhraseInputManager.swift; sourceTree = ""; }; + 8EA8AB402AB7ED76004E7EF3 /* AlertTriangleIcon.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AlertTriangleIcon.swift; sourceTree = ""; }; + 8EBFB1542ABA6AA6006B32A8 /* PasteIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasteIcon.swift; sourceTree = ""; }; + 8ED0562B2AA78E2C009BD5A2 /* ScrollFadeExtensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ScrollFadeExtensions.swift; sourceTree = ""; }; + 93783F96FA74CB9A1BEB32EE /* TokenTransfer.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = TokenTransfer.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Objects/TokenTransfer.graphql.swift; sourceTree = ""; }; + 93D1BA7F635F61FC7E62C6C1 /* TokenProtectionInfoParts.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = TokenProtectionInfoParts.graphql.swift; path = WidgetsCore/MobileSchema/Fragments/TokenProtectionInfoParts.graphql.swift; sourceTree = ""; }; + 957594D21A42B9D77F5AD685 /* ContractInput.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ContractInput.graphql.swift; path = WidgetsCore/MobileSchema/Schema/InputObjects/ContractInput.graphql.swift; sourceTree = ""; }; + 986A89D253EEB9BEBE5F08FF /* Pods-Widgets.debugoptimized.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Widgets.debugoptimized.xcconfig"; path = "Target Support Files/Pods-Widgets/Pods-Widgets.debugoptimized.xcconfig"; sourceTree = ""; }; + 9A2AA83B4F9F4290A2BC7CCB /* NftContract.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = NftContract.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Objects/NftContract.graphql.swift; sourceTree = ""; }; + 9BF540E1110D0FD1C219C45C /* ApplicationContract.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ApplicationContract.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Objects/ApplicationContract.graphql.swift; sourceTree = ""; }; + 9F1055EFA8467996754739A7 /* TokenProjectUrlsParts.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = TokenProjectUrlsParts.graphql.swift; path = WidgetsCore/MobileSchema/Fragments/TokenProjectUrlsParts.graphql.swift; sourceTree = ""; }; + 9F3500812A8AA5890077BFC5 /* EXSplashScreen.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = EXSplashScreen.xcframework; path = "../../../node_modules/expo-splash-screen/ios/EXSplashScreen.xcframework"; sourceTree = ""; }; + 9FCEBEFD2A95A8E00079EDDB /* RNWalletConnect.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = RNWalletConnect.h; path = Uniswap/WalletConnect/RNWalletConnect.h; sourceTree = ""; }; + 9FCEBEFE2A95A8E00079EDDB /* RNWalletConnect.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = RNWalletConnect.m; path = Uniswap/WalletConnect/RNWalletConnect.m; sourceTree = ""; }; + 9FCEBEFF2A95A8E00079EDDB /* RNWalletConnect.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = RNWalletConnect.swift; path = Uniswap/WalletConnect/RNWalletConnect.swift; sourceTree = ""; }; + 9FCEBF022A95A99B0079EDDB /* RCTThemeModule.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = RCTThemeModule.h; path = Appearance/RCTThemeModule.h; sourceTree = ""; }; + 9FCEBF032A95A99B0079EDDB /* RCTThemeModule.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = RCTThemeModule.m; path = Appearance/RCTThemeModule.m; sourceTree = ""; }; + A02D64B698D203A7F3643C18 /* TokenMarket.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = TokenMarket.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Objects/TokenMarket.graphql.swift; sourceTree = ""; }; + A182D5FF8C0919083F2E333C /* TransactionStatus.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = TransactionStatus.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Enums/TransactionStatus.graphql.swift; sourceTree = ""; }; + A24882D069B34B7DC9E8B4CE /* MobileSchema.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = MobileSchema.graphql.swift; path = WidgetsCore/MobileSchema/MobileSchema.graphql.swift; sourceTree = ""; }; + A32F9FBC272343C8002CFCDB /* GoogleService-Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; + A34E3A6E13A4066767AA6757 /* Portfolio.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = Portfolio.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Objects/Portfolio.graphql.swift; sourceTree = ""; }; + A372929BEDB706B6144334F0 /* IAmount.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = IAmount.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Interfaces/IAmount.graphql.swift; sourceTree = ""; }; + A3E638BE80543E905D072D7F /* TokenProjectMarket.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = TokenProjectMarket.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Objects/TokenProjectMarket.graphql.swift; sourceTree = ""; }; + A3F0A5B0272B1DFA00895B25 /* KeychainSwiftDistrib.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainSwiftDistrib.swift; sourceTree = ""; }; + A424FA93DB720DD7684C7674 /* TokenProject.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = TokenProject.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Objects/TokenProject.graphql.swift; sourceTree = ""; }; + A7C9F415D0E128A43003E071 /* Pods-Uniswap.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Uniswap.debug.xcconfig"; path = "Target Support Files/Pods-Uniswap/Pods-Uniswap.debug.xcconfig"; sourceTree = ""; }; + AB3EEA677984132330D8D279 /* HomeScreenTokenParts.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = HomeScreenTokenParts.graphql.swift; path = WidgetsCore/MobileSchema/Fragments/HomeScreenTokenParts.graphql.swift; sourceTree = ""; }; + AC0EE0972BD826E700BCCF07 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = Uniswap/PrivacyInfo.xcprivacy; sourceTree = ""; }; + AC2794442C51541E00F9AF68 /* sourcemaps-datadog.sh */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = "sourcemaps-datadog.sh"; sourceTree = ""; }; + AC2EF4022C914B1600EEEFDB /* fonts */ = {isa = PBXFileReference; lastKnownFileType = folder; name = fonts; path = ../src/assets/fonts; sourceTree = ""; }; + B0DA4D39B1A6D74A1D05B99F /* Pods-WidgetsCore.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WidgetsCore.debug.xcconfig"; path = "Target Support Files/Pods-WidgetsCore/Pods-WidgetsCore.debug.xcconfig"; sourceTree = ""; }; + B68E391195D4587F61A33380 /* ActivityDetails.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ActivityDetails.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Unions/ActivityDetails.graphql.swift; sourceTree = ""; }; + B97D41BC2760803FD1C7CB3C /* TokenBalanceParts.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = TokenBalanceParts.graphql.swift; path = WidgetsCore/MobileSchema/Fragments/TokenBalanceParts.graphql.swift; sourceTree = ""; }; + BB3CBF221D5D2E3C036A1BA0 /* SelectWalletScreenQuery.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SelectWalletScreenQuery.graphql.swift; path = WidgetsCore/MobileSchema/Operations/Queries/SelectWalletScreenQuery.graphql.swift; sourceTree = ""; }; + BB9FB14C8B68CE1D05643256 /* TokenBasicProjectParts.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = TokenBasicProjectParts.graphql.swift; path = WidgetsCore/MobileSchema/Fragments/TokenBasicProjectParts.graphql.swift; sourceTree = ""; }; + BCB2A43E5FB0D7B69CA02312 /* Pods-WidgetsCore.dev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WidgetsCore.dev.xcconfig"; path = "Target Support Files/Pods-WidgetsCore/Pods-WidgetsCore.dev.xcconfig"; sourceTree = ""; }; + BE4AB3FCC91BE6C73557E0E3 /* ProtectionAttackType.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ProtectionAttackType.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Enums/ProtectionAttackType.graphql.swift; sourceTree = ""; }; + BF9176E944C84910B1C0B057 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = Uniswap/SplashScreen.storyboard; sourceTree = ""; }; + C050FFEC067334CF05C17EA0 /* TopTokensQuery.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = TopTokensQuery.graphql.swift; path = WidgetsCore/MobileSchema/Operations/Queries/TopTokensQuery.graphql.swift; sourceTree = ""; }; + C0C956C520AAB54BCE8C5CCC /* WidgetTokensQuery.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WidgetTokensQuery.graphql.swift; path = WidgetsCore/MobileSchema/Operations/Queries/WidgetTokensQuery.graphql.swift; sourceTree = ""; }; + C26D739993D5C939C6FBB58A /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-Uniswap-UniswapTests/ExpoModulesProvider.swift"; sourceTree = ""; }; + C27225607FACF638581E25B5 /* TransactionListQuery.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = TransactionListQuery.graphql.swift; path = WidgetsCore/MobileSchema/Operations/Queries/TransactionListQuery.graphql.swift; sourceTree = ""; }; + C394AC5DB58BDF1C33CC0927 /* NftBalanceAssetInput.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = NftBalanceAssetInput.graphql.swift; path = WidgetsCore/MobileSchema/Schema/InputObjects/NftBalanceAssetInput.graphql.swift; sourceTree = ""; }; + C6754D4BD9CE76AAC915F814 /* TokenMarketParts.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = TokenMarketParts.graphql.swift; path = WidgetsCore/MobileSchema/Fragments/TokenMarketParts.graphql.swift; sourceTree = ""; }; + C89238E3ED9F3AC98876B573 /* Pods-WidgetsCoreTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WidgetsCoreTests.release.xcconfig"; path = "Target Support Files/Pods-WidgetsCoreTests/Pods-WidgetsCoreTests.release.xcconfig"; sourceTree = ""; }; + C8BAFEAABF78617095D44E32 /* NftBalance.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = NftBalance.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Objects/NftBalance.graphql.swift; sourceTree = ""; }; + CA586C522ABE5DA5363D27C7 /* ProtectionResult.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ProtectionResult.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Enums/ProtectionResult.graphql.swift; sourceTree = ""; }; + CB29AC0C0907A833F23D2C30 /* Pods_WidgetsCore.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_WidgetsCore.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + CB72291CE6EFD3842A8E2A1D /* Pods-WidgetsCoreTests.debugoptimized.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WidgetsCoreTests.debugoptimized.xcconfig"; path = "Target Support Files/Pods-WidgetsCoreTests/Pods-WidgetsCoreTests.debugoptimized.xcconfig"; sourceTree = ""; }; + CC2EE4B5A1180F03030E80A9 /* TokenProjectTokensTvlParts.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = TokenProjectTokensTvlParts.graphql.swift; path = WidgetsCore/MobileSchema/Fragments/TokenProjectTokensTvlParts.graphql.swift; sourceTree = ""; }; + CD3573B4E6FD61F5DDBD9C75 /* NftAsset.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = NftAsset.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Objects/NftAsset.graphql.swift; sourceTree = ""; }; + CE5E0F71E23968DEFCEB7A4B /* TokenStandard.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = TokenStandard.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Enums/TokenStandard.graphql.swift; sourceTree = ""; }; + D79B717BEAEA7857469D770A /* Pods-Widgets.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Widgets.debug.xcconfig"; path = "Target Support Files/Pods-Widgets/Pods-Widgets.debug.xcconfig"; sourceTree = ""; }; + DC6517FF8972DA448CDD2DFC /* HistoryDuration.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = HistoryDuration.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Enums/HistoryDuration.graphql.swift; sourceTree = ""; }; + E13150074BB66C20E7EA271A /* PortfolioValueModifier.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PortfolioValueModifier.graphql.swift; path = WidgetsCore/MobileSchema/Schema/InputObjects/PortfolioValueModifier.graphql.swift; sourceTree = ""; }; + E1B3A0C152B683215291E3BD /* TokenProjectsQuery.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = TokenProjectsQuery.graphql.swift; path = WidgetsCore/MobileSchema/Operations/Queries/TokenProjectsQuery.graphql.swift; sourceTree = ""; }; + E21B5EF6BC4582DD2BB0DA13 /* FeedTransactionListQuery.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = FeedTransactionListQuery.graphql.swift; path = WidgetsCore/MobileSchema/Operations/Queries/FeedTransactionListQuery.graphql.swift; sourceTree = ""; }; + E21EB4FB53B7B6B5848946D9 /* NftProfile.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = NftProfile.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Objects/NftProfile.graphql.swift; sourceTree = ""; }; + E3AF9944E853669C2BC9B5AB /* MultiplePortfolioBalancesQuery.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = MultiplePortfolioBalancesQuery.graphql.swift; path = WidgetsCore/MobileSchema/Operations/Queries/MultiplePortfolioBalancesQuery.graphql.swift; sourceTree = ""; }; + E5DDE9BCC6A539F6E9AE1664 /* Currency.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = Currency.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Enums/Currency.graphql.swift; sourceTree = ""; }; + E7ED83FFCB410FCCA2C6B97B /* TokenDetailsScreenQuery.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = TokenDetailsScreenQuery.graphql.swift; path = WidgetsCore/MobileSchema/Operations/Queries/TokenDetailsScreenQuery.graphql.swift; sourceTree = ""; }; + ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; }; + EDA02DA46906E4CDF827104A /* TokenPriceHistoryQuery.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = TokenPriceHistoryQuery.graphql.swift; path = WidgetsCore/MobileSchema/Operations/Queries/TokenPriceHistoryQuery.graphql.swift; sourceTree = ""; }; + F182567B705FC0E15DA8F34A /* NftBalanceEdge.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = NftBalanceEdge.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Objects/NftBalanceEdge.graphql.swift; sourceTree = ""; }; + F1A3F4DDD7E40DA9E4BBAAD1 /* Pods-Uniswap-UniswapTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Uniswap-UniswapTests.release.xcconfig"; path = "Target Support Files/Pods-Uniswap-UniswapTests/Pods-Uniswap-UniswapTests.release.xcconfig"; sourceTree = ""; }; + F35AFD3627EE49230011A725 /* Uniswap.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; name = Uniswap.entitlements; path = Uniswap/Uniswap.entitlements; sourceTree = ""; }; + F35AFD3B27EE49990011A725 /* OneSignalNotificationServiceExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = OneSignalNotificationServiceExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + F35AFD3D27EE49990011A725 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = ""; }; + F35AFD3F27EE49990011A725 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + F35AFD4727EE4B400011A725 /* OneSignalNotificationServiceExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = OneSignalNotificationServiceExtension.entitlements; sourceTree = ""; }; + F38DFE921F0467FF51A3D717 /* AssetChange.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AssetChange.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Unions/AssetChange.graphql.swift; sourceTree = ""; }; + F56CC08FBB20FAC0DF6B93DA /* Pods-OneSignalNotificationServiceExtension.beta.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-OneSignalNotificationServiceExtension.beta.xcconfig"; path = "Target Support Files/Pods-OneSignalNotificationServiceExtension/Pods-OneSignalNotificationServiceExtension.beta.xcconfig"; sourceTree = ""; }; + F70F4D05A45BC18EA88868A0 /* NftBalancesFilterInput.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = NftBalancesFilterInput.graphql.swift; path = WidgetsCore/MobileSchema/Schema/InputObjects/NftBalancesFilterInput.graphql.swift; sourceTree = ""; }; + F99987D29B574C20584CE11A /* NftApproveForAll.graphql.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = NftApproveForAll.graphql.swift; path = WidgetsCore/MobileSchema/Schema/Objects/NftApproveForAll.graphql.swift; sourceTree = ""; }; + FD54D51C296C79A4007A37E9 /* GoogleServiceInfo */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = GoogleServiceInfo; sourceTree = SOURCE_ROOT; }; + FD7304CD28A364FC0085BDEA /* Colors.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Colors.xcassets; path = Uniswap/Colors.xcassets; sourceTree = ""; }; + FD7304CF28A3650A0085BDEA /* Colors.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = Colors.swift; path = Uniswap/Colors.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 00E356EB1AD99517003FC87E /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + E4B3067A930D2E57558E5229 /* Pods_Uniswap_UniswapTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 072E23832A44D5BC006AD6C9 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + BA8FC9627A40644259D9E2F9 /* Pods_WidgetsCore.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 072E238A2A44D5BD006AD6C9 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 072E238E2A44D5BD006AD6C9 /* WidgetsCore.framework in Frameworks */, + D7926D4A878B2237137B300F /* Pods_WidgetsCoreTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 072F6C1C2A44A32E00DA720A /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 9F78980B2A819CC4004D5A98 /* SwiftUI.framework in Frameworks */, + 9F7898112A819D32004D5A98 /* WidgetsCore.framework in Frameworks */, + 072F6C212A44A32E00DA720A /* WidgetKit.framework in Frameworks */, + DE2F24512E7204C2CA255C50 /* Pods_Widgets.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 078E79422A55EB3300F59CF2 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 078E79472A55EB3300F59CF2 /* Intents.framework in Frameworks */, + 9F78980E2A819D2B004D5A98 /* WidgetsCore.framework in Frameworks */, + 70EB8338CA39744B7DBD553E /* Pods_WidgetIntentExtension.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 13B07F8C1A680F5B00A75B9A /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 9F7898142A819D62004D5A98 /* WidgetsCore.framework in Frameworks */, + 463BA791004B1B7AC1773914 /* Pods_Uniswap.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + F35AFD3827EE49990011A725 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 8273FC23FB1AE47B80C5E09F /* Pods_OneSignalNotificationServiceExtension.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 00E356EF1AD99517003FC87E /* UniswapTests */ = { + isa = PBXGroup; + children = ( + 6C8EFC2C2891B99100FBD8EB /* EncryptionHelperTests.swift */, + 00E356F21AD99517003FC87E /* UniswapTests.m */, + 00E356F01AD99517003FC87E /* Supporting Files */, + ); + path = UniswapTests; + sourceTree = ""; + }; + 00E356F01AD99517003FC87E /* Supporting Files */ = { + isa = PBXGroup; + children = ( + 00E356F11AD99517003FC87E /* Info.plist */, + ); + name = "Supporting Files"; + sourceTree = ""; + }; + 03C788212C10E71D0011E5DC /* Shared */ = { + isa = PBXGroup; + children = ( + 03C788222C10E7390011E5DC /* ActionButtons.swift */, + 03D2F3172C218D380030D987 /* RelativeOffsetView.swift */, + ); + path = Shared; + sourceTree = ""; + }; + 03DD298C2A4CE34B00E3E0F5 /* Appearance */ = { + isa = PBXGroup; + children = ( + 9FCEBF022A95A99B0079EDDB /* RCTThemeModule.h */, + 9FCEBF032A95A99B0079EDDB /* RCTThemeModule.m */, + ); + name = Appearance; + sourceTree = ""; + }; + 0703EE042A57350400AED1DA /* Utils */ = { + isa = PBXGroup; + children = ( + 0741433F2A588F5800A157D3 /* Structs.swift */, + 0767E0392A65CA590042ADA2 /* UI */, + 0703EE022A5734A600AED1DA /* UserDefaults.swift */, + 0743223F2A841BBD00F8518D /* Constants.swift */, + 07F136412A5763480067004F /* Network.swift */, + 072F6C372A44BECC00DA720A /* Logging.swift */, + 07F1363F2A575EC00067004F /* DataQueries.swift */, + 07F5CF742A7020FD00C648A5 /* Format.swift */, + ); + path = Utils; + sourceTree = ""; + }; + 072E23872A44D5BD006AD6C9 /* WidgetsCore */ = { + isa = PBXGroup; + children = ( + 07F0C28E2A5F3E2E00D5353E /* Env.swift */, + 073C67F42A5C8FBE00F6DAD8 /* MobileSchema */, + 072E23882A44D5BD006AD6C9 /* WidgetsCore.h */, + 0703EE042A57350400AED1DA /* Utils */, + ); + path = WidgetsCore; + sourceTree = ""; + }; + 072E23932A44D5BD006AD6C9 /* WidgetsCoreTests */ = { + isa = PBXGroup; + children = ( + 072E23942A44D5BD006AD6C9 /* WidgetsCoreTests.swift */, + 074086F92A703B76006E3053 /* FormatTests.swift */, + ); + path = WidgetsCoreTests; + sourceTree = ""; + }; + 072F6C242A44A32E00DA720A /* Widgets */ = { + isa = PBXGroup; + children = ( + 072E23A62A44D61A006AD6C9 /* Widgets.entitlements */, + 072F6C252A44A32E00DA720A /* WidgetsBundle.swift */, + 072F6C272A44A32E00DA720A /* TokenPriceWidget.swift */, + 072F6C292A44A32E00DA720A /* TokenPriceWidget.intentdefinition */, + 072F6C2A2A44A32F00DA720A /* Assets.xcassets */, + 072F6C2C2A44A32F00DA720A /* Info.plist */, + ); + path = Widgets; + sourceTree = ""; + }; + 073C67F42A5C8FBE00F6DAD8 /* MobileSchema */ = { + isa = PBXGroup; + children = ( + 074321A32A83E3C900F8518D /* Fragments */, + 0743218E2A83E3C900F8518D /* Operations */, + 074321A62A83E3C900F8518D /* Schema */, + ); + path = MobileSchema; + sourceTree = ""; + }; + 0743218E2A83E3C900F8518D /* Operations */ = { + isa = PBXGroup; + children = ( + 0743218F2A83E3C900F8518D /* Queries */, + ); + path = Operations; + sourceTree = ""; + }; + 0743218F2A83E3C900F8518D /* Queries */ = { + isa = PBXGroup; + children = ( + ); + path = Queries; + sourceTree = ""; + }; + 074321A32A83E3C900F8518D /* Fragments */ = { + isa = PBXGroup; + children = ( + ); + path = Fragments; + sourceTree = ""; + }; + 074321A62A83E3C900F8518D /* Schema */ = { + isa = PBXGroup; + children = ( + 074321A72A83E3C900F8518D /* Unions */, + 074321A92A83E3C900F8518D /* Enums */, + 074321B62A83E3C900F8518D /* Objects */, + 074321DE2A83E3C900F8518D /* SchemaConfiguration.swift */, + 074321DF2A83E3C900F8518D /* InputObjects */, + 074321E82A83E3C900F8518D /* Interfaces */, + ); + path = Schema; + sourceTree = ""; + }; + 074321A72A83E3C900F8518D /* Unions */ = { + isa = PBXGroup; + children = ( + ); + path = Unions; + sourceTree = ""; + }; + 074321A92A83E3C900F8518D /* Enums */ = { + isa = PBXGroup; + children = ( + ); + path = Enums; + sourceTree = ""; + }; + 074321B62A83E3C900F8518D /* Objects */ = { + isa = PBXGroup; + children = ( + ); + path = Objects; + sourceTree = ""; + }; + 074321DF2A83E3C900F8518D /* InputObjects */ = { + isa = PBXGroup; + children = ( + ); + path = InputObjects; + sourceTree = ""; + }; + 074321E82A83E3C900F8518D /* Interfaces */ = { + isa = PBXGroup; + children = ( + ); + path = Interfaces; + sourceTree = ""; + }; + 0767E0392A65CA590042ADA2 /* UI */ = { + isa = PBXGroup; + children = ( + 0767E0372A65C8330042ADA2 /* Colors.swift */, + 07F5CF702A6AD97D00C648A5 /* Chart.swift */, + 0783F7B32A619E7C009ED617 /* UIComponents.swift */, + 0767E03A2A65D2550042ADA2 /* Styling.swift */, + ); + path = UI; + sourceTree = ""; + }; + 078E79482A55EB3300F59CF2 /* WidgetIntentExtension */ = { + isa = PBXGroup; + children = ( + 070480372A58A507009006CE /* WidgetIntentExtension.entitlements */, + 078E79492A55EB3300F59CF2 /* IntentHandler.swift */, + 078E794B2A55EB3300F59CF2 /* Info.plist */, + ); + path = WidgetIntentExtension; + sourceTree = ""; + }; + 07B067692A7D6EC8001DD9B9 /* Widget */ = { + isa = PBXGroup; + children = ( + 07B0676A2A7D6EC8001DD9B9 /* RNWidgets.swift */, + 07B0676B2A7D6EC8001DD9B9 /* RNWidgets.m */, + ); + name = Widget; + path = Uniswap/Widget; + sourceTree = ""; + }; + 0DB282232CDADB260014CF77 /* EmbeddedWallet */ = { + isa = PBXGroup; + children = ( + 0DB282242CDADB260014CF77 /* EmbeddedWallet.m */, + 0DB282252CDADB260014CF77 /* EmbeddedWallet.swift */, + ); + name = EmbeddedWallet; + path = Uniswap/Onboarding/EmbeddedWallet; + sourceTree = ""; + }; + 0E2552A1FE1E968DB6E45EDB /* Enums */ = { + isa = PBXGroup; + children = ( + 25DDE9DCAFA0EE2838508994 /* Chain.graphql.swift */, + E5DDE9BCC6A539F6E9AE1664 /* Currency.graphql.swift */, + DC6517FF8972DA448CDD2DFC /* HistoryDuration.graphql.swift */, + 8D973A66C23859D34E6FE175 /* NftStandard.graphql.swift */, + BE4AB3FCC91BE6C73557E0E3 /* ProtectionAttackType.graphql.swift */, + CA586C522ABE5DA5363D27C7 /* ProtectionResult.graphql.swift */, + 41FFD02A227ACFB661A44813 /* SafetyLevel.graphql.swift */, + 132FC1B05AE3D510C3B71EBA /* SwapOrderStatus.graphql.swift */, + 0A7D41CB7AA0496123E2153D /* SwapOrderType.graphql.swift */, + 3D96819BA563CB218D29C657 /* TokenSortableField.graphql.swift */, + CE5E0F71E23968DEFCEB7A4B /* TokenStandard.graphql.swift */, + 4EC9CABC0B4E73B25C7A2128 /* TransactionDirection.graphql.swift */, + A182D5FF8C0919083F2E333C /* TransactionStatus.graphql.swift */, + 37A6D5D76D31F8C5CDD29814 /* TransactionType.graphql.swift */, + ); + name = Enums; + sourceTree = ""; + }; + 13B07FAE1A68108700A75B9A /* Uniswap */ = { + isa = PBXGroup; + children = ( + AC0EE0972BD826E700BCCF07 /* PrivacyInfo.xcprivacy */, + 8EA8AB3F2AB7ED76004E7EF3 /* Icons */, + 8E566D9F2AA1095000D4AA76 /* Components */, + 07B067692A7D6EC8001DD9B9 /* Widget */, + 03DD298C2A4CE34B00E3E0F5 /* Appearance */, + F35AFD3627EE49230011A725 /* Uniswap.entitlements */, + 45FFF7DD2E8C2A3A00362570 /* Notifications */, + 6C84F055283D83CF0071FA2E /* Onboarding */, + 6CE631B928186D4500716D29 /* WalletConnect */, + A32F9FBC272343C8002CFCDB /* GoogleService-Info.plist */, + 6CA91BD72A95223C00C4063E /* RNEthersRs */, + 6CA91BDD2A95226200C4063E /* RNCloudBackupsManager */, + 13B07FB51A68108700A75B9A /* Images.xcassets */, + 13B07FB61A68108700A75B9A /* Info.plist */, + BF9176E944C84910B1C0B057 /* SplashScreen.storyboard */, + FD7304CD28A364FC0085BDEA /* Colors.xcassets */, + FD7304CF28A3650A0085BDEA /* Colors.swift */, + 8B2A92162EB3E78E00990413 /* AppDelegate.swift */, + 8B2A92182EB3E79500990413 /* Uniswap-Bridging-Header.h */, + ); + name = Uniswap; + sourceTree = ""; + }; + 14138E8BFDEA86F0825B9C6D /* MobileSchema */ = { + isa = PBXGroup; + children = ( + A24882D069B34B7DC9E8B4CE /* MobileSchema.graphql.swift */, + 14D11F5F6A001B2F9CC05DC9 /* Fragments */, + 7A9EBBAEE00CEC84AFF4B49E /* Operations */, + 73DBB3ED6E3D25FBAA098F91 /* Schema */, + ); + name = MobileSchema; + sourceTree = ""; + }; + 14D11F5F6A001B2F9CC05DC9 /* Fragments */ = { + isa = PBXGroup; + children = ( + AB3EEA677984132330D8D279 /* HomeScreenTokenParts.graphql.swift */, + 7F0C115796D33739746778E3 /* TokenBalanceMainParts.graphql.swift */, + B97D41BC2760803FD1C7CB3C /* TokenBalanceParts.graphql.swift */, + 8D5FF4316461BD4FB5847D62 /* TokenBalanceQuantityParts.graphql.swift */, + 319AAC5377C60EB6579CC5C6 /* TokenBasicInfoParts.graphql.swift */, + BB9FB14C8B68CE1D05643256 /* TokenBasicProjectParts.graphql.swift */, + 413CEB2443D6DD1DA7CC7231 /* TokenFeeDataParts.graphql.swift */, + C6754D4BD9CE76AAC915F814 /* TokenMarketParts.graphql.swift */, + 0A266B733FD2FA9C1C1A7731 /* TokenParts.graphql.swift */, + 3BA467FB84503BB53B081C73 /* TokenProjectMarketsParts.graphql.swift */, + CC2EE4B5A1180F03030E80A9 /* TokenProjectTokensTvlParts.graphql.swift */, + 9F1055EFA8467996754739A7 /* TokenProjectUrlsParts.graphql.swift */, + 93D1BA7F635F61FC7E62C6C1 /* TokenProtectionInfoParts.graphql.swift */, + 43D7F54953D864DC067FADD1 /* TopTokenParts.graphql.swift */, + ); + name = Fragments; + sourceTree = ""; + }; + 2D16E6871FA4F8E400B85C8A /* Frameworks */ = { + isa = PBXGroup; + children = ( + 9F3500812A8AA5890077BFC5 /* EXSplashScreen.xcframework */, + ED297162215061F000B7C4FE /* JavaScriptCore.framework */, + 072F6C202A44A32E00DA720A /* WidgetKit.framework */, + 072F6C222A44A32E00DA720A /* SwiftUI.framework */, + 078E79462A55EB3300F59CF2 /* Intents.framework */, + 15092E550A1C78508ABA3280 /* Pods_OneSignalNotificationServiceExtension.framework */, + 2226DF79BEAFECEE11A51347 /* Pods_Uniswap.framework */, + 0929C0B4AE1570B8C0B45D4D /* Pods_Uniswap_UniswapTests.framework */, + 1064E23E366D0C2C2B20C30E /* Pods_WidgetIntentExtension.framework */, + 2E8B7D36D2E14D9488F351EB /* Pods_Widgets.framework */, + CB29AC0C0907A833F23D2C30 /* Pods_WidgetsCore.framework */, + 021E59CE7ECBD4FE0F3BFCFD /* Pods_WidgetsCoreTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + 2F1F2ABA5F5FE75352ED9B7C /* Objects */ = { + isa = PBXGroup; + children = ( + 74673620CE906C9AF34C6750 /* Amount.graphql.swift */, + 43AB42F3ED903CF49D7D4DE7 /* AmountChange.graphql.swift */, + 9BF540E1110D0FD1C219C45C /* ApplicationContract.graphql.swift */, + 8BB8B6395E3C86412FA793CB /* AssetActivity.graphql.swift */, + 201FF7E30EC98ABC121BC8BF /* BlockaidFees.graphql.swift */, + 44D824154584CD39C82904E9 /* BridgedWithdrawalInfo.graphql.swift */, + 6F02DBC2C51EFC6B1AA9C1CD /* DescriptionTranslations.graphql.swift */, + 6DCC7E203AB163C9C2D3D789 /* Dimensions.graphql.swift */, + 290B66F38FEC486AF1709BE4 /* FeeData.graphql.swift */, + 284C5150E9B50FB5BE531572 /* Image.graphql.swift */, + 2FB70424D7C456B9B72536C6 /* NetworkFee.graphql.swift */, + 42158AF500DDBEEF6B7E98E3 /* NftApproval.graphql.swift */, + F99987D29B574C20584CE11A /* NftApproveForAll.graphql.swift */, + CD3573B4E6FD61F5DDBD9C75 /* NftAsset.graphql.swift */, + C8BAFEAABF78617095D44E32 /* NftBalance.graphql.swift */, + 56093E216B82288E1A4018F0 /* NftBalanceConnection.graphql.swift */, + F182567B705FC0E15DA8F34A /* NftBalanceEdge.graphql.swift */, + 2ECFE86020AB7A252CDC9BB1 /* NftCollection.graphql.swift */, + 66697C913CF365C9460F42E6 /* NftCollectionMarket.graphql.swift */, + 9A2AA83B4F9F4290A2BC7CCB /* NftContract.graphql.swift */, + E21EB4FB53B7B6B5848946D9 /* NftProfile.graphql.swift */, + 792EF7A7820987EA9E99D792 /* NftTransfer.graphql.swift */, + 6AFC48B9A573915BFA6FC6E2 /* OffRampTransactionDetails.graphql.swift */, + 3063061F7C203A1DA47B8FBB /* OffRampTransfer.graphql.swift */, + 0E5478334318BD5D6F8366D6 /* OnRampServiceProvider.graphql.swift */, + 614A491FAB9659FA08CD9AE4 /* OnRampTransactionDetails.graphql.swift */, + 60195493B9C71EDEECA46E80 /* OnRampTransfer.graphql.swift */, + 5F2898E709490CC198B98228 /* PageInfo.graphql.swift */, + A34E3A6E13A4066767AA6757 /* Portfolio.graphql.swift */, + 346B304DE3207797FD1FDE71 /* ProtectionInfo.graphql.swift */, + 58641E30674C4C4241702902 /* Query.graphql.swift */, + 32764E27B5D9FCF0AA4217CF /* SwapOrderDetails.graphql.swift */, + 2C9E8E39C13967E447FC193F /* TimestampedAmount.graphql.swift */, + 88B481513A2E780E6489DC4F /* Token.graphql.swift */, + 009F4258CE111989223E99A4 /* TokenApproval.graphql.swift */, + 4AF8EE05291534B82ACC1105 /* TokenBalance.graphql.swift */, + A02D64B698D203A7F3643C18 /* TokenMarket.graphql.swift */, + A424FA93DB720DD7684C7674 /* TokenProject.graphql.swift */, + A3E638BE80543E905D072D7F /* TokenProjectMarket.graphql.swift */, + 93783F96FA74CB9A1BEB32EE /* TokenTransfer.graphql.swift */, + 47148657FBA8DCC92DD69573 /* TransactionDetails.graphql.swift */, + ); + name = Objects; + sourceTree = ""; + }; + 45FFF7DD2E8C2A3A00362570 /* Notifications */ = { + isa = PBXGroup; + children = ( + 45FFF7E02E8C2E6100362570 /* SilentPushEventEmitter.swift */, + 45FFF7DE2E8C2A6400362570 /* SilentPushEventEmitter.m */, + ); + name = Notifications; + path = Uniswap/Notifications; + sourceTree = ""; + }; + 47914D9EE3A4DE926EFC5089 /* UniswapTests */ = { + isa = PBXGroup; + children = ( + C26D739993D5C939C6FBB58A /* ExpoModulesProvider.swift */, + ); + name = UniswapTests; + sourceTree = ""; + }; + 49A0455CC5D07F432B367D3F /* Interfaces */ = { + isa = PBXGroup; + children = ( + A372929BEDB706B6144334F0 /* IAmount.graphql.swift */, + 1D88F29D12AB0D8B4C00D065 /* IContract.graphql.swift */, + ); + name = Interfaces; + sourceTree = ""; + }; + 5754C6A1B51170788A63F6F3 /* ExpoModulesProviders */ = { + isa = PBXGroup; + children = ( + 9759A762F61D6B2F01C79DBF /* Uniswap */, + 47914D9EE3A4DE926EFC5089 /* UniswapTests */, + ); + name = ExpoModulesProviders; + sourceTree = ""; + }; + 5841D897B122046172ACD989 /* WidgetsCore */ = { + isa = PBXGroup; + children = ( + 14138E8BFDEA86F0825B9C6D /* MobileSchema */, + ); + name = WidgetsCore; + sourceTree = ""; + }; + 5B4398EB2DD3B22C00F6BE08 /* PrivateKeyDisplay */ = { + isa = PBXGroup; + children = ( + 5B4398E82DD3B22C00F6BE08 /* PrivateKeyDisplayManager.m */, + 5B4398E92DD3B22C00F6BE08 /* PrivateKeyDisplayManager.swift */, + 5B4398EA2DD3B22C00F6BE08 /* PrivateKeyDisplayView.swift */, + ); + path = PrivateKeyDisplay; + sourceTree = ""; + }; + 6583DF0353495046646DE918 /* InputObjects */ = { + isa = PBXGroup; + children = ( + 957594D21A42B9D77F5AD685 /* ContractInput.graphql.swift */, + C394AC5DB58BDF1C33CC0927 /* NftBalanceAssetInput.graphql.swift */, + F70F4D05A45BC18EA88868A0 /* NftBalancesFilterInput.graphql.swift */, + 38EAF64134981DC76B6C7E90 /* OnRampTransactionsAuth.graphql.swift */, + E13150074BB66C20E7EA271A /* PortfolioValueModifier.graphql.swift */, + ); + name = InputObjects; + sourceTree = ""; + }; + 6BC7D07A2B5FF02400617C95 /* Scantastic */ = { + isa = PBXGroup; + children = ( + 6BC7D07B2B5FF02400617C95 /* ScantasticEncryption.m */, + 6BC7D07C2B5FF02400617C95 /* ScantasticEncryption.swift */, + 6BC7D07D2B5FF02400617C95 /* EncryptionUtils.swift */, + ); + name = Scantastic; + path = Uniswap/Onboarding/Scantastic; + sourceTree = ""; + }; + 6C84F055283D83CF0071FA2E /* Onboarding */ = { + isa = PBXGroup; + children = ( + 0DB282232CDADB260014CF77 /* EmbeddedWallet */, + 03C788212C10E71D0011E5DC /* Shared */, + 6BC7D07A2B5FF02400617C95 /* Scantastic */, + 8E89C3A52AB8AAA400C84DE5 /* Backup */, + 8EA8AB2F2AB7ED3C004E7EF3 /* Import */, + ); + name = Onboarding; + sourceTree = ""; + }; + 6CA91BD72A95223C00C4063E /* RNEthersRs */ = { + isa = PBXGroup; + children = ( + 649A7A762D9AE70B00B53589 /* KeychainConstants.swift */, + 649A7A772D9AE70B00B53589 /* KeychainUtils.swift */, + A3F0A5B0272B1DFA00895B25 /* KeychainSwiftDistrib.swift */, + 6CA91BD82A95223C00C4063E /* RNEthersRS-Bridging-Header.h */, + 6CA91BD92A95223C00C4063E /* RNEthersRS.swift */, + 6CA91BDA2A95223C00C4063E /* RnEthersRS.m */, + ); + name = RNEthersRs; + path = Uniswap/RNEthersRs; + sourceTree = ""; + }; + 6CA91BDD2A95226200C4063E /* RNCloudBackupsManager */ = { + isa = PBXGroup; + children = ( + 6CA91BDE2A95226200C4063E /* RNCloudStorageBackupsManager.m */, + 6CA91BDF2A95226200C4063E /* EncryptionHelper.swift */, + 6CA91BE02A95226200C4063E /* RNCloudStorageBackupsManager.swift */, + ); + name = RNCloudBackupsManager; + path = Uniswap/RNCloudBackupsManager; + sourceTree = ""; + }; + 6CE631B928186D4500716D29 /* WalletConnect */ = { + isa = PBXGroup; + children = ( + 9FCEBEFD2A95A8E00079EDDB /* RNWalletConnect.h */, + 9FCEBEFE2A95A8E00079EDDB /* RNWalletConnect.m */, + 9FCEBEFF2A95A8E00079EDDB /* RNWalletConnect.swift */, + ); + name = WalletConnect; + sourceTree = ""; + }; + 73DBB3ED6E3D25FBAA098F91 /* Schema */ = { + isa = PBXGroup; + children = ( + 0D25B69F4A69D733A5DAEC42 /* SchemaConfiguration.swift */, + 73D981A2D31B6F8822C19324 /* SchemaMetadata.graphql.swift */, + 0E2552A1FE1E968DB6E45EDB /* Enums */, + 6583DF0353495046646DE918 /* InputObjects */, + 49A0455CC5D07F432B367D3F /* Interfaces */, + 2F1F2ABA5F5FE75352ED9B7C /* Objects */, + C371BE586C38E8B49D49C32A /* Unions */, + ); + name = Schema; + sourceTree = ""; + }; + 7A9EBBAEE00CEC84AFF4B49E /* Operations */ = { + isa = PBXGroup; + children = ( + FAB5CD57887CDC6FE536B147 /* Queries */, + ); + name = Operations; + sourceTree = ""; + }; + 832341AE1AAA6A7D00B99B32 /* Libraries */ = { + isa = PBXGroup; + children = ( + ); + name = Libraries; + sourceTree = ""; + }; + 83CBB9F61A601CBA00E9B192 = { + isa = PBXGroup; + children = ( + AC2EF4022C914B1600EEEFDB /* fonts */, + AC2794442C51541E00F9AF68 /* sourcemaps-datadog.sh */, + FD54D51C296C79A4007A37E9 /* GoogleServiceInfo */, + 13B07FAE1A68108700A75B9A /* Uniswap */, + 832341AE1AAA6A7D00B99B32 /* Libraries */, + 00E356EF1AD99517003FC87E /* UniswapTests */, + F35AFD3C27EE49990011A725 /* OneSignalNotificationServiceExtension */, + 072F6C242A44A32E00DA720A /* Widgets */, + 072E23872A44D5BD006AD6C9 /* WidgetsCore */, + 072E23932A44D5BD006AD6C9 /* WidgetsCoreTests */, + 078E79482A55EB3300F59CF2 /* WidgetIntentExtension */, + 83CBBA001A601CBA00E9B192 /* Products */, + 2D16E6871FA4F8E400B85C8A /* Frameworks */, + E233CBF5F47BEE60B243DCF8 /* Pods */, + C2C18ECBEF5A4489BF3A314C /* Resources */, + 5754C6A1B51170788A63F6F3 /* ExpoModulesProviders */, + 5841D897B122046172ACD989 /* WidgetsCore */, + ); + indentWidth = 2; + sourceTree = ""; + tabWidth = 2; + usesTabs = 0; + }; + 83CBBA001A601CBA00E9B192 /* Products */ = { + isa = PBXGroup; + children = ( + 13B07F961A680F5B00A75B9A /* Uniswap.app */, + 00E356EE1AD99517003FC87E /* UniswapTests.xctest */, + F35AFD3B27EE49990011A725 /* OneSignalNotificationServiceExtension.appex */, + 072F6C1F2A44A32E00DA720A /* Widgets.appex */, + 072E23862A44D5BC006AD6C9 /* WidgetsCore.framework */, + 072E238D2A44D5BD006AD6C9 /* WidgetsCoreTests.xctest */, + 078E79452A55EB3300F59CF2 /* WidgetIntentExtension.appex */, + ); + name = Products; + sourceTree = ""; + }; + 8E566D9F2AA1095000D4AA76 /* Components */ = { + isa = PBXGroup; + children = ( + 5B4398EB2DD3B22C00F6BE08 /* PrivateKeyDisplay */, + 8ED0562B2AA78E2C009BD5A2 /* ScrollFadeExtensions.swift */, + ); + path = Components; + sourceTree = ""; + }; + 8E89C3A52AB8AAA400C84DE5 /* Backup */ = { + isa = PBXGroup; + children = ( + 8E89C3A62AB8AAA400C84DE5 /* MnemonicConfirmationView.swift */, + 8E89C3A72AB8AAA400C84DE5 /* MnemonicDisplayView.swift */, + 8E89C3A92AB8AAA400C84DE5 /* MnemonicConfirmationWordBankView.swift */, + 8E89C3AA2AB8AAA400C84DE5 /* MnemonicTextField.swift */, + 8E89C3AB2AB8AAA400C84DE5 /* MnemonicDisplayManager.m */, + 8E89C3AC2AB8AAA400C84DE5 /* MnemonicConfirmationManager.m */, + 8E89C3AD2AB8AAA400C84DE5 /* RNSwiftUI-Bridging-Header.h */, + ); + name = Backup; + path = Uniswap/Onboarding/Backup; + sourceTree = ""; + }; + 8EA8AB2F2AB7ED3C004E7EF3 /* Import */ = { + isa = PBXGroup; + children = ( + 8EA8AB302AB7ED3C004E7EF3 /* SeedPhraseInputManager.m */, + 8EA8AB312AB7ED3C004E7EF3 /* SeedPhraseInputViewModel.swift */, + 8EA8AB322AB7ED3C004E7EF3 /* SeedPhraseInputView.swift */, + 8EA8AB332AB7ED3C004E7EF3 /* SeedPhraseInputManager.swift */, + ); + name = Import; + path = Uniswap/Onboarding/Import; + sourceTree = ""; + }; + 8EA8AB3F2AB7ED76004E7EF3 /* Icons */ = { + isa = PBXGroup; + children = ( + 8EA8AB402AB7ED76004E7EF3 /* AlertTriangleIcon.swift */, + 8EBFB1542ABA6AA6006B32A8 /* PasteIcon.swift */, + 037C5AA92C04970B00B1D808 /* CopyIcon.swift */, + 5B4CEC5E2DD65DD4009F082B /* CopyIconOutline.swift */, + ); + name = Icons; + path = Uniswap/Icons; + sourceTree = ""; + }; + 9759A762F61D6B2F01C79DBF /* Uniswap */ = { + isa = PBXGroup; + children = ( + 4DF5F26A06553EFDD4D99214 /* ExpoModulesProvider.swift */, + ); + name = Uniswap; + sourceTree = ""; + }; + C2C18ECBEF5A4489BF3A314C /* Resources */ = { + isa = PBXGroup; + children = ( + 1834199AFFB04D91B05FFB64 /* InputMono-Regular.ttf */, + 6F33E8069B7B40AFB313B8B0 /* Basel-Grotesk-Book.otf */, + 3C606D2C81014A0A8898F38E /* Basel-Grotesk-Medium.otf */, + ); + name = Resources; + sourceTree = ""; + }; + C371BE586C38E8B49D49C32A /* Unions */ = { + isa = PBXGroup; + children = ( + B68E391195D4587F61A33380 /* ActivityDetails.graphql.swift */, + F38DFE921F0467FF51A3D717 /* AssetChange.graphql.swift */, + ); + name = Unions; + sourceTree = ""; + }; + E233CBF5F47BEE60B243DCF8 /* Pods */ = { + isa = PBXGroup; + children = ( + 3A2186B1FF7FB85663D96EA9 /* Pods-OneSignalNotificationServiceExtension.debug.xcconfig */, + 1193B3A845BC3BE8CAA00D01 /* Pods-OneSignalNotificationServiceExtension.release.xcconfig */, + 6F7814C6D40D9C348EA1F1C7 /* Pods-OneSignalNotificationServiceExtension.dev.xcconfig */, + F56CC08FBB20FAC0DF6B93DA /* Pods-OneSignalNotificationServiceExtension.beta.xcconfig */, + A7C9F415D0E128A43003E071 /* Pods-Uniswap.debug.xcconfig */, + 178644A78AB62609EFDB66B3 /* Pods-Uniswap.release.xcconfig */, + 56FE9C9AF785221B7E3F4C04 /* Pods-Uniswap.dev.xcconfig */, + 62CEA9F2D5176D20A6402A3E /* Pods-Uniswap.beta.xcconfig */, + 6F3DC921A65D749C0852B10C /* Pods-Uniswap-UniswapTests.debug.xcconfig */, + F1A3F4DDD7E40DA9E4BBAAD1 /* Pods-Uniswap-UniswapTests.release.xcconfig */, + 0712B3629C74D1F958DF35FB /* Pods-Uniswap-UniswapTests.dev.xcconfig */, + 4781CD4CDD95B5792B793F75 /* Pods-Uniswap-UniswapTests.beta.xcconfig */, + 8719E5872CC41AB64503E903 /* Pods-WidgetIntentExtension.debug.xcconfig */, + 08C60D53AB82A6D0D31D0F78 /* Pods-WidgetIntentExtension.release.xcconfig */, + 1CC6ADAADCA38FDAEB181E86 /* Pods-WidgetIntentExtension.dev.xcconfig */, + 7A7637BBC9B3A68E0338D96E /* Pods-WidgetIntentExtension.beta.xcconfig */, + D79B717BEAEA7857469D770A /* Pods-Widgets.debug.xcconfig */, + 82C9871585F60F92D079FB95 /* Pods-Widgets.release.xcconfig */, + 08EBF075A4482F701892270B /* Pods-Widgets.dev.xcconfig */, + 0C19DE44A750FB17647FF2B6 /* Pods-Widgets.beta.xcconfig */, + B0DA4D39B1A6D74A1D05B99F /* Pods-WidgetsCore.debug.xcconfig */, + 3E279F675B02CBC50D3B57D5 /* Pods-WidgetsCore.release.xcconfig */, + BCB2A43E5FB0D7B69CA02312 /* Pods-WidgetsCore.dev.xcconfig */, + 0B7E5D62E11408EB5F0F5A80 /* Pods-WidgetsCore.beta.xcconfig */, + 3D8FCE4CD401350CA74DCC89 /* Pods-WidgetsCoreTests.debug.xcconfig */, + C89238E3ED9F3AC98876B573 /* Pods-WidgetsCoreTests.release.xcconfig */, + 065A981F892F7A06A900FCD5 /* Pods-WidgetsCoreTests.dev.xcconfig */, + 4C445DB9798210862C34D0E0 /* Pods-WidgetsCoreTests.beta.xcconfig */, + 8CBD584CCF002F58176DA863 /* Pods-OneSignalNotificationServiceExtension.debugoptimized.xcconfig */, + 31CB18D02DE297D6DADB87C0 /* Pods-Uniswap.debugoptimized.xcconfig */, + 36F66895EB592B9F61AE658C /* Pods-Uniswap-UniswapTests.debugoptimized.xcconfig */, + 2590691EDB017AE6FB6C10AC /* Pods-WidgetIntentExtension.debugoptimized.xcconfig */, + 986A89D253EEB9BEBE5F08FF /* Pods-Widgets.debugoptimized.xcconfig */, + 24343423F6B8A16CB14F1E4C /* Pods-WidgetsCore.debugoptimized.xcconfig */, + CB72291CE6EFD3842A8E2A1D /* Pods-WidgetsCoreTests.debugoptimized.xcconfig */, + ); + path = Pods; + sourceTree = ""; + }; + F35AFD3C27EE49990011A725 /* OneSignalNotificationServiceExtension */ = { + isa = PBXGroup; + children = ( + 5E5E0A622D380F5700E166AA /* Env.swift */, + F35AFD4727EE4B400011A725 /* OneSignalNotificationServiceExtension.entitlements */, + F35AFD3D27EE49990011A725 /* NotificationService.swift */, + F35AFD3F27EE49990011A725 /* Info.plist */, + ); + path = OneSignalNotificationServiceExtension; + sourceTree = ""; + }; + FAB5CD57887CDC6FE536B147 /* Queries */ = { + isa = PBXGroup; + children = ( + 3D3112654E5722A2F862D3FC /* ConvertQuery.graphql.swift */, + 48DE72B77CD2D89B75BB934D /* FavoriteTokenCardQuery.graphql.swift */, + E21B5EF6BC4582DD2BB0DA13 /* FeedTransactionListQuery.graphql.swift */, + 2240891823DAE7BF8BB88FF4 /* HomeScreenTokensQuery.graphql.swift */, + E3AF9944E853669C2BC9B5AB /* MultiplePortfolioBalancesQuery.graphql.swift */, + 42013D777BAA40652AB28985 /* NftsQuery.graphql.swift */, + 0EA8FE9029A1E7C4EAD7F547 /* NftsTabQuery.graphql.swift */, + 8604CAA8F7E44D704A6F4AAF /* PortfolioBalancesQuery.graphql.swift */, + BB3CBF221D5D2E3C036A1BA0 /* SelectWalletScreenQuery.graphql.swift */, + E7ED83FFCB410FCCA2C6B97B /* TokenDetailsScreenQuery.graphql.swift */, + EDA02DA46906E4CDF827104A /* TokenPriceHistoryQuery.graphql.swift */, + 8C66DD1D03A824EB6A231DCE /* TokenProjectDescriptionQuery.graphql.swift */, + E1B3A0C152B683215291E3BD /* TokenProjectsQuery.graphql.swift */, + 16F706375DFAFB58F8440CF9 /* TokenQuery.graphql.swift */, + 5442480B3C91EDC515728606 /* TokensQuery.graphql.swift */, + C050FFEC067334CF05C17EA0 /* TopTokensQuery.graphql.swift */, + 3AE6A0CAEDE1DC1D2FA14DEA /* TransactionHistoryUpdaterQuery.graphql.swift */, + C27225607FACF638581E25B5 /* TransactionListQuery.graphql.swift */, + C0C956C520AAB54BCE8C5CCC /* WidgetTokensQuery.graphql.swift */, + ); + name = Queries; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXHeadersBuildPhase section */ + 072E23812A44D5BC006AD6C9 /* Headers */ = { + isa = PBXHeadersBuildPhase; + buildActionMask = 2147483647; + files = ( + 072E23962A44D5BD006AD6C9 /* WidgetsCore.h in Headers */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXHeadersBuildPhase section */ + +/* Begin PBXNativeTarget section */ + 00E356ED1AD99517003FC87E /* UniswapTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 00E357021AD99517003FC87E /* Build configuration list for PBXNativeTarget "UniswapTests" */; + buildPhases = ( + 100379B32AFE3ED61809176D /* [CP] Check Pods Manifest.lock */, + 6236BA814A452C299F46F817 /* [Expo] Configure project */, + 00E356EA1AD99517003FC87E /* Sources */, + 00E356EB1AD99517003FC87E /* Frameworks */, + 00E356EC1AD99517003FC87E /* Resources */, + F5C7F44CBF58F052A43EB4AA /* [CP] Embed Pods Frameworks */, + 24AD8CEA045C82D94D2A96F3 /* [CP] Copy Pods Resources */, + ); + buildRules = ( + ); + dependencies = ( + 00E356F51AD99517003FC87E /* PBXTargetDependency */, + ); + name = UniswapTests; + productName = UniswapTests; + productReference = 00E356EE1AD99517003FC87E /* UniswapTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 072E23852A44D5BC006AD6C9 /* WidgetsCore */ = { + isa = PBXNativeTarget; + buildConfigurationList = 072E23A42A44D5BD006AD6C9 /* Build configuration list for PBXNativeTarget "WidgetsCore" */; + buildPhases = ( + 420594480338AF2E35636F6B /* [CP] Check Pods Manifest.lock */, + 204FD9232D84400E00736C13 /* [GraphQL] Apollo Generate Swift */, + 204FD9222D843CEE00736C13 /* Copy Env Vars to Swift */, + 072E23812A44D5BC006AD6C9 /* Headers */, + 072E23822A44D5BC006AD6C9 /* Sources */, + 072E23832A44D5BC006AD6C9 /* Frameworks */, + 072E23842A44D5BC006AD6C9 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = WidgetsCore; + productName = UniswapWidgetsCore; + productReference = 072E23862A44D5BC006AD6C9 /* WidgetsCore.framework */; + productType = "com.apple.product-type.framework"; + }; + 072E238C2A44D5BD006AD6C9 /* WidgetsCoreTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 072E23A52A44D5BD006AD6C9 /* Build configuration list for PBXNativeTarget "WidgetsCoreTests" */; + buildPhases = ( + F655443D83433C1BB79AB354 /* [CP] Check Pods Manifest.lock */, + 072E23892A44D5BD006AD6C9 /* Sources */, + 072E238A2A44D5BD006AD6C9 /* Frameworks */, + 072E238B2A44D5BD006AD6C9 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 072E23902A44D5BD006AD6C9 /* PBXTargetDependency */, + 072E23922A44D5BD006AD6C9 /* PBXTargetDependency */, + ); + name = WidgetsCoreTests; + productName = UniswapWidgetsCoreTests; + productReference = 072E238D2A44D5BD006AD6C9 /* WidgetsCoreTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 072F6C1E2A44A32E00DA720A /* Widgets */ = { + isa = PBXNativeTarget; + buildConfigurationList = 072F6C362A44A32F00DA720A /* Build configuration list for PBXNativeTarget "Widgets" */; + buildPhases = ( + 7856E1172A714318AA224CFE /* [CP] Check Pods Manifest.lock */, + 072F6C1B2A44A32E00DA720A /* Sources */, + 072F6C1C2A44A32E00DA720A /* Frameworks */, + 072F6C1D2A44A32E00DA720A /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 0703EE092A57355400AED1DA /* PBXTargetDependency */, + ); + name = Widgets; + productName = UniswapWidgetExtension; + productReference = 072F6C1F2A44A32E00DA720A /* Widgets.appex */; + productType = "com.apple.product-type.app-extension"; + }; + 078E79442A55EB3300F59CF2 /* WidgetIntentExtension */ = { + isa = PBXNativeTarget; + buildConfigurationList = 078E794F2A55EB3400F59CF2 /* Build configuration list for PBXNativeTarget "WidgetIntentExtension" */; + buildPhases = ( + 39755FBAA6CFA5CF80E716DB /* [CP] Check Pods Manifest.lock */, + 078E79412A55EB3300F59CF2 /* Sources */, + 078E79422A55EB3300F59CF2 /* Frameworks */, + 078E79432A55EB3300F59CF2 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 9F7898062A819AA2004D5A98 /* PBXTargetDependency */, + ); + name = WidgetIntentExtension; + productName = UniswapWidgetsIntentExtension; + productReference = 078E79452A55EB3300F59CF2 /* WidgetIntentExtension.appex */; + productType = "com.apple.product-type.app-extension"; + }; + 13B07F861A680F5B00A75B9A /* Uniswap */ = { + isa = PBXNativeTarget; + buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "Uniswap" */; + buildPhases = ( + 683FB1CFA5FEF24043F65606 /* [CP] Check Pods Manifest.lock */, + FD10A7F022414F080027D42C /* Start Packager */, + 5930ECD30D77D41A3180451F /* [Expo] Configure project */, + 13B07F871A680F5B00A75B9A /* Sources */, + FD54D51B296C780A007A37E9 /* Copy configuration-specific GoogleServices-Info.plist */, + 13B07F8C1A680F5B00A75B9A /* Frameworks */, + 13B07F8E1A680F5B00A75B9A /* Resources */, + 00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */, + AC2794462C51756600F9AF68 /* Bundle and upload JS source maps for Datadog */, + F35AFD4627EE49990011A725 /* Embed App Extensions */, + 9F7898182A819D62004D5A98 /* Embed Frameworks */, + 163678CCBB906C7B12421609 /* [CP] Embed Pods Frameworks */, + 0487071ABBC71F28EF79F4AA /* [CP] Copy Pods Resources */, + 868B6279F959D6931E7A870E /* [CP-User] [RNFB] Core Configuration */, + ); + buildRules = ( + ); + dependencies = ( + F35AFD4127EE49990011A725 /* PBXTargetDependency */, + 072F6C302A44A32F00DA720A /* PBXTargetDependency */, + 078E794D2A55EB3300F59CF2 /* PBXTargetDependency */, + 9F7898172A819D62004D5A98 /* PBXTargetDependency */, + ); + name = Uniswap; + productName = Uniswap; + productReference = 13B07F961A680F5B00A75B9A /* Uniswap.app */; + productType = "com.apple.product-type.application"; + }; + F35AFD3A27EE49990011A725 /* OneSignalNotificationServiceExtension */ = { + isa = PBXNativeTarget; + buildConfigurationList = F35AFD4327EE49990011A725 /* Build configuration list for PBXNativeTarget "OneSignalNotificationServiceExtension" */; + buildPhases = ( + 49ACF6EF62101F01F694FBF6 /* [CP] Check Pods Manifest.lock */, + 204FD9212D843C9500736C13 /* Copy Env Vars to Swift */, + F35AFD3727EE49990011A725 /* Sources */, + F35AFD3827EE49990011A725 /* Frameworks */, + F35AFD3927EE49990011A725 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = OneSignalNotificationServiceExtension; + productName = OneSignalNotificationServiceExtension; + productReference = F35AFD3B27EE49990011A725 /* OneSignalNotificationServiceExtension.appex */; + productType = "com.apple.product-type.app-extension"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 83CBB9F71A601CBA00E9B192 /* Project object */ = { + isa = PBXProject; + attributes = { + LastSwiftUpdateCheck = 1420; + LastUpgradeCheck = 1210; + TargetAttributes = { + 00E356ED1AD99517003FC87E = { + CreatedOnToolsVersion = 6.2; + TestTargetID = 13B07F861A680F5B00A75B9A; + }; + 072E23852A44D5BC006AD6C9 = { + CreatedOnToolsVersion = 14.2; + LastSwiftMigration = 1420; + }; + 072E238C2A44D5BD006AD6C9 = { + CreatedOnToolsVersion = 14.2; + TestTargetID = 13B07F861A680F5B00A75B9A; + }; + 072F6C1E2A44A32E00DA720A = { + CreatedOnToolsVersion = 14.2; + }; + 078E79442A55EB3300F59CF2 = { + CreatedOnToolsVersion = 14.2; + }; + 13B07F861A680F5B00A75B9A = { + LastSwiftMigration = 1310; + }; + F35AFD3A27EE49990011A725 = { + CreatedOnToolsVersion = 13.2.1; + }; + }; + }; + buildConfigurationList = 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "Uniswap" */; + compatibilityVersion = "Xcode 12.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 83CBB9F61A601CBA00E9B192; + productRefGroup = 83CBBA001A601CBA00E9B192 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 13B07F861A680F5B00A75B9A /* Uniswap */, + 00E356ED1AD99517003FC87E /* UniswapTests */, + F35AFD3A27EE49990011A725 /* OneSignalNotificationServiceExtension */, + 072F6C1E2A44A32E00DA720A /* Widgets */, + 072E23852A44D5BC006AD6C9 /* WidgetsCore */, + 072E238C2A44D5BD006AD6C9 /* WidgetsCoreTests */, + 078E79442A55EB3300F59CF2 /* WidgetIntentExtension */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 00E356EC1AD99517003FC87E /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 072E23842A44D5BC006AD6C9 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 072E238B2A44D5BD006AD6C9 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 072F6C1D2A44A32E00DA720A /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 072F6C2B2A44A32F00DA720A /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 078E79432A55EB3300F59CF2 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 13B07F8E1A680F5B00A75B9A /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + FD7304CE28A364FC0085BDEA /* Colors.xcassets in Resources */, + A32F9FBD272343C9002CFCDB /* GoogleService-Info.plist in Resources */, + 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */, + 77CF6065C8A24FE48204A2C1 /* SplashScreen.storyboard in Resources */, + AC0EE0982BD826E700BCCF07 /* PrivacyInfo.xcprivacy in Resources */, + D3B63ACA9B0C42F68080B080 /* InputMono-Regular.ttf in Resources */, + AC2EF4032C914B1600EEEFDB /* fonts in Resources */, + A3551F2CAC134AD49D40927F /* Basel-Grotesk-Book.otf in Resources */, + B193AD315CF844A3BDC3D11D /* Basel-Grotesk-Medium.otf in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + F35AFD3927EE49990011A725 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + FD54D51D296C79A4007A37E9 /* GoogleServiceInfo in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Bundle React Native code and images"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "if [[ -f \"$PODS_ROOT/../.xcode.env\" ]]; then\n source \"$PODS_ROOT/../.xcode.env\"\nfi\nif [[ -f \"$PODS_ROOT/../.xcode.env.local\" ]]; then\n source \"$PODS_ROOT/../.xcode.env.local\"\nfi\n\n# The project root by default is one level up from the ios directory\nexport PROJECT_ROOT=\"$PROJECT_DIR\"/..\n\nif [[ \"$CONFIGURATION\" = *Debug* ]]; then\n export SKIP_BUNDLING=1\nfi\nif [[ -z \"$ENTRY_FILE\" ]]; then\n # Set the entry JS file using the bundler's entry resolution.\n export ENTRY_FILE=\"$(\"$NODE_BINARY\" -e \"require('expo/scripts/resolveAppEntry')\" \"$PROJECT_ROOT\" ios absolute | tail -n 1)\"\nfi\n\nif [[ -z \"$CLI_PATH\" ]]; then\n # Use Expo CLI\n export CLI_PATH=\"$(\"$NODE_BINARY\" --print \"require.resolve('@expo/cli')\")\"\nfi\nif [[ -z \"$BUNDLE_COMMAND\" ]]; then\n # Default Expo CLI command for bundling\n export BUNDLE_COMMAND=\"export:embed\"\nfi\n\n`\"$NODE_BINARY\" --print \"require('path').dirname(require.resolve('react-native/package.json')) + '/scripts/react-native-xcode.sh'\"`\n"; + }; + 0487071ABBC71F28EF79F4AA /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Uniswap/Pods-Uniswap-resources-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Copy Pods Resources"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Uniswap/Pods-Uniswap-resources-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Uniswap/Pods-Uniswap-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; + 100379B32AFE3ED61809176D /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Uniswap-UniswapTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 163678CCBB906C7B12421609 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Uniswap/Pods-Uniswap-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Uniswap/Pods-Uniswap-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Uniswap/Pods-Uniswap-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + 204FD9212D843C9500736C13 /* Copy Env Vars to Swift */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Copy Env Vars to Swift"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(SRCROOT)/OneSignalNotificationServiceExtension/Env.swift", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "cd ..\npython3 scripts/copy_env_vars_to_swift.py\necho \"OneSignalNotificationServiceExtension envs\"\nls -al ios/OneSignalNotificationServiceExtension\necho \"WidgetsCore envs\"\nls -al ios/WidgetsCore\n"; + }; + 204FD9222D843CEE00736C13 /* Copy Env Vars to Swift */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Copy Env Vars to Swift"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(SRCROOT)/WidgetsCore/Env.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Fragments/HomeScreenTokenParts.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Fragments/TokenBalanceMainParts.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Fragments/TokenBalanceParts.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Fragments/TokenBalanceQuantityParts.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Fragments/TokenBasicInfoParts.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Fragments/TokenBasicProjectParts.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Fragments/TokenFeeDataParts.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Fragments/TokenMarketParts.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Fragments/TokenParts.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Fragments/TokenProjectMarketsParts.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Fragments/TokenProjectTokensTvlParts.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Fragments/TokenProjectUrlsParts.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Fragments/TokenProtectionInfoParts.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Fragments/TopTokenParts.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/MobileSchema.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Operations/Queries/ConvertQuery.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Operations/Queries/ExploreSearchQuery.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Operations/Queries/FavoriteTokenCardQuery.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Operations/Queries/FeedTransactionListQuery.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Operations/Queries/HomeScreenTokensQuery.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Operations/Queries/MultiplePortfolioBalancesQuery.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Operations/Queries/NFTItemScreenQuery.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Operations/Queries/NftCollectionScreenQuery.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Operations/Queries/NftsQuery.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Operations/Queries/NftsTabQuery.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Operations/Queries/PortfolioBalancesQuery.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Operations/Queries/SelectWalletScreenQuery.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Operations/Queries/TokenDetailsScreenQuery.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Operations/Queries/TokenPriceHistoryQuery.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Operations/Queries/TokenProjectDescriptionQuery.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Operations/Queries/TokenProjectsQuery.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Operations/Queries/TokenQuery.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Operations/Queries/TokensQuery.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Operations/Queries/TopTokensQuery.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Operations/Queries/TransactionHistoryUpdaterQuery.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Operations/Queries/TransactionListQuery.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Operations/Queries/WidgetTokensQuery.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Enums/Chain.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Enums/Currency.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Enums/HistoryDuration.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Enums/NftActivityType.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Enums/NftMarketplace.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Enums/NftStandard.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Enums/ProtectionAttackType.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Enums/ProtectionResult.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Enums/SafetyLevel.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Enums/SwapOrderStatus.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Enums/SwapOrderType.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Enums/TokenSortableField.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Enums/TokenStandard.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Enums/TransactionDirection.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Enums/TransactionStatus.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Enums/TransactionType.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/InputObjects/ContractInput.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/InputObjects/NftActivityFilterInput.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/InputObjects/NftAssetTraitInput.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/InputObjects/NftAssetsFilterInput.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/InputObjects/NftBalanceAssetInput.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/InputObjects/NftBalancesFilterInput.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/InputObjects/OnRampTransactionsAuth.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/InputObjects/PortfolioValueModifier.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Interfaces/IAmount.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Interfaces/IContract.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Objects/Amount.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Objects/AmountChange.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Objects/ApplicationContract.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Objects/AssetActivity.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Objects/BlockaidFees.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Objects/BridgedWithdrawalInfo.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Objects/DescriptionTranslations.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Objects/Dimensions.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Objects/FeeData.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Objects/Image.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Objects/NetworkFee.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Objects/NftActivity.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Objects/NftActivityConnection.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Objects/NftActivityEdge.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Objects/NftApproval.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Objects/NftApproveForAll.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Objects/NftAsset.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Objects/NftAssetConnection.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Objects/NftAssetEdge.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Objects/NftAssetTrait.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Objects/NftBalance.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Objects/NftBalanceConnection.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Objects/NftBalanceEdge.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Objects/NftCollection.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Objects/NftCollectionConnection.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Objects/NftCollectionEdge.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Objects/NftCollectionMarket.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Objects/NftContract.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Objects/NftOrder.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Objects/NftOrderConnection.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Objects/NftOrderEdge.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Objects/NftProfile.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Objects/NftTransfer.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Objects/OnRampServiceProvider.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Objects/OnRampTransactionDetails.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Objects/OnRampTransfer.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Objects/OffRampTransactionDetails.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Objects/OffRampTransfer.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Objects/PageInfo.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Objects/Portfolio.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Objects/ProtectionInfo.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Objects/Query.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Objects/SwapOrderDetails.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Objects/TimestampedAmount.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Objects/Token.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Objects/TokenApproval.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Objects/TokenBalance.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Objects/TokenMarket.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Objects/TokenProject.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Objects/TokenProjectMarket.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Objects/TokenTransfer.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Objects/TransactionDetails.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/SchemaConfiguration.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/SchemaMetadata.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Unions/ActivityDetails.graphql.swift", + "$(SRCROOT)/WidgetsCore/MobileSchema/Schema/Unions/AssetChange.graphql.swift", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "cd ..\npython3 scripts/copy_env_vars_to_swift.py\n"; + }; + 204FD9232D84400E00736C13 /* [GraphQL] Apollo Generate Swift */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "[GraphQL] Apollo Generate Swift"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "# Remove all files inside MobileSchema except for README.md\nfind ./WidgetsCore/MobileSchema -mindepth 1 -maxdepth 1 ! -name 'README.md' -exec rm -rf {} +\n\"${PODS_ROOT}/Apollo/apollo-ios-cli\" generate\n"; + }; + 24AD8CEA045C82D94D2A96F3 /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Uniswap-UniswapTests/Pods-Uniswap-UniswapTests-resources-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Copy Pods Resources"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Uniswap-UniswapTests/Pods-Uniswap-UniswapTests-resources-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Uniswap-UniswapTests/Pods-Uniswap-UniswapTests-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; + 39755FBAA6CFA5CF80E716DB /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-WidgetIntentExtension-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 420594480338AF2E35636F6B /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-WidgetsCore-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 49ACF6EF62101F01F694FBF6 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-OneSignalNotificationServiceExtension-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 5930ECD30D77D41A3180451F /* [Expo] Configure project */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "[Expo] Configure project"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "# This script configures Expo modules and generates the modules provider file.\nbash -l -c \"./Pods/Target\\ Support\\ Files/Pods-Uniswap/expo-configure-project.sh\"\n"; + }; + 6236BA814A452C299F46F817 /* [Expo] Configure project */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "[Expo] Configure project"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "# This script configures Expo modules and generates the modules provider file.\nbash -l -c \"./Pods/Target\\ Support\\ Files/Pods-Uniswap-UniswapTests/expo-configure-project.sh\"\n"; + }; + 683FB1CFA5FEF24043F65606 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Uniswap-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 7856E1172A714318AA224CFE /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Widgets-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 868B6279F959D6931E7A870E /* [CP-User] [RNFB] Core Configuration */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "$(BUILT_PRODUCTS_DIR)/$(INFOPLIST_PATH)", + ); + name = "[CP-User] [RNFB] Core Configuration"; + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "#!/usr/bin/env bash\n#\n# Copyright (c) 2016-present Invertase Limited & Contributors\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this library except in compliance with the License.\n# You may obtain a copy of the License at\n#\n# http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\n##########################################################################\n##########################################################################\n#\n# NOTE THAT IF YOU CHANGE THIS FILE YOU MUST RUN pod install AFTERWARDS\n#\n# This file is installed as an Xcode build script in the project file\n# by cocoapods, and you will not see your changes until you pod install\n#\n##########################################################################\n##########################################################################\n\nset -e\n\n_MAX_LOOKUPS=2;\n_SEARCH_RESULT=''\n_RN_ROOT_EXISTS=''\n_CURRENT_LOOKUPS=1\n_JSON_ROOT=\"'react-native'\"\n_JSON_FILE_NAME='firebase.json'\n_JSON_OUTPUT_BASE64='e30=' # { }\n_CURRENT_SEARCH_DIR=${PROJECT_DIR}\n_PLIST_BUDDY=/usr/libexec/PlistBuddy\n_TARGET_PLIST=\"${BUILT_PRODUCTS_DIR}/${INFOPLIST_PATH}\"\n_DSYM_PLIST=\"${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/Info.plist\"\n\n# plist arrays\n_PLIST_ENTRY_KEYS=()\n_PLIST_ENTRY_TYPES=()\n_PLIST_ENTRY_VALUES=()\n\nfunction setPlistValue {\n echo \"info: setting plist entry '$1' of type '$2' in file '$4'\"\n ${_PLIST_BUDDY} -c \"Add :$1 $2 '$3'\" $4 || echo \"info: '$1' already exists\"\n}\n\nfunction getFirebaseJsonKeyValue () {\n if [[ ${_RN_ROOT_EXISTS} ]]; then\n ruby -Ku -e \"require 'rubygems';require 'json'; output=JSON.parse('$1'); puts output[$_JSON_ROOT]['$2']\"\n else\n echo \"\"\n fi;\n}\n\nfunction jsonBoolToYesNo () {\n if [[ $1 == \"false\" ]]; then\n echo \"NO\"\n elif [[ $1 == \"true\" ]]; then\n echo \"YES\"\n else echo \"NO\"\n fi\n}\n\necho \"info: -> RNFB build script started\"\necho \"info: 1) Locating ${_JSON_FILE_NAME} file:\"\n\nif [[ -z ${_CURRENT_SEARCH_DIR} ]]; then\n _CURRENT_SEARCH_DIR=$(pwd)\nfi;\n\nwhile true; do\n _CURRENT_SEARCH_DIR=$(dirname \"$_CURRENT_SEARCH_DIR\")\n if [[ \"$_CURRENT_SEARCH_DIR\" == \"/\" ]] || [[ ${_CURRENT_LOOKUPS} -gt ${_MAX_LOOKUPS} ]]; then break; fi;\n echo \"info: ($_CURRENT_LOOKUPS of $_MAX_LOOKUPS) Searching in '$_CURRENT_SEARCH_DIR' for a ${_JSON_FILE_NAME} file.\"\n _SEARCH_RESULT=$(find \"$_CURRENT_SEARCH_DIR\" -maxdepth 2 -name ${_JSON_FILE_NAME} -print | /usr/bin/head -n 1)\n if [[ ${_SEARCH_RESULT} ]]; then\n echo \"info: ${_JSON_FILE_NAME} found at $_SEARCH_RESULT\"\n break;\n fi;\n _CURRENT_LOOKUPS=$((_CURRENT_LOOKUPS+1))\ndone\n\nif [[ ${_SEARCH_RESULT} ]]; then\n _JSON_OUTPUT_RAW=$(cat \"${_SEARCH_RESULT}\")\n _RN_ROOT_EXISTS=$(ruby -Ku -e \"require 'rubygems';require 'json'; output=JSON.parse('$_JSON_OUTPUT_RAW'); puts output[$_JSON_ROOT]\" || echo '')\n\n if [[ ${_RN_ROOT_EXISTS} ]]; then\n if ! python3 --version >/dev/null 2>&1; then echo \"python3 not found, firebase.json file processing error.\" && exit 1; fi\n _JSON_OUTPUT_BASE64=$(python3 -c 'import json,sys,base64;print(base64.b64encode(bytes(json.dumps(json.loads(open('\"'${_SEARCH_RESULT}'\"', '\"'rb'\"').read())['${_JSON_ROOT}']), '\"'utf-8'\"')).decode())' || echo \"e30=\")\n fi\n\n _PLIST_ENTRY_KEYS+=(\"firebase_json_raw\")\n _PLIST_ENTRY_TYPES+=(\"string\")\n _PLIST_ENTRY_VALUES+=(\"$_JSON_OUTPUT_BASE64\")\n\n # config.app_data_collection_default_enabled\n _APP_DATA_COLLECTION_ENABLED=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"app_data_collection_default_enabled\")\n if [[ $_APP_DATA_COLLECTION_ENABLED ]]; then\n _PLIST_ENTRY_KEYS+=(\"FirebaseDataCollectionDefaultEnabled\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_APP_DATA_COLLECTION_ENABLED\")\")\n fi\n\n # config.analytics_auto_collection_enabled\n _ANALYTICS_AUTO_COLLECTION=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"analytics_auto_collection_enabled\")\n if [[ $_ANALYTICS_AUTO_COLLECTION ]]; then\n _PLIST_ENTRY_KEYS+=(\"FIREBASE_ANALYTICS_COLLECTION_ENABLED\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_AUTO_COLLECTION\")\")\n fi\n\n # config.analytics_collection_deactivated\n _ANALYTICS_DEACTIVATED=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"analytics_collection_deactivated\")\n if [[ $_ANALYTICS_DEACTIVATED ]]; then\n _PLIST_ENTRY_KEYS+=(\"FIREBASE_ANALYTICS_COLLECTION_DEACTIVATED\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_DEACTIVATED\")\")\n fi\n\n # config.analytics_idfv_collection_enabled\n _ANALYTICS_IDFV_COLLECTION=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"analytics_idfv_collection_enabled\")\n if [[ $_ANALYTICS_IDFV_COLLECTION ]]; then\n _PLIST_ENTRY_KEYS+=(\"GOOGLE_ANALYTICS_IDFV_COLLECTION_ENABLED\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_IDFV_COLLECTION\")\")\n fi\n\n # config.analytics_default_allow_analytics_storage\n _ANALYTICS_STORAGE=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"analytics_default_allow_analytics_storage\")\n if [[ $_ANALYTICS_STORAGE ]]; then\n _PLIST_ENTRY_KEYS+=(\"GOOGLE_ANALYTICS_DEFAULT_ALLOW_ANALYTICS_STORAGE\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_STORAGE\")\")\n fi\n\n # config.analytics_default_allow_ad_storage\n _ANALYTICS_AD_STORAGE=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"analytics_default_allow_ad_storage\")\n if [[ $_ANALYTICS_AD_STORAGE ]]; then\n _PLIST_ENTRY_KEYS+=(\"GOOGLE_ANALYTICS_DEFAULT_ALLOW_AD_STORAGE\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_AD_STORAGE\")\")\n fi\n\n # config.analytics_default_allow_ad_user_data\n _ANALYTICS_AD_USER_DATA=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"analytics_default_allow_ad_user_data\")\n if [[ $_ANALYTICS_AD_USER_DATA ]]; then\n _PLIST_ENTRY_KEYS+=(\"GOOGLE_ANALYTICS_DEFAULT_ALLOW_AD_USER_DATA\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_AD_USER_DATA\")\")\n fi\n\n # config.analytics_default_allow_ad_personalization_signals\n _ANALYTICS_PERSONALIZATION=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"analytics_default_allow_ad_personalization_signals\")\n if [[ $_ANALYTICS_PERSONALIZATION ]]; then\n _PLIST_ENTRY_KEYS+=(\"GOOGLE_ANALYTICS_DEFAULT_ALLOW_AD_PERSONALIZATION_SIGNALS\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_PERSONALIZATION\")\")\n fi\n\n # config.analytics_registration_with_ad_network_enabled\n _ANALYTICS_REGISTRATION_WITH_AD_NETWORK=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"google_analytics_registration_with_ad_network_enabled\")\n if [[ $_ANALYTICS_REGISTRATION_WITH_AD_NETWORK ]]; then\n _PLIST_ENTRY_KEYS+=(\"GOOGLE_ANALYTICS_REGISTRATION_WITH_AD_NETWORK_ENABLED\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_REGISTRATION_WITH_AD_NETWORK\")\")\n fi\n\n # config.google_analytics_automatic_screen_reporting_enabled\n _ANALYTICS_AUTO_SCREEN_REPORTING=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"google_analytics_automatic_screen_reporting_enabled\")\n if [[ $_ANALYTICS_AUTO_SCREEN_REPORTING ]]; then\n _PLIST_ENTRY_KEYS+=(\"FirebaseAutomaticScreenReportingEnabled\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_AUTO_SCREEN_REPORTING\")\")\n fi\n\n # config.perf_auto_collection_enabled\n _PERF_AUTO_COLLECTION=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"perf_auto_collection_enabled\")\n if [[ $_PERF_AUTO_COLLECTION ]]; then\n _PLIST_ENTRY_KEYS+=(\"firebase_performance_collection_enabled\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_PERF_AUTO_COLLECTION\")\")\n fi\n\n # config.perf_collection_deactivated\n _PERF_DEACTIVATED=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"perf_collection_deactivated\")\n if [[ $_PERF_DEACTIVATED ]]; then\n _PLIST_ENTRY_KEYS+=(\"firebase_performance_collection_deactivated\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_PERF_DEACTIVATED\")\")\n fi\n\n # config.messaging_auto_init_enabled\n _MESSAGING_AUTO_INIT=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"messaging_auto_init_enabled\")\n if [[ $_MESSAGING_AUTO_INIT ]]; then\n _PLIST_ENTRY_KEYS+=(\"FirebaseMessagingAutoInitEnabled\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_MESSAGING_AUTO_INIT\")\")\n fi\n\n # config.in_app_messaging_auto_colllection_enabled\n _FIAM_AUTO_INIT=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"in_app_messaging_auto_collection_enabled\")\n if [[ $_FIAM_AUTO_INIT ]]; then\n _PLIST_ENTRY_KEYS+=(\"FirebaseInAppMessagingAutomaticDataCollectionEnabled\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_FIAM_AUTO_INIT\")\")\n fi\n\n # config.app_check_token_auto_refresh\n _APP_CHECK_TOKEN_AUTO_REFRESH=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"app_check_token_auto_refresh\")\n if [[ $_APP_CHECK_TOKEN_AUTO_REFRESH ]]; then\n _PLIST_ENTRY_KEYS+=(\"FirebaseAppCheckTokenAutoRefreshEnabled\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_APP_CHECK_TOKEN_AUTO_REFRESH\")\")\n fi\n\n # config.crashlytics_disable_auto_disabler - undocumented for now - mainly for debugging, document if becomes useful\n _CRASHLYTICS_AUTO_DISABLE_ENABLED=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"crashlytics_disable_auto_disabler\")\n if [[ $_CRASHLYTICS_AUTO_DISABLE_ENABLED == \"true\" ]]; then\n echo \"Disabled Crashlytics auto disabler.\" # do nothing\n else\n _PLIST_ENTRY_KEYS+=(\"FirebaseCrashlyticsCollectionEnabled\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"NO\")\n fi\nelse\n _PLIST_ENTRY_KEYS+=(\"firebase_json_raw\")\n _PLIST_ENTRY_TYPES+=(\"string\")\n _PLIST_ENTRY_VALUES+=(\"$_JSON_OUTPUT_BASE64\")\n echo \"warning: A firebase.json file was not found, whilst this file is optional it is recommended to include it to configure firebase services in React Native Firebase.\"\nfi;\n\necho \"info: 2) Injecting Info.plist entries: \"\n\n# Log out the keys we're adding\nfor i in \"${!_PLIST_ENTRY_KEYS[@]}\"; do\n echo \" -> $i) ${_PLIST_ENTRY_KEYS[$i]}\" \"${_PLIST_ENTRY_TYPES[$i]}\" \"${_PLIST_ENTRY_VALUES[$i]}\"\ndone\n\nfor plist in \"${_TARGET_PLIST}\" \"${_DSYM_PLIST}\" ; do\n if [[ -f \"${plist}\" ]]; then\n\n # paths with spaces break the call to setPlistValue. temporarily modify\n # the shell internal field separator variable (IFS), which normally\n # includes spaces, to consist only of line breaks\n oldifs=$IFS\n IFS=\"\n\"\n\n for i in \"${!_PLIST_ENTRY_KEYS[@]}\"; do\n setPlistValue \"${_PLIST_ENTRY_KEYS[$i]}\" \"${_PLIST_ENTRY_TYPES[$i]}\" \"${_PLIST_ENTRY_VALUES[$i]}\" \"${plist}\"\n done\n\n # restore the original internal field separator value\n IFS=$oldifs\n else\n echo \"warning: A Info.plist build output file was not found (${plist})\"\n fi\ndone\n\necho \"info: <- RNFB build script finished\"\n"; + }; + AC2794462C51756600F9AF68 /* Bundle and upload JS source maps for Datadog */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Bundle and upload JS source maps for Datadog"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "set -e\n\n# Skip in debug configs\nif [ \"$CONFIGURATION\" = \"Debug\" ] || [ \"$CONFIGURATION\" = \"DebugOptimized\" ]; then\n echo \"warning: Skipping Datadog upload in $CONFIGURATION\"\n exit 0\nfi\n\nWITH_ENVIRONMENT=\"../../../node_modules/react-native/scripts/xcode/with-environment.sh\"\nREACT_NATIVE_XCODE=\"./sourcemaps-datadog.sh\"\n\nexport SOURCEMAP_FILE=$DERIVED_FILE_DIR/main.jsbundle.map\n\nif [[ -n \"$DATADOG_API_KEY\" && \"$SKIP_DATADOG_UPLOAD\" != \"true\" ]]; then\n echo \"warning: Starting Datadog Uploads\"\n echo \"warning: Build Configuration: $CONFIGURATION\"\n echo \"warning: Product Name: $PRODUCT_NAME\"\n echo \"warning: Source Map File: $SOURCEMAP_FILE\"\n echo \"warning: dSYM Path: $DWARF_DSYM_FOLDER_PATH\"\n echo \"\"\n\n # JS source maps\n echo \"warning: Uploading JS source maps...\"\n \n /bin/sh -c \"$WITH_ENVIRONMENT $REACT_NATIVE_XCODE\"\n\n echo \"warning: \"\n\n # iOS dSYM\n echo \"warning: Uploading iOS dSYMs...\"\n echo \"warning: dSYM command: datadog-ci dsyms upload $DWARF_DSYM_FOLDER_PATH\"\n\n DSYM_LOG=$(mktemp)\n if ../../../node_modules/.bin/datadog-ci dsyms upload $DWARF_DSYM_FOLDER_PATH 2>&1 | tee \"$DSYM_LOG\"; then\n echo \"warning: dSYM upload completed successfully\"\n rm -f \"$DSYM_LOG\"\n else\n DSYM_EXIT_CODE=$?\n echo \"warning: \"\n echo \"warning: dSYM Upload Failed\"\n echo \"warning: Exit Code: $DSYM_EXIT_CODE\"\n echo \"warning: \"\n echo \"warning:Full Error Output:\"\n echo \"warning: ---\"\n echo \"warning: $(cat \"$DSYM_LOG\")\"\n echo \"warning: ---\"\n echo \"warning: \"\n echo \"warning: Debug Information:\"\n echo \"warning: - datadog-ci version: $(../../../node_modules/.bin/datadog-ci version 2>&1 || echo 'Failed to get version')\"\n echo \"warning: - dSYM folder exists: $([ -d \\\"$DWARF_DSYM_FOLDER_PATH\\\" ] && echo 'Yes' || echo 'No')\"\n echo \"warning: - dSYM contents: $(ls -la \\\"$DWARF_DSYM_FOLDER_PATH\\\" 2>&1 || echo 'Failed to list')\"\n echo \"warning: - DATADOG_API_KEY set: $([ -n \\\"$DATADOG_API_KEY\\\" ] && echo 'Yes (${#DATADOG_API_KEY} chars)' || echo 'No')\"\n echo \"warning: - Working directory: $(pwd)\"\n echo \"warning: \"\n echo \"warning: This is non-critical. Build will continue.\"\n rm -f \"$DSYM_LOG\"\n fi\n\n echo \"warning: \"\n echo \"warning: Datadog upload phase completed (build continues regardless of upload status)\"\nelse\n echo \"warning: Skipping Datadog upload (DATADOG_API_KEY not set or SKIP_DATADOG_UPLOAD=true)\"\nfi\n\n# Always exit 0 to not fail the build\nexit 0\n"; + }; + F5C7F44CBF58F052A43EB4AA /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Uniswap-UniswapTests/Pods-Uniswap-UniswapTests-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Uniswap-UniswapTests/Pods-Uniswap-UniswapTests-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Uniswap-UniswapTests/Pods-Uniswap-UniswapTests-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + F655443D83433C1BB79AB354 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-WidgetsCoreTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + FD10A7F022414F080027D42C /* Start Packager */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Start Packager"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "if [[ -f \"$PODS_ROOT/../.xcode.env\" ]]; then\n source \"$PODS_ROOT/../.xcode.env\"\nfi\nif [[ -f \"$PODS_ROOT/../.xcode.env.local\" ]]; then\n source \"$PODS_ROOT/../.xcode.env.local\"\nfi\n\nexport RCT_METRO_PORT=\"${RCT_METRO_PORT:=8081}\"\necho \"export RCT_METRO_PORT=${RCT_METRO_PORT}\" > `$NODE_BINARY --print \"require('path').dirname(require.resolve('react-native/package.json')) + '/scripts/.packager.env'\"`\nif [ -z \"${RCT_NO_LAUNCH_PACKAGER+xxx}\" ] ; then\n if nc -w 5 -z localhost ${RCT_METRO_PORT} ; then\n if ! curl -s \"http://localhost:${RCT_METRO_PORT}/status\" | grep -q \"packager-status:running\" ; then\n echo \"Port ${RCT_METRO_PORT} already in use, packager is either not running or not running correctly\"\n exit 2\n fi\n else\n open `$NODE_BINARY --print \"require('path').dirname(require.resolve('expo/package.json')) + '/scripts/launchPackager.command'\"` || echo \"Can't start packager automatically\"\n fi\nfi\n"; + showEnvVarsInLog = 0; + }; + FD54D51B296C780A007A37E9 /* Copy configuration-specific GoogleServices-Info.plist */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Copy configuration-specific GoogleServices-Info.plist"; + outputFileListPaths = ( + ); + outputPaths = ( + "", + "$(SRCROOT)/GoogleService-Info.plist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "# Type a script or drag a script file from your workspace to insert its path.\n\nif [ $CONFIGURATION == \"Release\" ]\nthen\n cp \"${SRCROOT}/GoogleServiceInfo/GoogleService-Info-Prod.plist\" \"${SRCROOT}/GoogleService-Info.plist\"\nelif [ $CONFIGURATION == 'Debug' ] || [ $CONFIGURATION == 'DebugOptimized' ]\nthen\n cp \"${SRCROOT}/GoogleServiceInfo/GoogleService-Info-Dev.plist\" \"${SRCROOT}/GoogleService-Info.plist\"\nelse\n cp \"${SRCROOT}/GoogleServiceInfo/GoogleService-Info-$CONFIGURATION.plist\" \"${SRCROOT}/GoogleService-Info.plist\"\nfi\n"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 00E356EA1AD99517003FC87E /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 00E356F31AD99517003FC87E /* UniswapTests.m in Sources */, + 6C8EFC2D2891B99100FBD8EB /* EncryptionHelperTests.swift in Sources */, + 8385A47D3C765B841F450090 /* ExpoModulesProvider.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 072E23822A44D5BC006AD6C9 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 074322342A83E3CA00F8518D /* SchemaConfiguration.swift in Sources */, + 0703EE032A5734A600AED1DA /* UserDefaults.swift in Sources */, + 074322402A841BBD00F8518D /* Constants.swift in Sources */, + 0703EE052A57351800AED1DA /* Logging.swift in Sources */, + 0767E03B2A65D2550042ADA2 /* Styling.swift in Sources */, + 07F0C28F2A5F3E2E00D5353E /* Env.swift in Sources */, + 07F136422A5763480067004F /* Network.swift in Sources */, + 0767E0382A65C8330042ADA2 /* Colors.swift in Sources */, + 07F5CF752A7020FD00C648A5 /* Format.swift in Sources */, + 0783F7B42A619E7C009ED617 /* UIComponents.swift in Sources */, + 07F5CF712A6AD97D00C648A5 /* Chart.swift in Sources */, + 074143402A588F5800A157D3 /* Structs.swift in Sources */, + 07F136402A575EC00067004F /* DataQueries.swift in Sources */, + CFA408C9A92DB1F808BA57CD /* MobileSchema.graphql.swift in Sources */, + 1E01B5593B54A53D822972C1 /* HomeScreenTokenParts.graphql.swift in Sources */, + 333C6D9C267BB7783F9CA56E /* TokenBalanceMainParts.graphql.swift in Sources */, + 4AA5D71F770AA5E8A5AB8D6A /* TokenBalanceParts.graphql.swift in Sources */, + 4ECB63464100262E5A163345 /* TokenBalanceQuantityParts.graphql.swift in Sources */, + 90B93D7970186C8FC83F9292 /* TokenBasicInfoParts.graphql.swift in Sources */, + 74D0262298BD1BE5363444B5 /* TokenBasicProjectParts.graphql.swift in Sources */, + 6FAEE2539C8AFB99445F29BE /* TokenFeeDataParts.graphql.swift in Sources */, + 3B27BBB18E152F19B68D6FAB /* TokenMarketParts.graphql.swift in Sources */, + 7154698A2773F05CAD639211 /* TokenParts.graphql.swift in Sources */, + 23FC1CDFE7B5E25CEDF338F2 /* TokenProjectMarketsParts.graphql.swift in Sources */, + 7BDC2CBE10DC34F9BCFF86EA /* TokenProjectTokensTvlParts.graphql.swift in Sources */, + D0AC45D21734567F720877AD /* TokenProjectUrlsParts.graphql.swift in Sources */, + 722E7F2ECE654320C2452CC8 /* TokenProtectionInfoParts.graphql.swift in Sources */, + CA4994AA5EF5F36F42117ECA /* TopTokenParts.graphql.swift in Sources */, + D6B69EFB74ACEF485C289266 /* ConvertQuery.graphql.swift in Sources */, + B83B63F900E565F5C3F11589 /* FavoriteTokenCardQuery.graphql.swift in Sources */, + 0781032B76A7F58B5776207B /* FeedTransactionListQuery.graphql.swift in Sources */, + 890E98060D98F2F5617D1914 /* HomeScreenTokensQuery.graphql.swift in Sources */, + 462433873C56042BDAA3D8E1 /* MultiplePortfolioBalancesQuery.graphql.swift in Sources */, + B2692B7521F4E7767DECE974 /* NftsQuery.graphql.swift in Sources */, + 12A7E560842C954098B3A558 /* NftsTabQuery.graphql.swift in Sources */, + 29BC0E8E68D0365EB77456AF /* PortfolioBalancesQuery.graphql.swift in Sources */, + 63E84504F6D89EFBF97F4ACF /* SelectWalletScreenQuery.graphql.swift in Sources */, + 554AB3FB0D09B2DBC870E044 /* TokenDetailsScreenQuery.graphql.swift in Sources */, + F36073C9BDFF611771D04683 /* TokenPriceHistoryQuery.graphql.swift in Sources */, + 72567EB83861EBC04FAA0941 /* TokenProjectDescriptionQuery.graphql.swift in Sources */, + 19AB4E8D176A5656BBB2D797 /* TokenProjectsQuery.graphql.swift in Sources */, + 038FC04C9385A04F3EA5EBF4 /* TokenQuery.graphql.swift in Sources */, + F3E5BB84916B1F496A0C740D /* TokensQuery.graphql.swift in Sources */, + 6B8E2BD6B9A12FF05F826BDB /* TopTokensQuery.graphql.swift in Sources */, + 50F87796A463D0224875F0F4 /* TransactionHistoryUpdaterQuery.graphql.swift in Sources */, + 5FED383DD705AEFE8B7DA8DC /* TransactionListQuery.graphql.swift in Sources */, + 99ED3ACCFE04709CFA7FD490 /* WidgetTokensQuery.graphql.swift in Sources */, + CBFF07DDFB9C638D6E383290 /* SchemaConfiguration.swift in Sources */, + 5A9BA87096096C8BDC5FF210 /* SchemaMetadata.graphql.swift in Sources */, + F6EA6445BF4E0DEEDD076C7A /* Chain.graphql.swift in Sources */, + 57D4FACDC13E20AD220D7AEC /* Currency.graphql.swift in Sources */, + 6602483D8F6C6481FB8128E9 /* HistoryDuration.graphql.swift in Sources */, + B8ADB9BF8BB2D4E456D80B23 /* NftStandard.graphql.swift in Sources */, + 914DFEC13BB1853D25D23E34 /* ProtectionAttackType.graphql.swift in Sources */, + E774B10E7FB502BE97D3895B /* ProtectionResult.graphql.swift in Sources */, + E6CC238CB9AF565DE89087B7 /* SafetyLevel.graphql.swift in Sources */, + F7B8B2FAEB30B3343CFF6F29 /* SwapOrderStatus.graphql.swift in Sources */, + 340A73A44EC57C9376FF24E9 /* SwapOrderType.graphql.swift in Sources */, + 5F14564B8F6814A85EF53C46 /* TokenSortableField.graphql.swift in Sources */, + 4D846E92BAEED7A4F5B72919 /* TokenStandard.graphql.swift in Sources */, + F5FD688A33BCADBF601A2D7F /* TransactionDirection.graphql.swift in Sources */, + B009D229E81544EA0F47C6DD /* TransactionStatus.graphql.swift in Sources */, + D86DB22D27B00DA71F968324 /* TransactionType.graphql.swift in Sources */, + B4F84A94618C5A8D4F12007E /* ContractInput.graphql.swift in Sources */, + 121CD8F92A5E382AD1A84AA9 /* NftBalanceAssetInput.graphql.swift in Sources */, + 2FF904EA65F2A7B960547609 /* NftBalancesFilterInput.graphql.swift in Sources */, + F8E54B79B2742008CF1C0AA9 /* OnRampTransactionsAuth.graphql.swift in Sources */, + A3EEE7EE3CC93DDBA3CE6A5C /* PortfolioValueModifier.graphql.swift in Sources */, + D1642682124F702EE2454A64 /* IAmount.graphql.swift in Sources */, + 6E2E4593B2C40DBBA7C861BF /* IContract.graphql.swift in Sources */, + FAB057109F187E5375D137FD /* Amount.graphql.swift in Sources */, + 530FBF9EB2190828460BD496 /* AmountChange.graphql.swift in Sources */, + D420E10F9BA8C789530E70F4 /* ApplicationContract.graphql.swift in Sources */, + 95AA27A2056B51265EE643F1 /* AssetActivity.graphql.swift in Sources */, + DEBB37600A7C5C9A273DA38E /* BlockaidFees.graphql.swift in Sources */, + C403639465231038D196D7B5 /* BridgedWithdrawalInfo.graphql.swift in Sources */, + D179B805D7A77EA127F41F13 /* DescriptionTranslations.graphql.swift in Sources */, + 939256080FE6141E53B49E90 /* Dimensions.graphql.swift in Sources */, + 2DE0A41ECCCE673BAE68F715 /* FeeData.graphql.swift in Sources */, + 213BE982C7E2CCE304B88A6E /* Image.graphql.swift in Sources */, + 75FA3F2F18047B759472AEA8 /* NetworkFee.graphql.swift in Sources */, + 8989D182DEC9682661D588F3 /* NftApproval.graphql.swift in Sources */, + ECB6546D9AA307163172BEA3 /* NftApproveForAll.graphql.swift in Sources */, + 1A4145B8CC5F5F5B9297B9D6 /* NftAsset.graphql.swift in Sources */, + 6682BC9B8E38430BE82FADDA /* NftBalance.graphql.swift in Sources */, + 1F9BD005762B8BFA302E1B0C /* NftBalanceConnection.graphql.swift in Sources */, + 2C7ED9DF09914F82D85DE7C9 /* NftBalanceEdge.graphql.swift in Sources */, + 72272D14F0DB24D05F1162FE /* NftCollection.graphql.swift in Sources */, + ADD898CA4B87F6E7C990E268 /* NftCollectionMarket.graphql.swift in Sources */, + 5CC5D4B276CC1E3BA1906120 /* NftContract.graphql.swift in Sources */, + F784F1CA9FCEB5FFE4C1DD62 /* NftProfile.graphql.swift in Sources */, + 1B9BE681A85AE55F18054F37 /* NftTransfer.graphql.swift in Sources */, + A0ACC9C3ABF174616E0CBCA4 /* OffRampTransactionDetails.graphql.swift in Sources */, + 1FB2F652907A72BD28CF6DD4 /* OffRampTransfer.graphql.swift in Sources */, + DFBC904E6C0B818152912819 /* OnRampServiceProvider.graphql.swift in Sources */, + 8D90B4306573344A1FFC4832 /* OnRampTransactionDetails.graphql.swift in Sources */, + 100A336AFEC40D106F257664 /* OnRampTransfer.graphql.swift in Sources */, + 77206ACF669BD9DAAC096227 /* PageInfo.graphql.swift in Sources */, + BD07375602C71B48961CD5A0 /* Portfolio.graphql.swift in Sources */, + 9F1AE0C43E80AE592CA4AD7E /* ProtectionInfo.graphql.swift in Sources */, + 44B612C45F986F8ACB66D366 /* Query.graphql.swift in Sources */, + 0DF7B81A4727A8CA063CD5B5 /* SwapOrderDetails.graphql.swift in Sources */, + 7B49698C356931577828B41E /* TimestampedAmount.graphql.swift in Sources */, + AEA0B1AC57BB6F11F5861BCE /* Token.graphql.swift in Sources */, + 5F581541EFD85236EF98D3F3 /* TokenApproval.graphql.swift in Sources */, + AF467A9F1C200706537B24E5 /* TokenBalance.graphql.swift in Sources */, + 37A47FF4EEEB8E9D839D5DD6 /* TokenMarket.graphql.swift in Sources */, + EEEE88236C7EBC4B67BBE858 /* TokenProject.graphql.swift in Sources */, + B5EF58A67FC42D684B96C0F0 /* TokenProjectMarket.graphql.swift in Sources */, + 498F01286C660E8241774216 /* TokenTransfer.graphql.swift in Sources */, + 0901AAD2DCDD615889B6680A /* TransactionDetails.graphql.swift in Sources */, + 8C19BAD465EA9DFEB20EFB24 /* ActivityDetails.graphql.swift in Sources */, + F7CA47B62C91F3B0DA4106C0 /* AssetChange.graphql.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 072E23892A44D5BD006AD6C9 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 074086FA2A703B76006E3053 /* FormatTests.swift in Sources */, + 072E23952A44D5BD006AD6C9 /* WidgetsCoreTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 072F6C1B2A44A32E00DA720A /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 072F6C262A44A32E00DA720A /* WidgetsBundle.swift in Sources */, + 072F6C282A44A32E00DA720A /* TokenPriceWidget.swift in Sources */, + 072F6C2D2A44A32F00DA720A /* TokenPriceWidget.intentdefinition in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 078E79412A55EB3300F59CF2 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 07378F492A5C83ED00D26D3E /* IntentHandler.swift in Sources */, + 0741433E2A588CCC00A157D3 /* TokenPriceWidget.intentdefinition in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 13B07F871A680F5B00A75B9A /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 8EA8AB3E2AB7ED3C004E7EF3 /* SeedPhraseInputManager.swift in Sources */, + 8EA8AB412AB7ED76004E7EF3 /* AlertTriangleIcon.swift in Sources */, + 8E89C3B22AB8AAA400C84DE5 /* MnemonicTextField.swift in Sources */, + FD7304D028A3650A0085BDEA /* Colors.swift in Sources */, + 8E89C3AF2AB8AAA400C84DE5 /* MnemonicDisplayView.swift in Sources */, + 45FFF7E12E8C2E6900362570 /* SilentPushEventEmitter.swift in Sources */, + 6BC7D0802B5FF02400617C95 /* EncryptionUtils.swift in Sources */, + 03C788232C10E7390011E5DC /* ActionButtons.swift in Sources */, + 8EA8AB3B2AB7ED3C004E7EF3 /* SeedPhraseInputManager.m in Sources */, + 03D2F3182C218D390030D987 /* RelativeOffsetView.swift in Sources */, + 45FFF7DF2E8C2A8100362570 /* SilentPushEventEmitter.m in Sources */, + 6CA91BDB2A95223C00C4063E /* RNEthersRS.swift in Sources */, + 8EA8AB3C2AB7ED3C004E7EF3 /* SeedPhraseInputViewModel.swift in Sources */, + 072F6C2E2A44A32F00DA720A /* TokenPriceWidget.intentdefinition in Sources */, + 8E89C3B12AB8AAA400C84DE5 /* MnemonicConfirmationWordBankView.swift in Sources */, + 07B0676D2A7D6EC8001DD9B9 /* RNWidgets.m in Sources */, + 8EBFB1552ABA6AA6006B32A8 /* PasteIcon.swift in Sources */, + 6BC7D07F2B5FF02400617C95 /* ScantasticEncryption.swift in Sources */, + 07B0676C2A7D6EC8001DD9B9 /* RNWidgets.swift in Sources */, + 8E89C3AE2AB8AAA400C84DE5 /* MnemonicConfirmationView.swift in Sources */, + 8B2A92172EB3E78E00990413 /* AppDelegate.swift in Sources */, + 5B4398EC2DD3B22C00F6BE08 /* PrivateKeyDisplayManager.m in Sources */, + 5B4398ED2DD3B22C00F6BE08 /* PrivateKeyDisplayManager.swift in Sources */, + 5B4398EE2DD3B22C00F6BE08 /* PrivateKeyDisplayView.swift in Sources */, + 8ED0562C2AA78E2C009BD5A2 /* ScrollFadeExtensions.swift in Sources */, + 037C5AAA2C04970B00B1D808 /* CopyIcon.swift in Sources */, + 6CA91BE22A95226200C4063E /* EncryptionHelper.swift in Sources */, + 649A7A782D9AE70B00B53589 /* KeychainUtils.swift in Sources */, + 649A7A792D9AE70B00B53589 /* KeychainConstants.swift in Sources */, + 9FCEBF002A95A8E00079EDDB /* RNWalletConnect.m in Sources */, + 6CA91BE32A95226200C4063E /* RNCloudStorageBackupsManager.swift in Sources */, + 9FCEBF042A95A99C0079EDDB /* RCTThemeModule.m in Sources */, + 9FCEBF012A95A8E00079EDDB /* RNWalletConnect.swift in Sources */, + 6CA91BDC2A95223C00C4063E /* RnEthersRS.m in Sources */, + 6CA91BE12A95226200C4063E /* RNCloudStorageBackupsManager.m in Sources */, + 6BC7D07E2B5FF02400617C95 /* ScantasticEncryption.m in Sources */, + A3F0A5B1272B1DFA00895B25 /* KeychainSwiftDistrib.swift in Sources */, + 0DB282272CDADB260014CF77 /* EmbeddedWallet.swift in Sources */, + 8E89C3B42AB8AAA400C84DE5 /* MnemonicConfirmationManager.m in Sources */, + 1440B371A1C9A42F3E91DAAE /* ExpoModulesProvider.swift in Sources */, + 8E89C3B32AB8AAA400C84DE5 /* MnemonicDisplayManager.m in Sources */, + 0DB282262CDADB260014CF77 /* EmbeddedWallet.m in Sources */, + 5B4CEC5F2DD65DD4009F082B /* CopyIconOutline.swift in Sources */, + 8EA8AB3D2AB7ED3C004E7EF3 /* SeedPhraseInputView.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + F35AFD3727EE49990011A725 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + F35AFD3E27EE49990011A725 /* NotificationService.swift in Sources */, + 5E5E0A632D380F5800E166AA /* Env.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 00E356F51AD99517003FC87E /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 13B07F861A680F5B00A75B9A /* Uniswap */; + targetProxy = 00E356F41AD99517003FC87E /* PBXContainerItemProxy */; + }; + 0703EE092A57355400AED1DA /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 072E23852A44D5BC006AD6C9 /* WidgetsCore */; + targetProxy = 0703EE082A57355400AED1DA /* PBXContainerItemProxy */; + }; + 072E23902A44D5BD006AD6C9 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 072E23852A44D5BC006AD6C9 /* WidgetsCore */; + targetProxy = 072E238F2A44D5BD006AD6C9 /* PBXContainerItemProxy */; + }; + 072E23922A44D5BD006AD6C9 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 13B07F861A680F5B00A75B9A /* Uniswap */; + targetProxy = 072E23912A44D5BD006AD6C9 /* PBXContainerItemProxy */; + }; + 072F6C302A44A32F00DA720A /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 072F6C1E2A44A32E00DA720A /* Widgets */; + targetProxy = 072F6C2F2A44A32F00DA720A /* PBXContainerItemProxy */; + }; + 078E794D2A55EB3300F59CF2 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 078E79442A55EB3300F59CF2 /* WidgetIntentExtension */; + targetProxy = 078E794C2A55EB3300F59CF2 /* PBXContainerItemProxy */; + }; + 9F7898062A819AA2004D5A98 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 072E23852A44D5BC006AD6C9 /* WidgetsCore */; + targetProxy = 9F7898052A819AA2004D5A98 /* PBXContainerItemProxy */; + }; + 9F7898172A819D62004D5A98 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 072E23852A44D5BC006AD6C9 /* WidgetsCore */; + targetProxy = 9F7898162A819D62004D5A98 /* PBXContainerItemProxy */; + }; + F35AFD4127EE49990011A725 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = F35AFD3A27EE49990011A725 /* OneSignalNotificationServiceExtension */; + targetProxy = F35AFD4027EE49990011A725 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + 00E356F61AD99517003FC87E /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 6F3DC921A65D749C0852B10C /* Pods-Uniswap-UniswapTests.debug.xcconfig */; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = "$(inherited)"; + BUNDLE_LOADER = "$(TEST_HOST)"; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + INFOPLIST_FILE = UniswapTests/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + OTHER_LDFLAGS = ( + "-ObjC", + "-lc++", + "$(inherited)", + ); + OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG"; + PRODUCT_BUNDLE_IDENTIFIER = "org.reactjs.native.example.$(PRODUCT_NAME:rfc1034identifier)"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Uniswap.app/Uniswap"; + }; + name = Debug; + }; + 00E356F71AD99517003FC87E /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = F1A3F4DDD7E40DA9E4BBAAD1 /* Pods-Uniswap-UniswapTests.release.xcconfig */; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = "$(inherited)"; + BUNDLE_LOADER = "$(TEST_HOST)"; + COPY_PHASE_STRIP = NO; + INFOPLIST_FILE = UniswapTests/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + OTHER_LDFLAGS = ( + "-ObjC", + "-lc++", + "$(inherited)", + ); + OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; + PRODUCT_BUNDLE_IDENTIFIER = "org.reactjs.native.example.$(PRODUCT_NAME:rfc1034identifier)"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Uniswap.app/Uniswap"; + }; + name = Release; + }; + 072E239C2A44D5BD006AD6C9 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = B0DA4D39B1A6D74A1D05B99F /* Pods-WidgetsCore.debug.xcconfig */; + buildSettings = { + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = dwarf; + DEFINES_MODULE = YES; + DEVELOPMENT_TEAM = JH3UHGZD75; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = i386; + GCC_C_LANGUAGE_STANDARD = gnu11; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MARKETING_VERSION = 1.70; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG"; + PRODUCT_BUNDLE_IDENTIFIER = schemes.WidgetsCore; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_INSTALL_OBJC_HEADER = YES; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + VERSIONING_SYSTEM = "apple-generic"; + VERSION_INFO_PREFIX = ""; + }; + name = Debug; + }; + 072E239D2A44D5BD006AD6C9 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 3E279F675B02CBC50D3B57D5 /* Pods-WidgetsCore.release.xcconfig */; + buildSettings = { + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_IDENTITY = "iPhone Distribution"; + CODE_SIGN_STYLE = Manual; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEFINES_MODULE = YES; + DEVELOPMENT_TEAM = JH3UHGZD75; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = i386; + GCC_C_LANGUAGE_STANDARD = gnu11; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MARKETING_VERSION = 1.70; + MTL_FAST_MATH = YES; + OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; + PRODUCT_BUNDLE_IDENTIFIER = schemes.WidgetsCore; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_INSTALL_OBJC_HEADER = YES; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + VERSIONING_SYSTEM = "apple-generic"; + VERSION_INFO_PREFIX = ""; + }; + name = Release; + }; + 072E239E2A44D5BD006AD6C9 /* Dev */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = BCB2A43E5FB0D7B69CA02312 /* Pods-WidgetsCore.dev.xcconfig */; + buildSettings = { + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_IDENTITY = "iPhone Distribution"; + CODE_SIGN_STYLE = Manual; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEFINES_MODULE = YES; + DEVELOPMENT_TEAM = JH3UHGZD75; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = i386; + GCC_C_LANGUAGE_STANDARD = gnu11; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MARKETING_VERSION = 1.70; + MTL_FAST_MATH = YES; + OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; + PRODUCT_BUNDLE_IDENTIFIER = schemes.WidgetsCore; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_INSTALL_OBJC_HEADER = YES; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + VERSIONING_SYSTEM = "apple-generic"; + VERSION_INFO_PREFIX = ""; + }; + name = Dev; + }; + 072E239F2A44D5BD006AD6C9 /* Beta */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 0B7E5D62E11408EB5F0F5A80 /* Pods-WidgetsCore.beta.xcconfig */; + buildSettings = { + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_IDENTITY = "iPhone Distribution"; + CODE_SIGN_STYLE = Manual; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEFINES_MODULE = YES; + DEVELOPMENT_TEAM = JH3UHGZD75; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = i386; + GCC_C_LANGUAGE_STANDARD = gnu11; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MARKETING_VERSION = 1.70; + MTL_FAST_MATH = YES; + OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; + PRODUCT_BUNDLE_IDENTIFIER = schemes.WidgetsCore; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_INSTALL_OBJC_HEADER = YES; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + VERSIONING_SYSTEM = "apple-generic"; + VERSION_INFO_PREFIX = ""; + }; + name = Beta; + }; + 072E23A02A44D5BD006AD6C9 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 3D8FCE4CD401350CA74DCC89 /* Pods-WidgetsCoreTests.debug.xcconfig */; + buildSettings = { + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = JH3UHGZD75; + GCC_C_LANGUAGE_STANDARD = gnu11; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + MARKETING_VERSION = 1.70; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG"; + PRODUCT_BUNDLE_IDENTIFIER = schemes.WidgetsCoreTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Uniswap.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Uniswap"; + }; + name = Debug; + }; + 072E23A12A44D5BD006AD6C9 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = C89238E3ED9F3AC98876B573 /* Pods-WidgetsCoreTests.release.xcconfig */; + buildSettings = { + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = JH3UHGZD75; + GCC_C_LANGUAGE_STANDARD = gnu11; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + MARKETING_VERSION = 1.70; + MTL_FAST_MATH = YES; + OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; + PRODUCT_BUNDLE_IDENTIFIER = schemes.WidgetsCoreTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Uniswap.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Uniswap"; + }; + name = Release; + }; + 072E23A22A44D5BD006AD6C9 /* Dev */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 065A981F892F7A06A900FCD5 /* Pods-WidgetsCoreTests.dev.xcconfig */; + buildSettings = { + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = JH3UHGZD75; + GCC_C_LANGUAGE_STANDARD = gnu11; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + MARKETING_VERSION = 1.70; + MTL_FAST_MATH = YES; + OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; + PRODUCT_BUNDLE_IDENTIFIER = schemes.WidgetsCoreTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Uniswap.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Uniswap"; + }; + name = Dev; + }; + 072E23A32A44D5BD006AD6C9 /* Beta */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 4C445DB9798210862C34D0E0 /* Pods-WidgetsCoreTests.beta.xcconfig */; + buildSettings = { + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = JH3UHGZD75; + GCC_C_LANGUAGE_STANDARD = gnu11; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + MARKETING_VERSION = 1.70; + MTL_FAST_MATH = YES; + OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; + PRODUCT_BUNDLE_IDENTIFIER = schemes.WidgetsCoreTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Uniswap.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Uniswap"; + }; + name = Beta; + }; + 072F6C322A44A32F00DA720A /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = D79B717BEAEA7857469D770A /* Pods-Widgets.debug.xcconfig */; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = Widgets/Widgets.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = JH3UHGZD75; + GCC_C_LANGUAGE_STANDARD = gnu11; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Widgets/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = Widgets; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.70; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG"; + PRODUCT_BUNDLE_IDENTIFIER = com.uniswap.mobile.dev.widgets; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Debug; + }; + 072F6C332A44A32F00DA720A /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 82C9871585F60F92D079FB95 /* Pods-Widgets.release.xcconfig */; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = Widgets/Widgets.entitlements; + CODE_SIGN_IDENTITY = "iPhone Distribution"; + CODE_SIGN_STYLE = Manual; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = JH3UHGZD75; + GCC_C_LANGUAGE_STANDARD = gnu11; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Widgets/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = Widgets; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.70; + MTL_FAST_MATH = YES; + OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; + PRODUCT_BUNDLE_IDENTIFIER = com.uniswap.mobile.widgets; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = "match AppStore com.uniswap.mobile.widgets"; + SKIP_INSTALL = YES; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Release; + }; + 072F6C342A44A32F00DA720A /* Dev */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 08EBF075A4482F701892270B /* Pods-Widgets.dev.xcconfig */; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = Widgets/Widgets.entitlements; + CODE_SIGN_IDENTITY = "iPhone Distribution"; + CODE_SIGN_STYLE = Manual; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = JH3UHGZD75; + GCC_C_LANGUAGE_STANDARD = gnu11; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Widgets/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = Widgets; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.70; + MTL_FAST_MATH = YES; + OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; + PRODUCT_BUNDLE_IDENTIFIER = com.uniswap.mobile.dev.widgets; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = "match AppStore com.uniswap.mobile.dev.widgets"; + SKIP_INSTALL = YES; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Dev; + }; + 072F6C352A44A32F00DA720A /* Beta */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 0C19DE44A750FB17647FF2B6 /* Pods-Widgets.beta.xcconfig */; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = Widgets/Widgets.entitlements; + CODE_SIGN_IDENTITY = "iPhone Distribution"; + CODE_SIGN_STYLE = Manual; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = JH3UHGZD75; + GCC_C_LANGUAGE_STANDARD = gnu11; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Widgets/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = Widgets; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.70; + MTL_FAST_MATH = YES; + OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; + PRODUCT_BUNDLE_IDENTIFIER = com.uniswap.mobile.beta.widgets; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = "match AppStore com.uniswap.mobile.beta.widgets"; + SKIP_INSTALL = YES; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Beta; + }; + 078E79502A55EB3400F59CF2 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 8719E5872CC41AB64503E903 /* Pods-WidgetIntentExtension.debug.xcconfig */; + buildSettings = { + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = WidgetIntentExtension/WidgetIntentExtension.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = JH3UHGZD75; + GCC_C_LANGUAGE_STANDARD = gnu11; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = WidgetIntentExtension/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = WidgetIntentExtension; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.70; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG"; + PRODUCT_BUNDLE_IDENTIFIER = com.uniswap.mobile.dev.WidgetIntentExtension; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Debug; + }; + 078E79512A55EB3400F59CF2 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 08C60D53AB82A6D0D31D0F78 /* Pods-WidgetIntentExtension.release.xcconfig */; + buildSettings = { + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = WidgetIntentExtension/WidgetIntentExtension.entitlements; + CODE_SIGN_IDENTITY = "iPhone Distribution"; + CODE_SIGN_STYLE = Manual; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = JH3UHGZD75; + GCC_C_LANGUAGE_STANDARD = gnu11; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = WidgetIntentExtension/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = WidgetIntentExtension; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.70; + MTL_FAST_MATH = YES; + OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; + PRODUCT_BUNDLE_IDENTIFIER = com.uniswap.mobile.WidgetIntentExtension; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = "match AppStore com.uniswap.mobile.WidgetIntentExtension"; + SKIP_INSTALL = YES; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Release; + }; + 078E79522A55EB3400F59CF2 /* Dev */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 1CC6ADAADCA38FDAEB181E86 /* Pods-WidgetIntentExtension.dev.xcconfig */; + buildSettings = { + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = WidgetIntentExtension/WidgetIntentExtension.entitlements; + CODE_SIGN_IDENTITY = "iPhone Distribution"; + CODE_SIGN_STYLE = Manual; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = JH3UHGZD75; + GCC_C_LANGUAGE_STANDARD = gnu11; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = WidgetIntentExtension/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = WidgetIntentExtension; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.70; + MTL_FAST_MATH = YES; + OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; + PRODUCT_BUNDLE_IDENTIFIER = com.uniswap.mobile.dev.WidgetIntentExtension; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = "match AppStore com.uniswap.mobile.dev.WidgetIntentExtension"; + SKIP_INSTALL = YES; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Dev; + }; + 078E79532A55EB3400F59CF2 /* Beta */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7A7637BBC9B3A68E0338D96E /* Pods-WidgetIntentExtension.beta.xcconfig */; + buildSettings = { + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = WidgetIntentExtension/WidgetIntentExtension.entitlements; + CODE_SIGN_IDENTITY = "iPhone Distribution"; + CODE_SIGN_STYLE = Manual; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = JH3UHGZD75; + GCC_C_LANGUAGE_STANDARD = gnu11; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = WidgetIntentExtension/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = WidgetIntentExtension; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.70; + MTL_FAST_MATH = YES; + OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; + PRODUCT_BUNDLE_IDENTIFIER = com.uniswap.mobile.beta.WidgetIntentExtension; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = "match AppStore com.uniswap.mobile.beta.WidgetIntentExtension"; + SKIP_INSTALL = YES; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Beta; + }; + 13B07F941A680F5B00A75B9A /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = A7C9F415D0E128A43003E071 /* Pods-Uniswap.debug.xcconfig */; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = "$(inherited)"; + ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon$(BUNDLE_ID_SUFFIX)"; + ASSETCATALOG_COMPILER_LAUNCHIMAGE_NAME = ""; + BUNDLE_ID_SUFFIX = .dev; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Uniswap/Uniswap.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = JH3UHGZD75; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Uniswap/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.70; + OTHER_LDFLAGS = ( + "$(inherited)", + "-ObjC", + "-lc++", + ); + OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG"; + PRODUCT_BUNDLE_IDENTIFIER = com.uniswap.mobile.dev; + PRODUCT_NAME = Uniswap; + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OBJC_BRIDGING_HEADER = "Uniswap/RNEthersRs/RNEthersRS-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 13B07F951A680F5B00A75B9A /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 178644A78AB62609EFDB66B3 /* Pods-Uniswap.release.xcconfig */; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = "$(inherited)"; + ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon$(BUNDLE_ID_SUFFIX)"; + ASSETCATALOG_COMPILER_LAUNCHIMAGE_NAME = ""; + BUNDLE_ID_SUFFIX = ""; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Uniswap/Uniswap.entitlements; + CODE_SIGN_IDENTITY = "iPhone Distribution"; + CODE_SIGN_STYLE = Manual; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = JH3UHGZD75; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Uniswap/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.70; + OTHER_LDFLAGS = ( + "$(inherited)", + "-ObjC", + "-lc++", + ); + OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; + PRODUCT_BUNDLE_IDENTIFIER = com.uniswap.mobile; + PRODUCT_NAME = Uniswap; + PROVISIONING_PROFILE_SPECIFIER = "match AppStore com.uniswap.mobile"; + SWIFT_OBJC_BRIDGING_HEADER = "Uniswap/RNEthersRs/RNEthersRS-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; + 31A0707BE53EF0E39440A5EA /* DebugOptimized */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 31CB18D02DE297D6DADB87C0 /* Pods-Uniswap.debugoptimized.xcconfig */; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = "$(inherited)"; + ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon$(BUNDLE_ID_SUFFIX)"; + ASSETCATALOG_COMPILER_LAUNCHIMAGE_NAME = ""; + BUNDLE_ID_SUFFIX = .dev; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Uniswap/Uniswap.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = JH3UHGZD75; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Uniswap/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.70; + OTHER_LDFLAGS = ( + "$(inherited)", + "-ObjC", + "-lc++", + ); + OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG"; + PRODUCT_BUNDLE_IDENTIFIER = com.uniswap.mobile.dev; + PRODUCT_NAME = Uniswap; + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OBJC_BRIDGING_HEADER = "Uniswap/RNEthersRs/RNEthersRS-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = DebugOptimized; + }; + 32CACF657BA5BEC28E2F1B29 /* DebugOptimized */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = CB72291CE6EFD3842A8E2A1D /* Pods-WidgetsCoreTests.debugoptimized.xcconfig */; + buildSettings = { + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = JH3UHGZD75; + GCC_C_LANGUAGE_STANDARD = gnu11; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + MARKETING_VERSION = 1.70; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG"; + PRODUCT_BUNDLE_IDENTIFIER = schemes.WidgetsCoreTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Uniswap.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Uniswap"; + }; + name = DebugOptimized; + }; + 699CC042160867DCE68402B2 /* DebugOptimized */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; + CLANG_CXX_LANGUAGE_STANDARD = "c++20"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COMPILER_INDEX_STORE_ENABLE = NO; + COPY_PHASE_STRIP = NO; + DEVELOPMENT_TEAM = CGCYLJG7GA; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = i386; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = s; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_SYMBOLS_PRIVATE_EXTERN = NO; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = NO; + GCC_WARN_UNUSED_FUNCTION = NO; + GCC_WARN_UNUSED_VARIABLE = NO; + HEADER_SEARCH_PATHS = ( + "$(inherited)", + "${PODS_CONFIGURATION_BUILD_DIR}/ReactCommon/ReactCommon.framework/Headers", + "${PODS_CONFIGURATION_BUILD_DIR}/ReactCommon/ReactCommon.framework/Headers/react/nativemodule/core", + "${PODS_CONFIGURATION_BUILD_DIR}/ReactCommon-Samples/ReactCommon_Samples.framework/Headers", + "${PODS_CONFIGURATION_BUILD_DIR}/ReactCommon-Samples/ReactCommon_Samples.framework/Headers/platform/ios", + "${PODS_CONFIGURATION_BUILD_DIR}/React-Fabric/React_Fabric.framework/Headers/react/renderer/components/view/platform/cxx", + "${PODS_CONFIGURATION_BUILD_DIR}/React-NativeModulesApple/React_NativeModulesApple.framework/Headers", + "${PODS_CONFIGURATION_BUILD_DIR}/React-graphics/React_graphics.framework/Headers", + "${PODS_CONFIGURATION_BUILD_DIR}/React-graphics/React_graphics.framework/Headers/react/renderer/graphics/platform/ios", + ); + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + LD_RUNPATH_SEARCH_PATHS = ( + /usr/lib/swift, + "$(inherited)", + ); + LIBRARY_SEARCH_PATHS = ( + "$(SDKROOT)/usr/lib/swift", + "\"$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)\"", + "\"$(inherited)\"", + ); + MTL_ENABLE_DEBUG_INFO = NO; + ONLY_ACTIVE_ARCH = YES; + OTHER_CFLAGS = "$(inherited)"; + OTHER_CPLUSPLUSFLAGS = "$(inherited)"; + OTHER_LDFLAGS = "$(inherited)"; + REACT_NATIVE_PATH = "${PODS_ROOT}/../../../../node_modules/react-native"; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG"; + SWIFT_ENFORCE_EXCLUSIVE_ACCESS = "compile-time"; + SWIFT_VERSION = 5.0; + USE_HERMES = true; + }; + name = DebugOptimized; + }; + 6DC48512A1ADEC1FF10D690A /* DebugOptimized */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 36F66895EB592B9F61AE658C /* Pods-Uniswap-UniswapTests.debugoptimized.xcconfig */; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = "$(inherited)"; + BUNDLE_LOADER = "$(TEST_HOST)"; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + INFOPLIST_FILE = UniswapTests/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + OTHER_LDFLAGS = ( + "-ObjC", + "-lc++", + "$(inherited)", + ); + OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG"; + PRODUCT_BUNDLE_IDENTIFIER = "org.reactjs.native.example.$(PRODUCT_NAME:rfc1034identifier)"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Uniswap.app/Uniswap"; + }; + name = DebugOptimized; + }; + 7385AD0991A48D80AD74DA33 /* DebugOptimized */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 24343423F6B8A16CB14F1E4C /* Pods-WidgetsCore.debugoptimized.xcconfig */; + buildSettings = { + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = dwarf; + DEFINES_MODULE = YES; + DEVELOPMENT_TEAM = JH3UHGZD75; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = i386; + GCC_C_LANGUAGE_STANDARD = gnu11; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MARKETING_VERSION = 1.70; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG"; + PRODUCT_BUNDLE_IDENTIFIER = schemes.WidgetsCore; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_INSTALL_OBJC_HEADER = YES; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + VERSIONING_SYSTEM = "apple-generic"; + VERSION_INFO_PREFIX = ""; + }; + name = DebugOptimized; + }; + 7932E66A3404F08533393AEE /* DebugOptimized */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 2590691EDB017AE6FB6C10AC /* Pods-WidgetIntentExtension.debugoptimized.xcconfig */; + buildSettings = { + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = WidgetIntentExtension/WidgetIntentExtension.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = JH3UHGZD75; + GCC_C_LANGUAGE_STANDARD = gnu11; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = WidgetIntentExtension/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = WidgetIntentExtension; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.70; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG"; + PRODUCT_BUNDLE_IDENTIFIER = com.uniswap.mobile.dev.WidgetIntentExtension; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = DebugOptimized; + }; + 7ACBAE66C5ABCA063B6627AC /* DebugOptimized */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 8CBD584CCF002F58176DA863 /* Pods-OneSignalNotificationServiceExtension.debugoptimized.xcconfig */; + buildSettings = { + BUNDLE_ID_SUFFIX = .dev; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = OneSignalNotificationServiceExtension/OneSignalNotificationServiceExtension.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = JH3UHGZD75; + GCC_C_LANGUAGE_STANDARD = gnu11; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = OneSignalNotificationServiceExtension/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = OneSignalNotificationServiceExtension; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.70; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG"; + PRODUCT_BUNDLE_IDENTIFIER = com.uniswap.mobile.dev.OneSignalNotificationServiceExtension; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SKIP_INSTALL = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = DebugOptimized; + }; + 83CBBA201A601CBA00E9B192 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; + CLANG_CXX_LANGUAGE_STANDARD = "c++20"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEVELOPMENT_TEAM = CGCYLJG7GA; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = i386; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_SYMBOLS_PRIVATE_EXTERN = NO; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + HEADER_SEARCH_PATHS = ( + "$(inherited)", + "${PODS_CONFIGURATION_BUILD_DIR}/ReactCommon/ReactCommon.framework/Headers", + "${PODS_CONFIGURATION_BUILD_DIR}/ReactCommon/ReactCommon.framework/Headers/react/nativemodule/core", + "${PODS_CONFIGURATION_BUILD_DIR}/ReactCommon-Samples/ReactCommon_Samples.framework/Headers", + "${PODS_CONFIGURATION_BUILD_DIR}/ReactCommon-Samples/ReactCommon_Samples.framework/Headers/platform/ios", + "${PODS_CONFIGURATION_BUILD_DIR}/React-Fabric/React_Fabric.framework/Headers/react/renderer/components/view/platform/cxx", + "${PODS_CONFIGURATION_BUILD_DIR}/React-NativeModulesApple/React_NativeModulesApple.framework/Headers", + "${PODS_CONFIGURATION_BUILD_DIR}/React-graphics/React_graphics.framework/Headers", + "${PODS_CONFIGURATION_BUILD_DIR}/React-graphics/React_graphics.framework/Headers/react/renderer/graphics/platform/ios", + ); + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + LD_RUNPATH_SEARCH_PATHS = ( + /usr/lib/swift, + "$(inherited)", + ); + LIBRARY_SEARCH_PATHS = ( + "$(SDKROOT)/usr/lib/swift", + "\"$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)\"", + "\"$(inherited)\"", + ); + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + OTHER_CFLAGS = "$(inherited)"; + OTHER_CPLUSPLUSFLAGS = "$(inherited)"; + OTHER_LDFLAGS = "$(inherited)"; + REACT_NATIVE_PATH = "${PODS_ROOT}/../../../../node_modules/react-native"; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG"; + SWIFT_VERSION = 5.0; + USE_HERMES = true; + }; + name = Debug; + }; + 83CBBA211A601CBA00E9B192 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; + CLANG_CXX_LANGUAGE_STANDARD = "c++20"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = YES; + DEVELOPMENT_TEAM = CGCYLJG7GA; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = i386; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + HEADER_SEARCH_PATHS = ( + "$(inherited)", + "${PODS_CONFIGURATION_BUILD_DIR}/ReactCommon/ReactCommon.framework/Headers", + "${PODS_CONFIGURATION_BUILD_DIR}/ReactCommon/ReactCommon.framework/Headers/react/nativemodule/core", + "${PODS_CONFIGURATION_BUILD_DIR}/ReactCommon-Samples/ReactCommon_Samples.framework/Headers", + "${PODS_CONFIGURATION_BUILD_DIR}/ReactCommon-Samples/ReactCommon_Samples.framework/Headers/platform/ios", + "${PODS_CONFIGURATION_BUILD_DIR}/React-Fabric/React_Fabric.framework/Headers/react/renderer/components/view/platform/cxx", + "${PODS_CONFIGURATION_BUILD_DIR}/React-NativeModulesApple/React_NativeModulesApple.framework/Headers", + "${PODS_CONFIGURATION_BUILD_DIR}/React-graphics/React_graphics.framework/Headers", + "${PODS_CONFIGURATION_BUILD_DIR}/React-graphics/React_graphics.framework/Headers/react/renderer/graphics/platform/ios", + ); + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + LD_RUNPATH_SEARCH_PATHS = ( + /usr/lib/swift, + "$(inherited)", + ); + LIBRARY_SEARCH_PATHS = ( + "$(SDKROOT)/usr/lib/swift", + "\"$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)\"", + "\"$(inherited)\"", + ); + MTL_ENABLE_DEBUG_INFO = NO; + OTHER_CFLAGS = "$(inherited)"; + OTHER_CPLUSPLUSFLAGS = "$(inherited)"; + OTHER_LDFLAGS = "$(inherited)"; + REACT_NATIVE_PATH = "${PODS_ROOT}/../../../../node_modules/react-native"; + SDKROOT = iphoneos; + SWIFT_VERSION = 5.0; + USE_HERMES = true; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + DFB0E04B2D800AD0DA05A6FA /* DebugOptimized */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 986A89D253EEB9BEBE5F08FF /* Pods-Widgets.debugoptimized.xcconfig */; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = Widgets/Widgets.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = JH3UHGZD75; + GCC_C_LANGUAGE_STANDARD = gnu11; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Widgets/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = Widgets; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.70; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG"; + PRODUCT_BUNDLE_IDENTIFIER = com.uniswap.mobile.dev.widgets; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = DebugOptimized; + }; + F35AFD4427EE49990011A725 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 3A2186B1FF7FB85663D96EA9 /* Pods-OneSignalNotificationServiceExtension.debug.xcconfig */; + buildSettings = { + BUNDLE_ID_SUFFIX = .dev; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = OneSignalNotificationServiceExtension/OneSignalNotificationServiceExtension.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = JH3UHGZD75; + GCC_C_LANGUAGE_STANDARD = gnu11; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = OneSignalNotificationServiceExtension/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = OneSignalNotificationServiceExtension; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.70; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG"; + PRODUCT_BUNDLE_IDENTIFIER = com.uniswap.mobile.dev.OneSignalNotificationServiceExtension; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SKIP_INSTALL = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Debug; + }; + F35AFD4527EE49990011A725 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 1193B3A845BC3BE8CAA00D01 /* Pods-OneSignalNotificationServiceExtension.release.xcconfig */; + buildSettings = { + BUNDLE_ID_SUFFIX = ""; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = OneSignalNotificationServiceExtension/OneSignalNotificationServiceExtension.entitlements; + CODE_SIGN_IDENTITY = "iPhone Distribution"; + CODE_SIGN_STYLE = Manual; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = JH3UHGZD75; + GCC_C_LANGUAGE_STANDARD = gnu11; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = OneSignalNotificationServiceExtension/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = OneSignalNotificationServiceExtension; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.70; + MTL_FAST_MATH = YES; + OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; + PRODUCT_BUNDLE_IDENTIFIER = com.uniswap.mobile.OneSignalNotificationServiceExtension; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = "match AppStore com.uniswap.mobile.OneSignalNotificationServiceExtension"; + SKIP_INSTALL = YES; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Release; + }; + FDB6FD3C294D3A6E00C7B822 /* Beta */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; + CLANG_CXX_LANGUAGE_STANDARD = "c++20"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = YES; + DEVELOPMENT_TEAM = CGCYLJG7GA; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = i386; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + HEADER_SEARCH_PATHS = ( + "$(inherited)", + "${PODS_CONFIGURATION_BUILD_DIR}/ReactCommon/ReactCommon.framework/Headers", + "${PODS_CONFIGURATION_BUILD_DIR}/ReactCommon/ReactCommon.framework/Headers/react/nativemodule/core", + "${PODS_CONFIGURATION_BUILD_DIR}/ReactCommon-Samples/ReactCommon_Samples.framework/Headers", + "${PODS_CONFIGURATION_BUILD_DIR}/ReactCommon-Samples/ReactCommon_Samples.framework/Headers/platform/ios", + "${PODS_CONFIGURATION_BUILD_DIR}/React-Fabric/React_Fabric.framework/Headers/react/renderer/components/view/platform/cxx", + "${PODS_CONFIGURATION_BUILD_DIR}/React-NativeModulesApple/React_NativeModulesApple.framework/Headers", + "${PODS_CONFIGURATION_BUILD_DIR}/React-graphics/React_graphics.framework/Headers", + "${PODS_CONFIGURATION_BUILD_DIR}/React-graphics/React_graphics.framework/Headers/react/renderer/graphics/platform/ios", + ); + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + LD_RUNPATH_SEARCH_PATHS = ( + /usr/lib/swift, + "$(inherited)", + ); + LIBRARY_SEARCH_PATHS = ( + "$(SDKROOT)/usr/lib/swift", + "\"$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)\"", + "\"$(inherited)\"", + ); + MTL_ENABLE_DEBUG_INFO = NO; + OTHER_CFLAGS = "$(inherited)"; + OTHER_CPLUSPLUSFLAGS = "$(inherited)"; + OTHER_LDFLAGS = "$(inherited)"; + REACT_NATIVE_PATH = "${PODS_ROOT}/../../../../node_modules/react-native"; + SDKROOT = iphoneos; + SWIFT_VERSION = 5.0; + USE_HERMES = true; + VALIDATE_PRODUCT = YES; + }; + name = Beta; + }; + FDB6FD3D294D3A6E00C7B822 /* Beta */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 62CEA9F2D5176D20A6402A3E /* Pods-Uniswap.beta.xcconfig */; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = "$(inherited)"; + ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon$(BUNDLE_ID_SUFFIX)"; + ASSETCATALOG_COMPILER_LAUNCHIMAGE_NAME = ""; + BUNDLE_ID_SUFFIX = .beta; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Uniswap/Uniswap.entitlements; + CODE_SIGN_IDENTITY = "iPhone Distribution"; + CODE_SIGN_STYLE = Manual; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = JH3UHGZD75; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Uniswap/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.70; + OTHER_LDFLAGS = ( + "$(inherited)", + "-ObjC", + "-lc++", + ); + OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; + PRODUCT_BUNDLE_IDENTIFIER = com.uniswap.mobile.beta; + PRODUCT_NAME = Uniswap; + PROVISIONING_PROFILE_SPECIFIER = "match AppStore com.uniswap.mobile.beta"; + SWIFT_OBJC_BRIDGING_HEADER = "Uniswap/RNEthersRs/RNEthersRS-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Beta; + }; + FDB6FD3E294D3A6E00C7B822 /* Beta */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 4781CD4CDD95B5792B793F75 /* Pods-Uniswap-UniswapTests.beta.xcconfig */; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = "$(inherited)"; + BUNDLE_LOADER = "$(TEST_HOST)"; + COPY_PHASE_STRIP = NO; + INFOPLIST_FILE = UniswapTests/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + OTHER_LDFLAGS = ( + "-ObjC", + "-lc++", + "$(inherited)", + ); + OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; + PRODUCT_BUNDLE_IDENTIFIER = "org.reactjs.native.example.$(PRODUCT_NAME:rfc1034identifier)"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Uniswap.app/Uniswap"; + }; + name = Beta; + }; + FDB6FD3F294D3A6E00C7B822 /* Beta */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = F56CC08FBB20FAC0DF6B93DA /* Pods-OneSignalNotificationServiceExtension.beta.xcconfig */; + buildSettings = { + BUNDLE_ID_SUFFIX = .beta; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = OneSignalNotificationServiceExtension/OneSignalNotificationServiceExtension.entitlements; + CODE_SIGN_IDENTITY = "iPhone Distribution"; + CODE_SIGN_STYLE = Manual; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = JH3UHGZD75; + GCC_C_LANGUAGE_STANDARD = gnu11; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = OneSignalNotificationServiceExtension/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = OneSignalNotificationServiceExtension; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.70; + MTL_FAST_MATH = YES; + OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; + PRODUCT_BUNDLE_IDENTIFIER = com.uniswap.mobile.beta.OneSignalNotificationServiceExtension; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = "match AppStore com.uniswap.mobile.beta.OneSignalNotificationServiceExtension"; + SKIP_INSTALL = YES; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Beta; + }; + FDB6FD40294D3A8200C7B822 /* Dev */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; + CLANG_CXX_LANGUAGE_STANDARD = "c++20"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = YES; + DEVELOPMENT_TEAM = CGCYLJG7GA; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = i386; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + HEADER_SEARCH_PATHS = ( + "$(inherited)", + "${PODS_CONFIGURATION_BUILD_DIR}/ReactCommon/ReactCommon.framework/Headers", + "${PODS_CONFIGURATION_BUILD_DIR}/ReactCommon/ReactCommon.framework/Headers/react/nativemodule/core", + "${PODS_CONFIGURATION_BUILD_DIR}/ReactCommon-Samples/ReactCommon_Samples.framework/Headers", + "${PODS_CONFIGURATION_BUILD_DIR}/ReactCommon-Samples/ReactCommon_Samples.framework/Headers/platform/ios", + "${PODS_CONFIGURATION_BUILD_DIR}/React-Fabric/React_Fabric.framework/Headers/react/renderer/components/view/platform/cxx", + "${PODS_CONFIGURATION_BUILD_DIR}/React-NativeModulesApple/React_NativeModulesApple.framework/Headers", + "${PODS_CONFIGURATION_BUILD_DIR}/React-graphics/React_graphics.framework/Headers", + "${PODS_CONFIGURATION_BUILD_DIR}/React-graphics/React_graphics.framework/Headers/react/renderer/graphics/platform/ios", + ); + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + LD_RUNPATH_SEARCH_PATHS = ( + /usr/lib/swift, + "$(inherited)", + ); + LIBRARY_SEARCH_PATHS = ( + "$(SDKROOT)/usr/lib/swift", + "\"$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)\"", + "\"$(inherited)\"", + ); + MTL_ENABLE_DEBUG_INFO = NO; + OTHER_CFLAGS = "$(inherited)"; + OTHER_CPLUSPLUSFLAGS = "$(inherited)"; + OTHER_LDFLAGS = "$(inherited)"; + REACT_NATIVE_PATH = "${PODS_ROOT}/../../../../node_modules/react-native"; + SDKROOT = iphoneos; + SWIFT_VERSION = 5.0; + USE_HERMES = true; + VALIDATE_PRODUCT = YES; + }; + name = Dev; + }; + FDB6FD41294D3A8200C7B822 /* Dev */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 56FE9C9AF785221B7E3F4C04 /* Pods-Uniswap.dev.xcconfig */; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = "$(inherited)"; + ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon$(BUNDLE_ID_SUFFIX)"; + ASSETCATALOG_COMPILER_LAUNCHIMAGE_NAME = ""; + BUNDLE_ID_SUFFIX = .dev; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Uniswap/Uniswap.entitlements; + CODE_SIGN_IDENTITY = "iPhone Distribution"; + CODE_SIGN_STYLE = Manual; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = JH3UHGZD75; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Uniswap/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.70; + OTHER_LDFLAGS = ( + "$(inherited)", + "-ObjC", + "-lc++", + ); + OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; + PRODUCT_BUNDLE_IDENTIFIER = com.uniswap.mobile.dev; + PRODUCT_NAME = Uniswap; + PROVISIONING_PROFILE_SPECIFIER = "match AppStore com.uniswap.mobile.dev"; + SWIFT_OBJC_BRIDGING_HEADER = "Uniswap/RNEthersRs/RNEthersRS-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Dev; + }; + FDB6FD42294D3A8200C7B822 /* Dev */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 0712B3629C74D1F958DF35FB /* Pods-Uniswap-UniswapTests.dev.xcconfig */; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = "$(inherited)"; + BUNDLE_LOADER = "$(TEST_HOST)"; + COPY_PHASE_STRIP = NO; + INFOPLIST_FILE = UniswapTests/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + OTHER_LDFLAGS = ( + "-ObjC", + "-lc++", + "$(inherited)", + ); + OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; + PRODUCT_BUNDLE_IDENTIFIER = "org.reactjs.native.example.$(PRODUCT_NAME:rfc1034identifier)"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Uniswap.app/Uniswap"; + }; + name = Dev; + }; + FDB6FD43294D3A8200C7B822 /* Dev */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 6F7814C6D40D9C348EA1F1C7 /* Pods-OneSignalNotificationServiceExtension.dev.xcconfig */; + buildSettings = { + BUNDLE_ID_SUFFIX = .dev; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = OneSignalNotificationServiceExtension/OneSignalNotificationServiceExtension.entitlements; + CODE_SIGN_IDENTITY = "iPhone Distribution"; + CODE_SIGN_STYLE = Manual; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = JH3UHGZD75; + GCC_C_LANGUAGE_STANDARD = gnu11; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = OneSignalNotificationServiceExtension/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = OneSignalNotificationServiceExtension; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.70; + MTL_FAST_MATH = YES; + OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; + PRODUCT_BUNDLE_IDENTIFIER = com.uniswap.mobile.dev.OneSignalNotificationServiceExtension; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = "match AppStore com.uniswap.mobile.dev.OneSignalNotificationServiceExtension"; + SKIP_INSTALL = YES; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Dev; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 00E357021AD99517003FC87E /* Build configuration list for PBXNativeTarget "UniswapTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 00E356F61AD99517003FC87E /* Debug */, + 00E356F71AD99517003FC87E /* Release */, + FDB6FD42294D3A8200C7B822 /* Dev */, + FDB6FD3E294D3A6E00C7B822 /* Beta */, + 6DC48512A1ADEC1FF10D690A /* DebugOptimized */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 072E23A42A44D5BD006AD6C9 /* Build configuration list for PBXNativeTarget "WidgetsCore" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 072E239C2A44D5BD006AD6C9 /* Debug */, + 072E239D2A44D5BD006AD6C9 /* Release */, + 072E239E2A44D5BD006AD6C9 /* Dev */, + 072E239F2A44D5BD006AD6C9 /* Beta */, + 7385AD0991A48D80AD74DA33 /* DebugOptimized */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 072E23A52A44D5BD006AD6C9 /* Build configuration list for PBXNativeTarget "WidgetsCoreTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 072E23A02A44D5BD006AD6C9 /* Debug */, + 072E23A12A44D5BD006AD6C9 /* Release */, + 072E23A22A44D5BD006AD6C9 /* Dev */, + 072E23A32A44D5BD006AD6C9 /* Beta */, + 32CACF657BA5BEC28E2F1B29 /* DebugOptimized */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 072F6C362A44A32F00DA720A /* Build configuration list for PBXNativeTarget "Widgets" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 072F6C322A44A32F00DA720A /* Debug */, + 072F6C332A44A32F00DA720A /* Release */, + 072F6C342A44A32F00DA720A /* Dev */, + 072F6C352A44A32F00DA720A /* Beta */, + DFB0E04B2D800AD0DA05A6FA /* DebugOptimized */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 078E794F2A55EB3400F59CF2 /* Build configuration list for PBXNativeTarget "WidgetIntentExtension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 078E79502A55EB3400F59CF2 /* Debug */, + 078E79512A55EB3400F59CF2 /* Release */, + 078E79522A55EB3400F59CF2 /* Dev */, + 078E79532A55EB3400F59CF2 /* Beta */, + 7932E66A3404F08533393AEE /* DebugOptimized */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "Uniswap" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 13B07F941A680F5B00A75B9A /* Debug */, + 13B07F951A680F5B00A75B9A /* Release */, + FDB6FD41294D3A8200C7B822 /* Dev */, + FDB6FD3D294D3A6E00C7B822 /* Beta */, + 31A0707BE53EF0E39440A5EA /* DebugOptimized */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "Uniswap" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 83CBBA201A601CBA00E9B192 /* Debug */, + 83CBBA211A601CBA00E9B192 /* Release */, + FDB6FD40294D3A8200C7B822 /* Dev */, + FDB6FD3C294D3A6E00C7B822 /* Beta */, + 699CC042160867DCE68402B2 /* DebugOptimized */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + F35AFD4327EE49990011A725 /* Build configuration list for PBXNativeTarget "OneSignalNotificationServiceExtension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + F35AFD4427EE49990011A725 /* Debug */, + F35AFD4527EE49990011A725 /* Release */, + FDB6FD43294D3A8200C7B822 /* Dev */, + FDB6FD3F294D3A6E00C7B822 /* Beta */, + 7ACBAE66C5ABCA063B6627AC /* DebugOptimized */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 83CBB9F71A601CBA00E9B192 /* Project object */; +} diff --git a/apps/mobile/ios/Uniswap.xcodeproj/xcshareddata/xcschemes/Uniswap.xcscheme b/apps/mobile/ios/Uniswap.xcodeproj/xcshareddata/xcschemes/Uniswap.xcscheme new file mode 100644 index 00000000..eb14b39f --- /dev/null +++ b/apps/mobile/ios/Uniswap.xcodeproj/xcshareddata/xcschemes/Uniswap.xcscheme @@ -0,0 +1,111 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/mobile/ios/Uniswap.xcworkspace/contents.xcworkspacedata b/apps/mobile/ios/Uniswap.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..6c288ca6 --- /dev/null +++ b/apps/mobile/ios/Uniswap.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/apps/mobile/ios/Uniswap.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/apps/mobile/ios/Uniswap.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000..18d98100 --- /dev/null +++ b/apps/mobile/ios/Uniswap.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/apps/mobile/ios/Uniswap.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/apps/mobile/ios/Uniswap.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 00000000..f9b0d7c5 --- /dev/null +++ b/apps/mobile/ios/Uniswap.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/apps/mobile/ios/Uniswap/AppDelegate.swift b/apps/mobile/ios/Uniswap/AppDelegate.swift new file mode 100644 index 00000000..ca105e69 --- /dev/null +++ b/apps/mobile/ios/Uniswap/AppDelegate.swift @@ -0,0 +1,163 @@ +import UIKit +import Expo +import ExpoModulesCore +import React +import ReactAppDependencyProvider +import Firebase +import ReactNativePerformance +import RNBootSplash +import UserNotifications + +@main +class AppDelegate: ExpoAppDelegate { + + static let hasLaunchedOnceKey = "HasLaunchedOnce" + + var window: UIWindow? + var reactNativeDelegate: ExpoReactNativeFactoryDelegate? + var reactNativeFactory: ExpoReactNativeFactory? + + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil + ) -> Bool { + print("🚀 AppDelegate: Starting initialization") + + // Must be first line in startup routine + ReactNativePerformance.onAppStarted() + print("📊 ReactNativePerformance started") + + // Handle keychain cleanup on first launch + handleKeychainCleanup() + print("🔐 Keychain cleanup completed") + + // Configure Firebase + FirebaseApp.configure() + print("🔥 Firebase configured") + + // Handle OneSignal deep linking + var newLaunchOptions = launchOptions ?? [:] + if let remoteNotif = launchOptions?[UIApplication.LaunchOptionsKey.remoteNotification] as? [String: Any], + let custom = remoteNotif["custom"] as? [String: Any], + let initialURL = custom["u"] as? String, + launchOptions?[UIApplication.LaunchOptionsKey.url] == nil { + newLaunchOptions[UIApplication.LaunchOptionsKey.url] = URL(string: initialURL) + print("🔗 OneSignal deep link processed") + } + + // Set up Expo React Native factory + let delegate = ReactNativeDelegate() + let factory = ExpoReactNativeFactory(delegate: delegate) + delegate.dependencyProvider = RCTAppDependencyProvider() + + reactNativeDelegate = delegate + reactNativeFactory = factory + bindReactNativeFactory(factory) + + window = UIWindow(frame: UIScreen.main.bounds) + factory.startReactNative( + withModuleName: "Uniswap", + in: window, + launchOptions: newLaunchOptions + ) + + let result = super.application(application, didFinishLaunchingWithOptions: newLaunchOptions) + + print("🏁 AppDelegate initialization complete") + return result + } + + // MARK: - Keychain Cleanup + private func handleKeychainCleanup() { + let defaults = UserDefaults.standard + let isFirstRun = !defaults.bool(forKey: AppDelegate.hasLaunchedOnceKey) + let canClearKeychainOnReinstall = KeychainUtils.getCanClearKeychainOnReinstall() + + if canClearKeychainOnReinstall && isFirstRun { + KeychainUtils.clearKeychain() + } + + if !canClearKeychainOnReinstall || isFirstRun { + defaults.set(true, forKey: AppDelegate.hasLaunchedOnceKey) + KeychainUtils.setCanClearKeychainOnReinstall() + } + } + + // MARK: - Deep Linking + override func application( + _ app: UIApplication, + open url: URL, + options: [UIApplication.OpenURLOptionsKey: Any] = [:] + ) -> Bool { + return super.application(app, open: url, options: options) || RCTLinkingManager.application(app, open: url, options: options) + } + + // Universal Links + override func application( + _ application: UIApplication, + continue userActivity: NSUserActivity, + restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void + ) -> Bool { + let result = RCTLinkingManager.application(application, continue: userActivity, restorationHandler: restorationHandler) + return super.application(application, continue: userActivity, restorationHandler: restorationHandler) || result + } + + // MARK: - Push Notifications + override func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { + // Handle device token registration + // OneSignal and other services will handle this via swizzling + } + + override func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { + // Handle registration failure + print("Failed to register for remote notifications: \(error)") + } + + override func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable : Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { + if let aps = userInfo["aps"] as? [String: Any] { + let contentAvailable = aps["content-available"] ?? aps["content_available"] + + if let contentNumber = contentAvailable as? NSNumber, contentNumber.intValue == 1 { + // Convert obj-c payload to SilentPushEventEmitter + let payload = userInfo.reduce(into: [String: Any]()) { result, entry in + if let key = entry.key as? String { + result[key] = entry.value + } + } + + SilentPushEventEmitter.emitEvent(with: payload) + } + } + completionHandler(.noData) + } + + // MARK: - Security + @objc(application:shouldAllowExtensionPointIdentifier:) + func application(_ application: UIApplication, shouldAllowExtensionPointIdentifier extensionPointIdentifier: UIApplication.ExtensionPointIdentifier) -> Bool { + // Disable 3rd party keyboards + if extensionPointIdentifier == .keyboard { + return false + } + return true + } +} + +class ReactNativeDelegate: ExpoReactNativeFactoryDelegate { + override func sourceURL(for bridge: RCTBridge) -> URL? { + bridge.bundleURL ?? bundleURL() + } + + override func bundleURL() -> URL? { + #if DEBUG + return RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: ".expo/.virtual-metro-entry") + #else + return Bundle.main.url(forResource: "main", withExtension: "jsbundle") + #endif + } + + // Override customize to initialize RNBootSplash BEFORE the window becomes visible + public override func customize(_ rootView: UIView) { + super.customize(rootView) + RNBootSplash.initWithStoryboard("SplashScreen", rootView: rootView) + } +} diff --git a/apps/mobile/ios/Uniswap/Colors.swift b/apps/mobile/ios/Uniswap/Colors.swift new file mode 100644 index 00000000..19699711 --- /dev/null +++ b/apps/mobile/ios/Uniswap/Colors.swift @@ -0,0 +1,21 @@ +// +// Colors.swift +// Uniswap +// +// Created by Thomas Thachil on 8/8/22. +// + +import SwiftUI + +struct Colors { + static let surface1 = Color("surface1") + static let surface2 = Color("surface2") + static let surface3 = Color("surface3") + static let neutral1 = Color("neutral1") + static let neutral2 = Color("neutral2") + static let neutral3 = Color("neutral3") + static let accent1 = Color("accent1") + static let statusCritical = Color("statusCritical") + static let statusSuccess = Color("statusSuccess") + static let onboardingBlue = Color("onboardingBlue") +} diff --git a/apps/mobile/ios/Uniswap/Colors.xcassets/Contents.json b/apps/mobile/ios/Uniswap/Colors.xcassets/Contents.json new file mode 100644 index 00000000..74d6a722 --- /dev/null +++ b/apps/mobile/ios/Uniswap/Colors.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/apps/mobile/ios/Uniswap/Colors.xcassets/accent1.colorset/Contents.json b/apps/mobile/ios/Uniswap/Colors.xcassets/accent1.colorset/Contents.json new file mode 100644 index 00000000..40b40a54 --- /dev/null +++ b/apps/mobile/ios/Uniswap/Colors.xcassets/accent1.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "255", + "green": "114", + "red": "252" + } + }, + "idiom": "universal" + }, + { + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "255", + "green": "114", + "red": "252" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/apps/mobile/ios/Uniswap/Colors.xcassets/neutral1.colorset/Contents.json b/apps/mobile/ios/Uniswap/Colors.xcassets/neutral1.colorset/Contents.json new file mode 100644 index 00000000..ba4d681c --- /dev/null +++ b/apps/mobile/ios/Uniswap/Colors.xcassets/neutral1.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "34", + "green": "34", + "red": "34" + } + }, + "idiom": "universal" + }, + { + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "255", + "green": "255", + "red": "255" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/apps/mobile/ios/Uniswap/Colors.xcassets/neutral2.colorset/Contents.json b/apps/mobile/ios/Uniswap/Colors.xcassets/neutral2.colorset/Contents.json new file mode 100644 index 00000000..56eb96f4 --- /dev/null +++ b/apps/mobile/ios/Uniswap/Colors.xcassets/neutral2.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "0x7D", + "green": "0x7D", + "red": "0x7D" + } + }, + "idiom": "universal" + }, + { + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "0x9B", + "green": "0x9B", + "red": "0x9B" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/apps/mobile/ios/Uniswap/Colors.xcassets/neutral3.colorset/Contents.json b/apps/mobile/ios/Uniswap/Colors.xcassets/neutral3.colorset/Contents.json new file mode 100644 index 00000000..977f2f8d --- /dev/null +++ b/apps/mobile/ios/Uniswap/Colors.xcassets/neutral3.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "206", + "green": "206", + "red": "206" + } + }, + "idiom": "universal" + }, + { + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "94", + "green": "94", + "red": "94" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/apps/mobile/ios/Uniswap/Colors.xcassets/onboardingBlue.colorset/Contents.json b/apps/mobile/ios/Uniswap/Colors.xcassets/onboardingBlue.colorset/Contents.json new file mode 100644 index 00000000..595b4cf1 --- /dev/null +++ b/apps/mobile/ios/Uniswap/Colors.xcassets/onboardingBlue.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "251", + "green": "130", + "red": "76" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/apps/mobile/ios/Uniswap/Colors.xcassets/statusCritical.colorset/Contents.json b/apps/mobile/ios/Uniswap/Colors.xcassets/statusCritical.colorset/Contents.json new file mode 100644 index 00000000..4d296fb5 --- /dev/null +++ b/apps/mobile/ios/Uniswap/Colors.xcassets/statusCritical.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "82", + "green": "95", + "red": "255" + } + }, + "idiom": "universal" + }, + { + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "82", + "green": "95", + "red": "255" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/apps/mobile/ios/Uniswap/Colors.xcassets/statusSuccess.colorset/Contents.json b/apps/mobile/ios/Uniswap/Colors.xcassets/statusSuccess.colorset/Contents.json new file mode 100644 index 00000000..4d9026db --- /dev/null +++ b/apps/mobile/ios/Uniswap/Colors.xcassets/statusSuccess.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "0x6B", + "green": "0xB6", + "red": "0x40" + } + }, + "idiom": "universal" + }, + { + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "0x6B", + "green": "0xB6", + "red": "0x40" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/apps/mobile/ios/Uniswap/Colors.xcassets/surface1.colorset/Contents.json b/apps/mobile/ios/Uniswap/Colors.xcassets/surface1.colorset/Contents.json new file mode 100644 index 00000000..67a72f35 --- /dev/null +++ b/apps/mobile/ios/Uniswap/Colors.xcassets/surface1.colorset/Contents.json @@ -0,0 +1,56 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "0xFF", + "green": "0xFF", + "red": "0xFF" + } + }, + "idiom": "universal" + }, + { + "appearances": [ + { + "appearance": "luminosity", + "value": "light" + } + ], + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "0xFF", + "green": "0xFF", + "red": "0xFF" + } + }, + "idiom": "universal" + }, + { + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "0x13", + "green": "0x13", + "red": "0x13" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/apps/mobile/ios/Uniswap/Colors.xcassets/surface2.colorset/Contents.json b/apps/mobile/ios/Uniswap/Colors.xcassets/surface2.colorset/Contents.json new file mode 100644 index 00000000..3835aa73 --- /dev/null +++ b/apps/mobile/ios/Uniswap/Colors.xcassets/surface2.colorset/Contents.json @@ -0,0 +1,56 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "0xF9", + "green": "0xF9", + "red": "0xF9" + } + }, + "idiom": "universal" + }, + { + "appearances": [ + { + "appearance": "luminosity", + "value": "light" + } + ], + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "0xF9", + "green": "0xF9", + "red": "0xF9" + } + }, + "idiom": "universal" + }, + { + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "0x1B", + "green": "0x1B", + "red": "0x1B" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/apps/mobile/ios/Uniswap/Colors.xcassets/surface3.colorset/Contents.json b/apps/mobile/ios/Uniswap/Colors.xcassets/surface3.colorset/Contents.json new file mode 100644 index 00000000..728ffc1d --- /dev/null +++ b/apps/mobile/ios/Uniswap/Colors.xcassets/surface3.colorset/Contents.json @@ -0,0 +1,56 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "0.050", + "blue": "0x22", + "green": "0x22", + "red": "0x22" + } + }, + "idiom": "universal" + }, + { + "appearances": [ + { + "appearance": "luminosity", + "value": "light" + } + ], + "color": { + "color-space": "srgb", + "components": { + "alpha": "0.050", + "blue": "0x22", + "green": "0x22", + "red": "0x22" + } + }, + "idiom": "universal" + }, + { + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "color": { + "color-space": "srgb", + "components": { + "alpha": "0.120", + "blue": "0xFF", + "green": "0xFF", + "red": "0xFF" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/apps/mobile/ios/Uniswap/Icons/AlertTriangleIcon.swift b/apps/mobile/ios/Uniswap/Icons/AlertTriangleIcon.swift new file mode 100644 index 00000000..ab1ecc07 --- /dev/null +++ b/apps/mobile/ios/Uniswap/Icons/AlertTriangleIcon.swift @@ -0,0 +1,40 @@ +// +// AlertTriangleIcon.swift +// Uniswap +// +// Created by Gary Ye on 9/16/23. +// + +import SwiftUI + +struct AlertTriangleIcon: Shape { + func path(in rect: CGRect) -> Path { + var path = Path() + let width = rect.size.width + let height = rect.size.height + path.move(to: CGPoint(x: 0.90031*width, y: 0.71468*height)) + path.addLine(to: CGPoint(x: 0.62502*width, y: 0.19983*height)) + path.addCurve(to: CGPoint(x: 0.37502*width, y: 0.19983*height), control1: CGPoint(x: 0.57168*width, y: 0.10008*height), control2: CGPoint(x: 0.42839*width, y: 0.10008*height)) + path.addLine(to: CGPoint(x: 0.09973*width, y: 0.71468*height)) + path.addCurve(to: CGPoint(x: 0.22119*width, y: 0.91667*height), control1: CGPoint(x: 0.05081*width, y: 0.80617*height), control2: CGPoint(x: 0.11723*width, y: 0.91667*height)) + path.addLine(to: CGPoint(x: 0.77885*width, y: 0.91667*height)) + path.addCurve(to: CGPoint(x: 0.90031*width, y: 0.71468*height), control1: CGPoint(x: 0.88276*width, y: 0.91667*height), control2: CGPoint(x: 0.94923*width, y: 0.80613*height)) + path.closeSubpath() + path.move(to: CGPoint(x: 0.46877*width, y: 0.41667*height)) + path.addCurve(to: CGPoint(x: 0.50002*width, y: 0.38542*height), control1: CGPoint(x: 0.46877*width, y: 0.39942*height), control2: CGPoint(x: 0.48277*width, y: 0.38542*height)) + path.addCurve(to: CGPoint(x: 0.53127*width, y: 0.41667*height), control1: CGPoint(x: 0.51727*width, y: 0.38542*height), control2: CGPoint(x: 0.53127*width, y: 0.39942*height)) + path.addLine(to: CGPoint(x: 0.53127*width, y: 0.58334*height)) + path.addCurve(to: CGPoint(x: 0.50002*width, y: 0.61459*height), control1: CGPoint(x: 0.53127*width, y: 0.60059*height), control2: CGPoint(x: 0.51727*width, y: 0.61459*height)) + path.addCurve(to: CGPoint(x: 0.46877*width, y: 0.58334*height), control1: CGPoint(x: 0.48277*width, y: 0.61459*height), control2: CGPoint(x: 0.46877*width, y: 0.60059*height)) + path.addLine(to: CGPoint(x: 0.46877*width, y: 0.41667*height)) + path.closeSubpath() + path.move(to: CGPoint(x: 0.50085*width, y: 0.75*height)) + path.addCurve(to: CGPoint(x: 0.45897*width, y: 0.70834*height), control1: CGPoint(x: 0.47785*width, y: 0.75*height), control2: CGPoint(x: 0.45897*width, y: 0.73134*height)) + path.addCurve(to: CGPoint(x: 0.50043*width, y: 0.66667*height), control1: CGPoint(x: 0.45897*width, y: 0.68534*height), control2: CGPoint(x: 0.47743*width, y: 0.66667*height)) + path.addLine(to: CGPoint(x: 0.50085*width, y: 0.66667*height)) + path.addCurve(to: CGPoint(x: 0.54252*width, y: 0.70834*height), control1: CGPoint(x: 0.52389*width, y: 0.66667*height), control2: CGPoint(x: 0.54252*width, y: 0.68534*height)) + path.addCurve(to: CGPoint(x: 0.50085*width, y: 0.75*height), control1: CGPoint(x: 0.54252*width, y: 0.73134*height), control2: CGPoint(x: 0.52385*width, y: 0.75*height)) + path.closeSubpath() + return path + } +} diff --git a/apps/mobile/ios/Uniswap/Icons/CopyIcon.swift b/apps/mobile/ios/Uniswap/Icons/CopyIcon.swift new file mode 100644 index 00000000..659b64e6 --- /dev/null +++ b/apps/mobile/ios/Uniswap/Icons/CopyIcon.swift @@ -0,0 +1,42 @@ +// +// CopyIcon.swift +// Uniswap +// +// Created by Mateusz Łopaciński on 27/05/2024. +// + +import SwiftUI + +struct CopyIcon: Shape { + func path(in rect: CGRect) -> Path { + var path = Path() + let width = rect.size.width + let height = rect.size.height + + path.move(to: CGPoint(x: 0.875 * width, y: 0.40104167 * height)) + path.addLine(to: CGPoint(x: 0.875 * width, y: 0.765625 * height)) + path.addCurve(to: CGPoint(x: 0.765625 * width, y: 0.875 * height), control1: CGPoint(x: 0.875 * width, y: 0.828125 * height), control2: CGPoint(x: 0.828125 * width, y: 0.875 * height)) + path.addLine(to: CGPoint(x: 0.40104167 * width, y: 0.875 * height)) + path.addCurve(to: CGPoint(x: 0.29166667 * width, y: 0.765625 * height), control1: CGPoint(x: 0.33854167 * width, y: 0.875 * height), control2: CGPoint(x: 0.29166667 * width, y: 0.828125 * height)) + path.addLine(to: CGPoint(x: 0.29166667 * width, y: 0.40104167 * height)) + path.addCurve(to: CGPoint(x: 0.40104167 * width, y: 0.29166667 * height), control1: CGPoint(x: 0.29166667 * width, y: 0.33854167 * height), control2: CGPoint(x: 0.33854167 * width, y: 0.29166667 * height)) + path.addLine(to: CGPoint(x: 0.765625 * width, y: 0.29166667 * height)) + path.addCurve(to: CGPoint(x: 0.875 * width, y: 0.40104167 * height), control1: CGPoint(x: 0.828125 * width, y: 0.29166667 * height), control2: CGPoint(x: 0.875 * width, y: 0.33854167 * height)) + path.closeSubpath() + + path.move(to: CGPoint(x: 0.65625 * width, y: 0.125 * height)) + path.addCurve(to: CGPoint(x: 0.625 * width, y: 0.09375 * height), control1: CGPoint(x: 0.65625 * width, y: 0.109375 * height), control2: CGPoint(x: 0.640625 * width, y: 0.09375 * height)) + path.addLine(to: CGPoint(x: 0.234375 * width, y: 0.09375 * height)) + path.addCurve(to: CGPoint(x: 0.09375 * width, y: 0.234375 * height), control1: CGPoint(x: 0.14583333 * width, y: 0.09375 * height), control2: CGPoint(x: 0.09375 * width, y: 0.14583333 * height)) + path.addLine(to: CGPoint(x: 0.09375 * width, y: 0.625 * height)) + path.addCurve(to: CGPoint(x: 0.125 * width, y: 0.65625 * height), control1: CGPoint(x: 0.09375 * width, y: 0.640625 * height), control2: CGPoint(x: 0.109375 * width, y: 0.65625 * height)) + path.addCurve(to: CGPoint(x: 0.15625 * width, y: 0.625 * height), control1: CGPoint(x: 0.140625 * width, y: 0.65625 * height), control2: CGPoint(x: 0.15625 * width, y: 0.640625 * height)) + path.addLine(to: CGPoint(x: 0.15625 * width, y: 0.234375 * height)) + path.addCurve(to: CGPoint(x: 0.234375 * width, y: 0.15625 * height), control1: CGPoint(x: 0.15625 * width, y: 0.19270833 * height), control2: CGPoint(x: 0.19270833 * width, y: 0.15625 * height)) + path.addLine(to: CGPoint(x: 0.625 * width, y: 0.15625 * height)) + path.addCurve(to: CGPoint(x: 0.65625 * width, y: 0.125 * height), control1: CGPoint(x: 0.640625 * width, y: 0.15625 * height), control2: CGPoint(x: 0.65625 * width, y: 0.140625 * height)) + path.closeSubpath() + + return path + } +} diff --git a/apps/mobile/ios/Uniswap/Icons/CopyIconOutline.swift b/apps/mobile/ios/Uniswap/Icons/CopyIconOutline.swift new file mode 100644 index 00000000..429243d5 --- /dev/null +++ b/apps/mobile/ios/Uniswap/Icons/CopyIconOutline.swift @@ -0,0 +1,54 @@ +// +// CopyIcon.swift +// Uniswap +// +// Created by Mateusz Łopaciński on 27/05/2024. +// + +import SwiftUI + +struct CopyIconOutline: Shape { + func path(in rect: CGRect) -> Path { + var path = Path() + let width = rect.size.width + let height = rect.size.height + path.move(to: CGPoint(x: 0.75298*width, y: 0.26042*height)) + path.addLine(to: CGPoint(x: 0.40575*width, y: 0.26042*height)) + path.addCurve(to: CGPoint(x: 0.27183*width, y: 0.40104*height), control1: CGPoint(x: 0.31937*width, y: 0.26042*height), control2: CGPoint(x: 0.27183*width, y: 0.31033*height)) + path.addLine(to: CGPoint(x: 0.27183*width, y: 0.76563*height)) + path.addCurve(to: CGPoint(x: 0.40575*width, y: 0.90625*height), control1: CGPoint(x: 0.27183*width, y: 0.85633*height), control2: CGPoint(x: 0.31937*width, y: 0.90625*height)) + path.addLine(to: CGPoint(x: 0.75298*width, y: 0.90625*height)) + path.addCurve(to: CGPoint(x: 0.8869*width, y: 0.76563*height), control1: CGPoint(x: 0.83937*width, y: 0.90625*height), control2: CGPoint(x: 0.8869*width, y: 0.85633*height)) + path.addLine(to: CGPoint(x: 0.8869*width, y: 0.40104*height)) + path.addCurve(to: CGPoint(x: 0.75298*width, y: 0.26042*height), control1: CGPoint(x: 0.8869*width, y: 0.31033*height), control2: CGPoint(x: 0.83937*width, y: 0.26042*height)) + path.closeSubpath() + path.move(to: CGPoint(x: 0.82738*width, y: 0.76563*height)) + path.addCurve(to: CGPoint(x: 0.75298*width, y: 0.84375*height), control1: CGPoint(x: 0.82738*width, y: 0.82112*height), control2: CGPoint(x: 0.80583*width, y: 0.84375*height)) + path.addLine(to: CGPoint(x: 0.40575*width, y: 0.84375*height)) + path.addCurve(to: CGPoint(x: 0.33135*width, y: 0.76563*height), control1: CGPoint(x: 0.3529*width, y: 0.84375*height), control2: CGPoint(x: 0.33135*width, y: 0.82112*height)) + path.addLine(to: CGPoint(x: 0.33135*width, y: 0.40104*height)) + path.addCurve(to: CGPoint(x: 0.40575*width, y: 0.32292*height), control1: CGPoint(x: 0.33135*width, y: 0.34554*height), control2: CGPoint(x: 0.3529*width, y: 0.32292*height)) + path.addLine(to: CGPoint(x: 0.75298*width, y: 0.32292*height)) + path.addCurve(to: CGPoint(x: 0.82738*width, y: 0.40104*height), control1: CGPoint(x: 0.80583*width, y: 0.32292*height), control2: CGPoint(x: 0.82738*width, y: 0.34554*height)) + path.addLine(to: CGPoint(x: 0.82738*width, y: 0.76563*height)) + path.closeSubpath() + path.move(to: CGPoint(x: 0.17262*width, y: 0.23417*height)) + path.addLine(to: CGPoint(x: 0.17262*width, y: 0.59916*height)) + path.addCurve(to: CGPoint(x: 0.1981*width, y: 0.66546*height), control1: CGPoint(x: 0.17262*width, y: 0.64909*height), control2: CGPoint(x: 0.19179*width, y: 0.66137*height)) + path.addCurve(to: CGPoint(x: 0.20793*width, y: 0.70842*height), control1: CGPoint(x: 0.21215*width, y: 0.67446*height), control2: CGPoint(x: 0.2165*width, y: 0.69371*height)) + path.addCurve(to: CGPoint(x: 0.1825*width, y: 0.72333*height), control1: CGPoint(x: 0.2023*width, y: 0.71804*height), control2: CGPoint(x: 0.19254*width, y: 0.72333*height)) + path.addCurve(to: CGPoint(x: 0.16698*width, y: 0.71875*height), control1: CGPoint(x: 0.17722*width, y: 0.72333*height), control2: CGPoint(x: 0.17182*width, y: 0.72184*height)) + path.addCurve(to: CGPoint(x: 0.1131*width, y: 0.59916*height), control1: CGPoint(x: 0.13123*width, y: 0.69575*height), control2: CGPoint(x: 0.1131*width, y: 0.65554*height)) + path.addLine(to: CGPoint(x: 0.1131*width, y: 0.23417*height)) + path.addCurve(to: CGPoint(x: 0.24683*width, y: 0.09375*height), control1: CGPoint(x: 0.1131*width, y: 0.14492*height), control2: CGPoint(x: 0.16187*width, y: 0.09375*height)) + path.addLine(to: CGPoint(x: 0.59444*width, y: 0.09375*height)) + path.addCurve(to: CGPoint(x: 0.70833*width, y: 0.15033*height), control1: CGPoint(x: 0.6613*width, y: 0.09375*height), control2: CGPoint(x: 0.69325*width, y: 0.12454*height)) + path.addCurve(to: CGPoint(x: 0.69849*width, y: 0.19329*height), control1: CGPoint(x: 0.7169*width, y: 0.16504*height), control2: CGPoint(x: 0.7125*width, y: 0.18429*height)) + path.addCurve(to: CGPoint(x: 0.65758*width, y: 0.18296*height), control1: CGPoint(x: 0.68444*width, y: 0.20233*height), control2: CGPoint(x: 0.66619*width, y: 0.19767*height)) + path.addCurve(to: CGPoint(x: 0.59444*width, y: 0.15621*height), control1: CGPoint(x: 0.65373*width, y: 0.17633*height), control2: CGPoint(x: 0.64198*width, y: 0.15621*height)) + path.addLine(to: CGPoint(x: 0.24683*width, y: 0.15621*height)) + path.addCurve(to: CGPoint(x: 0.17262*width, y: 0.23417*height), control1: CGPoint(x: 0.19413*width, y: 0.15625*height), control2: CGPoint(x: 0.17262*width, y: 0.17883*height)) + path.closeSubpath() + return path + } +} diff --git a/apps/mobile/ios/Uniswap/Icons/PasteIcon.swift b/apps/mobile/ios/Uniswap/Icons/PasteIcon.swift new file mode 100644 index 00000000..955cd7e9 --- /dev/null +++ b/apps/mobile/ios/Uniswap/Icons/PasteIcon.swift @@ -0,0 +1,39 @@ +// +// PasteIcon.swift +// Uniswap +// +// Created by Gary Ye on 9/19/23. +// + +import SwiftUI + +struct PasteIcon: Shape { + func path(in rect: CGRect) -> Path { + var path = Path() + let width = rect.size.width + let height = rect.size.height + path.move(to: CGPoint(x: 0.875*width, y: 0.26542*height)) + path.addLine(to: CGPoint(x: 0.875*width, y: 0.53541*height)) + path.addCurve(to: CGPoint(x: 0.87208*width, y: 0.5625*height), control1: CGPoint(x: 0.875*width, y: 0.54458*height), control2: CGPoint(x: 0.87416*width, y: 0.55375*height)) + path.addLine(to: CGPoint(x: 0.70333*width, y: 0.5625*height)) + path.addCurve(to: CGPoint(x: 0.5625*width, y: 0.70334*height), control1: CGPoint(x: 0.62541*width, y: 0.5625*height), control2: CGPoint(x: 0.5625*width, y: 0.62542*height)) + path.addLine(to: CGPoint(x: 0.5625*width, y: 0.87208*height)) + path.addCurve(to: CGPoint(x: 0.53542*width, y: 0.875*height), control1: CGPoint(x: 0.55375*width, y: 0.87417*height), control2: CGPoint(x: 0.54458*width, y: 0.875*height)) + path.addLine(to: CGPoint(x: 0.26583*width, y: 0.875*height)) + path.addCurve(to: CGPoint(x: 0.125*width, y: 0.73416*height), control1: CGPoint(x: 0.17166*width, y: 0.875*height), control2: CGPoint(x: 0.125*width, y: 0.82791*height)) + path.addLine(to: CGPoint(x: 0.125*width, y: 0.26542*height)) + path.addCurve(to: CGPoint(x: 0.26583*width, y: 0.125*height), control1: CGPoint(x: 0.125*width, y: 0.17167*height), control2: CGPoint(x: 0.17166*width, y: 0.125*height)) + path.addLine(to: CGPoint(x: 0.73417*width, y: 0.125*height)) + path.addCurve(to: CGPoint(x: 0.875*width, y: 0.26542*height), control1: CGPoint(x: 0.82834*width, y: 0.125*height), control2: CGPoint(x: 0.875*width, y: 0.17167*height)) + path.closeSubpath() + path.move(to: CGPoint(x: 0.625*width, y: 0.70334*height)) + path.addLine(to: CGPoint(x: 0.625*width, y: 0.8425*height)) + path.addCurve(to: CGPoint(x: 0.635*width, y: 0.83375*height), control1: CGPoint(x: 0.62875*width, y: 0.84*height), control2: CGPoint(x: 0.63167*width, y: 0.83709*height)) + path.addLine(to: CGPoint(x: 0.83375*width, y: 0.635*height)) + path.addCurve(to: CGPoint(x: 0.8425*width, y: 0.625*height), control1: CGPoint(x: 0.83709*width, y: 0.63167*height), control2: CGPoint(x: 0.84*width, y: 0.62875*height)) + path.addLine(to: CGPoint(x: 0.70333*width, y: 0.625*height)) + path.addCurve(to: CGPoint(x: 0.625*width, y: 0.70334*height), control1: CGPoint(x: 0.65999*width, y: 0.625*height), control2: CGPoint(x: 0.625*width, y: 0.66*height)) + path.closeSubpath() + return path + } +} diff --git a/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.appiconset/100.png b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.appiconset/100.png new file mode 100644 index 00000000..10403655 Binary files /dev/null and b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.appiconset/100.png differ diff --git a/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.appiconset/1024.png b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.appiconset/1024.png new file mode 100644 index 00000000..d987b91c Binary files /dev/null and b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.appiconset/1024.png differ diff --git a/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.appiconset/114.png b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.appiconset/114.png new file mode 100644 index 00000000..5fdd346e Binary files /dev/null and b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.appiconset/114.png differ diff --git a/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.appiconset/120.png b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.appiconset/120.png new file mode 100644 index 00000000..48741050 Binary files /dev/null and b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.appiconset/120.png differ diff --git a/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.appiconset/144.png b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.appiconset/144.png new file mode 100644 index 00000000..37350c25 Binary files /dev/null and b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.appiconset/144.png differ diff --git a/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.appiconset/152.png b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.appiconset/152.png new file mode 100644 index 00000000..899b7aa1 Binary files /dev/null and b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.appiconset/152.png differ diff --git a/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.appiconset/167.png b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.appiconset/167.png new file mode 100644 index 00000000..89e654ec Binary files /dev/null and b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.appiconset/167.png differ diff --git a/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.appiconset/180.png b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.appiconset/180.png new file mode 100644 index 00000000..1d6e86b9 Binary files /dev/null and b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.appiconset/180.png differ diff --git a/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.appiconset/20.png b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.appiconset/20.png new file mode 100644 index 00000000..9387b630 Binary files /dev/null and b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.appiconset/20.png differ diff --git a/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.appiconset/29.png b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.appiconset/29.png new file mode 100644 index 00000000..09c3406e Binary files /dev/null and b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.appiconset/29.png differ diff --git a/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.appiconset/40.png b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.appiconset/40.png new file mode 100644 index 00000000..df7ef4a7 Binary files /dev/null and b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.appiconset/40.png differ diff --git a/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.appiconset/50.png b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.appiconset/50.png new file mode 100644 index 00000000..24e537af Binary files /dev/null and b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.appiconset/50.png differ diff --git a/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.appiconset/57.png b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.appiconset/57.png new file mode 100644 index 00000000..79db5b19 Binary files /dev/null and b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.appiconset/57.png differ diff --git a/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.appiconset/58.png b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.appiconset/58.png new file mode 100644 index 00000000..985fc3d0 Binary files /dev/null and b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.appiconset/58.png differ diff --git a/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.appiconset/60.png b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.appiconset/60.png new file mode 100644 index 00000000..67dccebe Binary files /dev/null and b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.appiconset/60.png differ diff --git a/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.appiconset/72.png b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.appiconset/72.png new file mode 100644 index 00000000..85ff2e3d Binary files /dev/null and b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.appiconset/72.png differ diff --git a/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.appiconset/76.png b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.appiconset/76.png new file mode 100644 index 00000000..5f91ad13 Binary files /dev/null and b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.appiconset/76.png differ diff --git a/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.appiconset/80.png b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.appiconset/80.png new file mode 100644 index 00000000..b052ae9c Binary files /dev/null and b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.appiconset/80.png differ diff --git a/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.appiconset/87.png b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.appiconset/87.png new file mode 100644 index 00000000..7f93dd8f Binary files /dev/null and b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.appiconset/87.png differ diff --git a/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.appiconset/Contents.json b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..03f35aa2 --- /dev/null +++ b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,158 @@ +{ + "images": [ + { + "filename": "40.png", + "idiom": "iphone", + "scale": "2x", + "size": "20x20" + }, + { + "filename": "60.png", + "idiom": "iphone", + "scale": "3x", + "size": "20x20" + }, + { + "filename": "29.png", + "idiom": "iphone", + "scale": "1x", + "size": "29x29" + }, + { + "filename": "58.png", + "idiom": "iphone", + "scale": "2x", + "size": "29x29" + }, + { + "filename": "87.png", + "idiom": "iphone", + "scale": "3x", + "size": "29x29" + }, + { + "filename": "80.png", + "idiom": "iphone", + "scale": "2x", + "size": "40x40" + }, + { + "filename": "120.png", + "idiom": "iphone", + "scale": "3x", + "size": "40x40" + }, + { + "filename": "57.png", + "idiom": "iphone", + "scale": "1x", + "size": "57x57" + }, + { + "filename": "114.png", + "idiom": "iphone", + "scale": "2x", + "size": "57x57" + }, + { + "filename": "120.png", + "idiom": "iphone", + "scale": "2x", + "size": "60x60" + }, + { + "filename": "180.png", + "idiom": "iphone", + "scale": "3x", + "size": "60x60" + }, + { + "filename": "20.png", + "idiom": "ipad", + "scale": "1x", + "size": "20x20" + }, + { + "filename": "40.png", + "idiom": "ipad", + "scale": "2x", + "size": "20x20" + }, + { + "filename": "29.png", + "idiom": "ipad", + "scale": "1x", + "size": "29x29" + }, + { + "filename": "58.png", + "idiom": "ipad", + "scale": "2x", + "size": "29x29" + }, + { + "filename": "40.png", + "idiom": "ipad", + "scale": "1x", + "size": "40x40" + }, + { + "filename": "80.png", + "idiom": "ipad", + "scale": "2x", + "size": "40x40" + }, + { + "filename": "50.png", + "idiom": "ipad", + "scale": "1x", + "size": "50x50" + }, + { + "filename": "100.png", + "idiom": "ipad", + "scale": "2x", + "size": "50x50" + }, + { + "filename": "72.png", + "idiom": "ipad", + "scale": "1x", + "size": "72x72" + }, + { + "filename": "144.png", + "idiom": "ipad", + "scale": "2x", + "size": "72x72" + }, + { + "filename": "76.png", + "idiom": "ipad", + "scale": "1x", + "size": "76x76" + }, + { + "filename": "152.png", + "idiom": "ipad", + "scale": "2x", + "size": "76x76" + }, + { + "filename": "167.png", + "idiom": "ipad", + "scale": "2x", + "size": "83.5x83.5" + }, + { + "filename": "1024.png", + "idiom": "ios-marketing", + "scale": "1x", + "size": "1024x1024" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.beta.appiconset/100.png b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.beta.appiconset/100.png new file mode 100644 index 00000000..577dd0dd Binary files /dev/null and b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.beta.appiconset/100.png differ diff --git a/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.beta.appiconset/1024.png b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.beta.appiconset/1024.png new file mode 100644 index 00000000..6cfe74dc Binary files /dev/null and b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.beta.appiconset/1024.png differ diff --git a/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.beta.appiconset/114.png b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.beta.appiconset/114.png new file mode 100644 index 00000000..a3780ff9 Binary files /dev/null and b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.beta.appiconset/114.png differ diff --git a/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.beta.appiconset/120.png b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.beta.appiconset/120.png new file mode 100644 index 00000000..fec5d6b5 Binary files /dev/null and b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.beta.appiconset/120.png differ diff --git a/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.beta.appiconset/128.png b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.beta.appiconset/128.png new file mode 100644 index 00000000..7101e408 Binary files /dev/null and b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.beta.appiconset/128.png differ diff --git a/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.beta.appiconset/144.png b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.beta.appiconset/144.png new file mode 100644 index 00000000..637553f9 Binary files /dev/null and b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.beta.appiconset/144.png differ diff --git a/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.beta.appiconset/152.png b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.beta.appiconset/152.png new file mode 100644 index 00000000..98ed1a76 Binary files /dev/null and b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.beta.appiconset/152.png differ diff --git a/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.beta.appiconset/16.png b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.beta.appiconset/16.png new file mode 100644 index 00000000..f3c91141 Binary files /dev/null and b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.beta.appiconset/16.png differ diff --git a/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.beta.appiconset/167.png b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.beta.appiconset/167.png new file mode 100644 index 00000000..42ce7da3 Binary files /dev/null and b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.beta.appiconset/167.png differ diff --git a/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.beta.appiconset/172.png b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.beta.appiconset/172.png new file mode 100644 index 00000000..eeb1674b Binary files /dev/null and b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.beta.appiconset/172.png differ diff --git a/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.beta.appiconset/180.png b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.beta.appiconset/180.png new file mode 100644 index 00000000..9277f46a Binary files /dev/null and b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.beta.appiconset/180.png differ diff --git a/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.beta.appiconset/196.png b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.beta.appiconset/196.png new file mode 100644 index 00000000..dcd539b9 Binary files /dev/null and b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.beta.appiconset/196.png differ diff --git a/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.beta.appiconset/20.png b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.beta.appiconset/20.png new file mode 100644 index 00000000..2cce0e3b Binary files /dev/null and b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.beta.appiconset/20.png differ diff --git a/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.beta.appiconset/216.png b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.beta.appiconset/216.png new file mode 100644 index 00000000..c61cb4a9 Binary files /dev/null and b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.beta.appiconset/216.png differ diff --git a/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.beta.appiconset/256.png b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.beta.appiconset/256.png new file mode 100644 index 00000000..06c7972b Binary files /dev/null and b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.beta.appiconset/256.png differ diff --git a/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.beta.appiconset/29.png b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.beta.appiconset/29.png new file mode 100644 index 00000000..79a85b6d Binary files /dev/null and b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.beta.appiconset/29.png differ diff --git a/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.beta.appiconset/32.png b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.beta.appiconset/32.png new file mode 100644 index 00000000..ccd6ea8a Binary files /dev/null and b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.beta.appiconset/32.png differ diff --git a/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.beta.appiconset/40.png b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.beta.appiconset/40.png new file mode 100644 index 00000000..94082ee4 Binary files /dev/null and b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.beta.appiconset/40.png differ diff --git a/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.beta.appiconset/48.png b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.beta.appiconset/48.png new file mode 100644 index 00000000..dceaeeb2 Binary files /dev/null and b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.beta.appiconset/48.png differ diff --git a/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.beta.appiconset/50.png b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.beta.appiconset/50.png new file mode 100644 index 00000000..bd818bac Binary files /dev/null and b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.beta.appiconset/50.png differ diff --git a/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.beta.appiconset/512.png b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.beta.appiconset/512.png new file mode 100644 index 00000000..0e16ead2 Binary files /dev/null and b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.beta.appiconset/512.png differ diff --git a/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.beta.appiconset/55.png b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.beta.appiconset/55.png new file mode 100644 index 00000000..14f16400 Binary files /dev/null and b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.beta.appiconset/55.png differ diff --git a/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.beta.appiconset/57.png b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.beta.appiconset/57.png new file mode 100644 index 00000000..2e6c9f53 Binary files /dev/null and b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.beta.appiconset/57.png differ diff --git a/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.beta.appiconset/58.png b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.beta.appiconset/58.png new file mode 100644 index 00000000..39f4e99b Binary files /dev/null and b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.beta.appiconset/58.png differ diff --git a/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.beta.appiconset/60.png b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.beta.appiconset/60.png new file mode 100644 index 00000000..e0bef9db Binary files /dev/null and b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.beta.appiconset/60.png differ diff --git a/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.beta.appiconset/64.png b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.beta.appiconset/64.png new file mode 100644 index 00000000..dd3bea35 Binary files /dev/null and b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.beta.appiconset/64.png differ diff --git a/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.beta.appiconset/66.png b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.beta.appiconset/66.png new file mode 100644 index 00000000..4fde9c4e Binary files /dev/null and b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.beta.appiconset/66.png differ diff --git a/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.beta.appiconset/72.png b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.beta.appiconset/72.png new file mode 100644 index 00000000..7d75238f Binary files /dev/null and b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.beta.appiconset/72.png differ diff --git a/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.beta.appiconset/76.png b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.beta.appiconset/76.png new file mode 100644 index 00000000..e6fc227a Binary files /dev/null and b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.beta.appiconset/76.png differ diff --git a/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.beta.appiconset/80.png b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.beta.appiconset/80.png new file mode 100644 index 00000000..85f3210d Binary files /dev/null and b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.beta.appiconset/80.png differ diff --git a/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.beta.appiconset/87.png b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.beta.appiconset/87.png new file mode 100644 index 00000000..cf77b56b Binary files /dev/null and b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.beta.appiconset/87.png differ diff --git a/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.beta.appiconset/88.png b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.beta.appiconset/88.png new file mode 100644 index 00000000..895250b7 Binary files /dev/null and b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.beta.appiconset/88.png differ diff --git a/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.beta.appiconset/92.png b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.beta.appiconset/92.png new file mode 100644 index 00000000..18f29816 Binary files /dev/null and b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.beta.appiconset/92.png differ diff --git a/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.beta.appiconset/Contents.json b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.beta.appiconset/Contents.json new file mode 100644 index 00000000..c03a6b92 --- /dev/null +++ b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.beta.appiconset/Contents.json @@ -0,0 +1,346 @@ +{ + "images": [ + { + "filename": "40.png", + "idiom": "iphone", + "scale": "2x", + "size": "20x20" + }, + { + "filename": "60.png", + "idiom": "iphone", + "scale": "3x", + "size": "20x20" + }, + { + "filename": "29.png", + "idiom": "iphone", + "scale": "1x", + "size": "29x29" + }, + { + "filename": "58.png", + "idiom": "iphone", + "scale": "2x", + "size": "29x29" + }, + { + "filename": "87.png", + "idiom": "iphone", + "scale": "3x", + "size": "29x29" + }, + { + "filename": "80.png", + "idiom": "iphone", + "scale": "2x", + "size": "40x40" + }, + { + "filename": "120.png", + "idiom": "iphone", + "scale": "3x", + "size": "40x40" + }, + { + "filename": "57.png", + "idiom": "iphone", + "scale": "1x", + "size": "57x57" + }, + { + "filename": "114.png", + "idiom": "iphone", + "scale": "2x", + "size": "57x57" + }, + { + "filename": "120.png", + "idiom": "iphone", + "scale": "2x", + "size": "60x60" + }, + { + "filename": "180.png", + "idiom": "iphone", + "scale": "3x", + "size": "60x60" + }, + { + "filename": "20.png", + "idiom": "ipad", + "scale": "1x", + "size": "20x20" + }, + { + "filename": "40.png", + "idiom": "ipad", + "scale": "2x", + "size": "20x20" + }, + { + "filename": "29.png", + "idiom": "ipad", + "scale": "1x", + "size": "29x29" + }, + { + "filename": "58.png", + "idiom": "ipad", + "scale": "2x", + "size": "29x29" + }, + { + "filename": "40.png", + "idiom": "ipad", + "scale": "1x", + "size": "40x40" + }, + { + "filename": "80.png", + "idiom": "ipad", + "scale": "2x", + "size": "40x40" + }, + { + "filename": "50.png", + "idiom": "ipad", + "scale": "1x", + "size": "50x50" + }, + { + "filename": "100.png", + "idiom": "ipad", + "scale": "2x", + "size": "50x50" + }, + { + "filename": "72.png", + "idiom": "ipad", + "scale": "1x", + "size": "72x72" + }, + { + "filename": "144.png", + "idiom": "ipad", + "scale": "2x", + "size": "72x72" + }, + { + "filename": "76.png", + "idiom": "ipad", + "scale": "1x", + "size": "76x76" + }, + { + "filename": "152.png", + "idiom": "ipad", + "scale": "2x", + "size": "76x76" + }, + { + "filename": "167.png", + "idiom": "ipad", + "scale": "2x", + "size": "83.5x83.5" + }, + { + "filename": "1024.png", + "idiom": "ios-marketing", + "scale": "1x", + "size": "1024x1024" + }, + { + "filename": "16.png", + "idiom": "mac", + "scale": "1x", + "size": "16x16" + }, + { + "filename": "32.png", + "idiom": "mac", + "scale": "2x", + "size": "16x16" + }, + { + "filename": "32.png", + "idiom": "mac", + "scale": "1x", + "size": "32x32" + }, + { + "filename": "64.png", + "idiom": "mac", + "scale": "2x", + "size": "32x32" + }, + { + "filename": "128.png", + "idiom": "mac", + "scale": "1x", + "size": "128x128" + }, + { + "filename": "256.png", + "idiom": "mac", + "scale": "2x", + "size": "128x128" + }, + { + "filename": "256.png", + "idiom": "mac", + "scale": "1x", + "size": "256x256" + }, + { + "filename": "512.png", + "idiom": "mac", + "scale": "2x", + "size": "256x256" + }, + { + "filename": "512.png", + "idiom": "mac", + "scale": "1x", + "size": "512x512" + }, + { + "filename": "1024.png", + "idiom": "mac", + "scale": "2x", + "size": "512x512" + }, + { + "filename": "48.png", + "idiom": "watch", + "role": "notificationCenter", + "scale": "2x", + "size": "24x24", + "subtype": "38mm" + }, + { + "filename": "55.png", + "idiom": "watch", + "role": "notificationCenter", + "scale": "2x", + "size": "27.5x27.5", + "subtype": "42mm" + }, + { + "filename": "58.png", + "idiom": "watch", + "role": "companionSettings", + "scale": "2x", + "size": "29x29" + }, + { + "filename": "87.png", + "idiom": "watch", + "role": "companionSettings", + "scale": "3x", + "size": "29x29" + }, + { + "filename": "66.png", + "idiom": "watch", + "role": "notificationCenter", + "scale": "2x", + "size": "33x33", + "subtype": "45mm" + }, + { + "filename": "80.png", + "idiom": "watch", + "role": "appLauncher", + "scale": "2x", + "size": "40x40", + "subtype": "38mm" + }, + { + "filename": "88.png", + "idiom": "watch", + "role": "appLauncher", + "scale": "2x", + "size": "44x44", + "subtype": "40mm" + }, + { + "filename": "92.png", + "idiom": "watch", + "role": "appLauncher", + "scale": "2x", + "size": "46x46", + "subtype": "41mm" + }, + { + "filename": "100.png", + "idiom": "watch", + "role": "appLauncher", + "scale": "2x", + "size": "50x50", + "subtype": "44mm" + }, + { + "idiom": "watch", + "role": "appLauncher", + "scale": "2x", + "size": "51x51", + "subtype": "45mm" + }, + { + "idiom": "watch", + "role": "appLauncher", + "scale": "2x", + "size": "54x54", + "subtype": "49mm" + }, + { + "filename": "172.png", + "idiom": "watch", + "role": "quickLook", + "scale": "2x", + "size": "86x86", + "subtype": "38mm" + }, + { + "filename": "196.png", + "idiom": "watch", + "role": "quickLook", + "scale": "2x", + "size": "98x98", + "subtype": "42mm" + }, + { + "filename": "216.png", + "idiom": "watch", + "role": "quickLook", + "scale": "2x", + "size": "108x108", + "subtype": "44mm" + }, + { + "idiom": "watch", + "role": "quickLook", + "scale": "2x", + "size": "117x117", + "subtype": "45mm" + }, + { + "idiom": "watch", + "role": "quickLook", + "scale": "2x", + "size": "129x129", + "subtype": "49mm" + }, + { + "filename": "1024.png", + "idiom": "watch-marketing", + "scale": "1x", + "size": "1024x1024" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.dev.appiconset/100.png b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.dev.appiconset/100.png new file mode 100644 index 00000000..5ff03063 Binary files /dev/null and b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.dev.appiconset/100.png differ diff --git a/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.dev.appiconset/1024.png b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.dev.appiconset/1024.png new file mode 100644 index 00000000..566c96ce Binary files /dev/null and b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.dev.appiconset/1024.png differ diff --git a/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.dev.appiconset/114.png b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.dev.appiconset/114.png new file mode 100644 index 00000000..670f7bfa Binary files /dev/null and b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.dev.appiconset/114.png differ diff --git a/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.dev.appiconset/120.png b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.dev.appiconset/120.png new file mode 100644 index 00000000..bf30e76f Binary files /dev/null and b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.dev.appiconset/120.png differ diff --git a/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.dev.appiconset/128.png b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.dev.appiconset/128.png new file mode 100644 index 00000000..af395d67 Binary files /dev/null and b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.dev.appiconset/128.png differ diff --git a/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.dev.appiconset/144.png b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.dev.appiconset/144.png new file mode 100644 index 00000000..0994327a Binary files /dev/null and b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.dev.appiconset/144.png differ diff --git a/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.dev.appiconset/152.png b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.dev.appiconset/152.png new file mode 100644 index 00000000..555e1678 Binary files /dev/null and b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.dev.appiconset/152.png differ diff --git a/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.dev.appiconset/16.png b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.dev.appiconset/16.png new file mode 100644 index 00000000..ffbb91f0 Binary files /dev/null and b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.dev.appiconset/16.png differ diff --git a/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.dev.appiconset/167.png b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.dev.appiconset/167.png new file mode 100644 index 00000000..b7198638 Binary files /dev/null and b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.dev.appiconset/167.png differ diff --git a/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.dev.appiconset/172.png b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.dev.appiconset/172.png new file mode 100644 index 00000000..e06d0e24 Binary files /dev/null and b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.dev.appiconset/172.png differ diff --git a/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.dev.appiconset/180.png b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.dev.appiconset/180.png new file mode 100644 index 00000000..92017c48 Binary files /dev/null and b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.dev.appiconset/180.png differ diff --git a/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.dev.appiconset/196.png b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.dev.appiconset/196.png new file mode 100644 index 00000000..3a3746a6 Binary files /dev/null and b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.dev.appiconset/196.png differ diff --git a/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.dev.appiconset/20.png b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.dev.appiconset/20.png new file mode 100644 index 00000000..7939c341 Binary files /dev/null and b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.dev.appiconset/20.png differ diff --git a/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.dev.appiconset/216.png b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.dev.appiconset/216.png new file mode 100644 index 00000000..8f7f70d8 Binary files /dev/null and b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.dev.appiconset/216.png differ diff --git a/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.dev.appiconset/256.png b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.dev.appiconset/256.png new file mode 100644 index 00000000..2bbfb7b2 Binary files /dev/null and b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.dev.appiconset/256.png differ diff --git a/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.dev.appiconset/29.png b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.dev.appiconset/29.png new file mode 100644 index 00000000..87b92135 Binary files /dev/null and b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.dev.appiconset/29.png differ diff --git a/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.dev.appiconset/32.png b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.dev.appiconset/32.png new file mode 100644 index 00000000..0f7ebb7a Binary files /dev/null and b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.dev.appiconset/32.png differ diff --git a/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.dev.appiconset/40.png b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.dev.appiconset/40.png new file mode 100644 index 00000000..50945cf1 Binary files /dev/null and b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.dev.appiconset/40.png differ diff --git a/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.dev.appiconset/48.png b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.dev.appiconset/48.png new file mode 100644 index 00000000..3ba857f7 Binary files /dev/null and b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.dev.appiconset/48.png differ diff --git a/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.dev.appiconset/50.png b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.dev.appiconset/50.png new file mode 100644 index 00000000..94aeb0d6 Binary files /dev/null and b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.dev.appiconset/50.png differ diff --git a/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.dev.appiconset/512.png b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.dev.appiconset/512.png new file mode 100644 index 00000000..3fc06cf1 Binary files /dev/null and b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.dev.appiconset/512.png differ diff --git a/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.dev.appiconset/55.png b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.dev.appiconset/55.png new file mode 100644 index 00000000..e3bc66d7 Binary files /dev/null and b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.dev.appiconset/55.png differ diff --git a/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.dev.appiconset/57.png b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.dev.appiconset/57.png new file mode 100644 index 00000000..3c161e42 Binary files /dev/null and b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.dev.appiconset/57.png differ diff --git a/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.dev.appiconset/58.png b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.dev.appiconset/58.png new file mode 100644 index 00000000..e13f32bd Binary files /dev/null and b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.dev.appiconset/58.png differ diff --git a/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.dev.appiconset/60.png b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.dev.appiconset/60.png new file mode 100644 index 00000000..449a9931 Binary files /dev/null and b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.dev.appiconset/60.png differ diff --git a/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.dev.appiconset/64.png b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.dev.appiconset/64.png new file mode 100644 index 00000000..42562523 Binary files /dev/null and b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.dev.appiconset/64.png differ diff --git a/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.dev.appiconset/66.png b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.dev.appiconset/66.png new file mode 100644 index 00000000..a4bba166 Binary files /dev/null and b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.dev.appiconset/66.png differ diff --git a/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.dev.appiconset/72.png b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.dev.appiconset/72.png new file mode 100644 index 00000000..79c1ed26 Binary files /dev/null and b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.dev.appiconset/72.png differ diff --git a/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.dev.appiconset/76.png b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.dev.appiconset/76.png new file mode 100644 index 00000000..405cef0b Binary files /dev/null and b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.dev.appiconset/76.png differ diff --git a/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.dev.appiconset/80.png b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.dev.appiconset/80.png new file mode 100644 index 00000000..2247ca8b Binary files /dev/null and b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.dev.appiconset/80.png differ diff --git a/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.dev.appiconset/87.png b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.dev.appiconset/87.png new file mode 100644 index 00000000..e6eb123f Binary files /dev/null and b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.dev.appiconset/87.png differ diff --git a/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.dev.appiconset/88.png b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.dev.appiconset/88.png new file mode 100644 index 00000000..2ec33234 Binary files /dev/null and b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.dev.appiconset/88.png differ diff --git a/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.dev.appiconset/92.png b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.dev.appiconset/92.png new file mode 100644 index 00000000..80b08ccd Binary files /dev/null and b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.dev.appiconset/92.png differ diff --git a/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.dev.appiconset/Contents.json b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.dev.appiconset/Contents.json new file mode 100644 index 00000000..c03a6b92 --- /dev/null +++ b/apps/mobile/ios/Uniswap/Images.xcassets/AppIcon.dev.appiconset/Contents.json @@ -0,0 +1,346 @@ +{ + "images": [ + { + "filename": "40.png", + "idiom": "iphone", + "scale": "2x", + "size": "20x20" + }, + { + "filename": "60.png", + "idiom": "iphone", + "scale": "3x", + "size": "20x20" + }, + { + "filename": "29.png", + "idiom": "iphone", + "scale": "1x", + "size": "29x29" + }, + { + "filename": "58.png", + "idiom": "iphone", + "scale": "2x", + "size": "29x29" + }, + { + "filename": "87.png", + "idiom": "iphone", + "scale": "3x", + "size": "29x29" + }, + { + "filename": "80.png", + "idiom": "iphone", + "scale": "2x", + "size": "40x40" + }, + { + "filename": "120.png", + "idiom": "iphone", + "scale": "3x", + "size": "40x40" + }, + { + "filename": "57.png", + "idiom": "iphone", + "scale": "1x", + "size": "57x57" + }, + { + "filename": "114.png", + "idiom": "iphone", + "scale": "2x", + "size": "57x57" + }, + { + "filename": "120.png", + "idiom": "iphone", + "scale": "2x", + "size": "60x60" + }, + { + "filename": "180.png", + "idiom": "iphone", + "scale": "3x", + "size": "60x60" + }, + { + "filename": "20.png", + "idiom": "ipad", + "scale": "1x", + "size": "20x20" + }, + { + "filename": "40.png", + "idiom": "ipad", + "scale": "2x", + "size": "20x20" + }, + { + "filename": "29.png", + "idiom": "ipad", + "scale": "1x", + "size": "29x29" + }, + { + "filename": "58.png", + "idiom": "ipad", + "scale": "2x", + "size": "29x29" + }, + { + "filename": "40.png", + "idiom": "ipad", + "scale": "1x", + "size": "40x40" + }, + { + "filename": "80.png", + "idiom": "ipad", + "scale": "2x", + "size": "40x40" + }, + { + "filename": "50.png", + "idiom": "ipad", + "scale": "1x", + "size": "50x50" + }, + { + "filename": "100.png", + "idiom": "ipad", + "scale": "2x", + "size": "50x50" + }, + { + "filename": "72.png", + "idiom": "ipad", + "scale": "1x", + "size": "72x72" + }, + { + "filename": "144.png", + "idiom": "ipad", + "scale": "2x", + "size": "72x72" + }, + { + "filename": "76.png", + "idiom": "ipad", + "scale": "1x", + "size": "76x76" + }, + { + "filename": "152.png", + "idiom": "ipad", + "scale": "2x", + "size": "76x76" + }, + { + "filename": "167.png", + "idiom": "ipad", + "scale": "2x", + "size": "83.5x83.5" + }, + { + "filename": "1024.png", + "idiom": "ios-marketing", + "scale": "1x", + "size": "1024x1024" + }, + { + "filename": "16.png", + "idiom": "mac", + "scale": "1x", + "size": "16x16" + }, + { + "filename": "32.png", + "idiom": "mac", + "scale": "2x", + "size": "16x16" + }, + { + "filename": "32.png", + "idiom": "mac", + "scale": "1x", + "size": "32x32" + }, + { + "filename": "64.png", + "idiom": "mac", + "scale": "2x", + "size": "32x32" + }, + { + "filename": "128.png", + "idiom": "mac", + "scale": "1x", + "size": "128x128" + }, + { + "filename": "256.png", + "idiom": "mac", + "scale": "2x", + "size": "128x128" + }, + { + "filename": "256.png", + "idiom": "mac", + "scale": "1x", + "size": "256x256" + }, + { + "filename": "512.png", + "idiom": "mac", + "scale": "2x", + "size": "256x256" + }, + { + "filename": "512.png", + "idiom": "mac", + "scale": "1x", + "size": "512x512" + }, + { + "filename": "1024.png", + "idiom": "mac", + "scale": "2x", + "size": "512x512" + }, + { + "filename": "48.png", + "idiom": "watch", + "role": "notificationCenter", + "scale": "2x", + "size": "24x24", + "subtype": "38mm" + }, + { + "filename": "55.png", + "idiom": "watch", + "role": "notificationCenter", + "scale": "2x", + "size": "27.5x27.5", + "subtype": "42mm" + }, + { + "filename": "58.png", + "idiom": "watch", + "role": "companionSettings", + "scale": "2x", + "size": "29x29" + }, + { + "filename": "87.png", + "idiom": "watch", + "role": "companionSettings", + "scale": "3x", + "size": "29x29" + }, + { + "filename": "66.png", + "idiom": "watch", + "role": "notificationCenter", + "scale": "2x", + "size": "33x33", + "subtype": "45mm" + }, + { + "filename": "80.png", + "idiom": "watch", + "role": "appLauncher", + "scale": "2x", + "size": "40x40", + "subtype": "38mm" + }, + { + "filename": "88.png", + "idiom": "watch", + "role": "appLauncher", + "scale": "2x", + "size": "44x44", + "subtype": "40mm" + }, + { + "filename": "92.png", + "idiom": "watch", + "role": "appLauncher", + "scale": "2x", + "size": "46x46", + "subtype": "41mm" + }, + { + "filename": "100.png", + "idiom": "watch", + "role": "appLauncher", + "scale": "2x", + "size": "50x50", + "subtype": "44mm" + }, + { + "idiom": "watch", + "role": "appLauncher", + "scale": "2x", + "size": "51x51", + "subtype": "45mm" + }, + { + "idiom": "watch", + "role": "appLauncher", + "scale": "2x", + "size": "54x54", + "subtype": "49mm" + }, + { + "filename": "172.png", + "idiom": "watch", + "role": "quickLook", + "scale": "2x", + "size": "86x86", + "subtype": "38mm" + }, + { + "filename": "196.png", + "idiom": "watch", + "role": "quickLook", + "scale": "2x", + "size": "98x98", + "subtype": "42mm" + }, + { + "filename": "216.png", + "idiom": "watch", + "role": "quickLook", + "scale": "2x", + "size": "108x108", + "subtype": "44mm" + }, + { + "idiom": "watch", + "role": "quickLook", + "scale": "2x", + "size": "117x117", + "subtype": "45mm" + }, + { + "idiom": "watch", + "role": "quickLook", + "scale": "2x", + "size": "129x129", + "subtype": "49mm" + }, + { + "filename": "1024.png", + "idiom": "watch-marketing", + "scale": "1x", + "size": "1024x1024" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/apps/mobile/ios/Uniswap/Images.xcassets/Contents.json b/apps/mobile/ios/Uniswap/Images.xcassets/Contents.json new file mode 100644 index 00000000..74d6a722 --- /dev/null +++ b/apps/mobile/ios/Uniswap/Images.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/apps/mobile/ios/Uniswap/Images.xcassets/SplashLogo.imageset/Contents.json b/apps/mobile/ios/Uniswap/Images.xcassets/SplashLogo.imageset/Contents.json new file mode 100644 index 00000000..34252522 --- /dev/null +++ b/apps/mobile/ios/Uniswap/Images.xcassets/SplashLogo.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images": [ + { + "filename": "SplashLogo 1.png", + "idiom": "universal", + "scale": "1x" + }, + { + "filename": "SplashLogo@2x 1.png", + "idiom": "universal", + "scale": "2x" + }, + { + "filename": "SplashLogo@3x 1.png", + "idiom": "universal", + "scale": "3x" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/apps/mobile/ios/Uniswap/Images.xcassets/SplashLogo.imageset/SplashLogo 1.png b/apps/mobile/ios/Uniswap/Images.xcassets/SplashLogo.imageset/SplashLogo 1.png new file mode 100644 index 00000000..d0d440ee Binary files /dev/null and b/apps/mobile/ios/Uniswap/Images.xcassets/SplashLogo.imageset/SplashLogo 1.png differ diff --git a/apps/mobile/ios/Uniswap/Images.xcassets/SplashLogo.imageset/SplashLogo@2x 1.png b/apps/mobile/ios/Uniswap/Images.xcassets/SplashLogo.imageset/SplashLogo@2x 1.png new file mode 100644 index 00000000..f747b8f1 Binary files /dev/null and b/apps/mobile/ios/Uniswap/Images.xcassets/SplashLogo.imageset/SplashLogo@2x 1.png differ diff --git a/apps/mobile/ios/Uniswap/Images.xcassets/SplashLogo.imageset/SplashLogo@3x 1.png b/apps/mobile/ios/Uniswap/Images.xcassets/SplashLogo.imageset/SplashLogo@3x 1.png new file mode 100644 index 00000000..5a84604e Binary files /dev/null and b/apps/mobile/ios/Uniswap/Images.xcassets/SplashLogo.imageset/SplashLogo@3x 1.png differ diff --git a/apps/mobile/ios/Uniswap/Images.xcassets/SplashScreenBackground.imageset/Contents.json b/apps/mobile/ios/Uniswap/Images.xcassets/SplashScreenBackground.imageset/Contents.json new file mode 100644 index 00000000..1110e730 --- /dev/null +++ b/apps/mobile/ios/Uniswap/Images.xcassets/SplashScreenBackground.imageset/Contents.json @@ -0,0 +1,89 @@ +{ + "images": [ + { + "filename": "background.png", + "idiom": "universal", + "scale": "1x" + }, + { + "appearances": [ + { + "appearance": "luminosity", + "value": "light" + } + ], + "filename": "background-3.png", + "idiom": "universal", + "scale": "1x" + }, + { + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "filename": "background-1-dark.png", + "idiom": "universal", + "scale": "1x" + }, + { + "filename": "background-1.png", + "idiom": "universal", + "scale": "2x" + }, + { + "appearances": [ + { + "appearance": "luminosity", + "value": "light" + } + ], + "filename": "background-4.png", + "idiom": "universal", + "scale": "2x" + }, + { + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "filename": "background-1-dark-1.png", + "idiom": "universal", + "scale": "2x" + }, + { + "filename": "background-2.png", + "idiom": "universal", + "scale": "3x" + }, + { + "appearances": [ + { + "appearance": "luminosity", + "value": "light" + } + ], + "filename": "background-5.png", + "idiom": "universal", + "scale": "3x" + }, + { + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "filename": "background-1-dark-2.png", + "idiom": "universal", + "scale": "3x" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/apps/mobile/ios/Uniswap/Images.xcassets/SplashScreenBackground.imageset/background-1-dark-1.png b/apps/mobile/ios/Uniswap/Images.xcassets/SplashScreenBackground.imageset/background-1-dark-1.png new file mode 100644 index 00000000..12690b0b Binary files /dev/null and b/apps/mobile/ios/Uniswap/Images.xcassets/SplashScreenBackground.imageset/background-1-dark-1.png differ diff --git a/apps/mobile/ios/Uniswap/Images.xcassets/SplashScreenBackground.imageset/background-1-dark-2.png b/apps/mobile/ios/Uniswap/Images.xcassets/SplashScreenBackground.imageset/background-1-dark-2.png new file mode 100644 index 00000000..12690b0b Binary files /dev/null and b/apps/mobile/ios/Uniswap/Images.xcassets/SplashScreenBackground.imageset/background-1-dark-2.png differ diff --git a/apps/mobile/ios/Uniswap/Images.xcassets/SplashScreenBackground.imageset/background-1-dark.png b/apps/mobile/ios/Uniswap/Images.xcassets/SplashScreenBackground.imageset/background-1-dark.png new file mode 100644 index 00000000..12690b0b Binary files /dev/null and b/apps/mobile/ios/Uniswap/Images.xcassets/SplashScreenBackground.imageset/background-1-dark.png differ diff --git a/apps/mobile/ios/Uniswap/Images.xcassets/SplashScreenBackground.imageset/background-1.png b/apps/mobile/ios/Uniswap/Images.xcassets/SplashScreenBackground.imageset/background-1.png new file mode 100644 index 00000000..ff3dd16f Binary files /dev/null and b/apps/mobile/ios/Uniswap/Images.xcassets/SplashScreenBackground.imageset/background-1.png differ diff --git a/apps/mobile/ios/Uniswap/Images.xcassets/SplashScreenBackground.imageset/background-2.png b/apps/mobile/ios/Uniswap/Images.xcassets/SplashScreenBackground.imageset/background-2.png new file mode 100644 index 00000000..ff3dd16f Binary files /dev/null and b/apps/mobile/ios/Uniswap/Images.xcassets/SplashScreenBackground.imageset/background-2.png differ diff --git a/apps/mobile/ios/Uniswap/Images.xcassets/SplashScreenBackground.imageset/background-3.png b/apps/mobile/ios/Uniswap/Images.xcassets/SplashScreenBackground.imageset/background-3.png new file mode 100644 index 00000000..ff3dd16f Binary files /dev/null and b/apps/mobile/ios/Uniswap/Images.xcassets/SplashScreenBackground.imageset/background-3.png differ diff --git a/apps/mobile/ios/Uniswap/Images.xcassets/SplashScreenBackground.imageset/background-4.png b/apps/mobile/ios/Uniswap/Images.xcassets/SplashScreenBackground.imageset/background-4.png new file mode 100644 index 00000000..ff3dd16f Binary files /dev/null and b/apps/mobile/ios/Uniswap/Images.xcassets/SplashScreenBackground.imageset/background-4.png differ diff --git a/apps/mobile/ios/Uniswap/Images.xcassets/SplashScreenBackground.imageset/background-5.png b/apps/mobile/ios/Uniswap/Images.xcassets/SplashScreenBackground.imageset/background-5.png new file mode 100644 index 00000000..ff3dd16f Binary files /dev/null and b/apps/mobile/ios/Uniswap/Images.xcassets/SplashScreenBackground.imageset/background-5.png differ diff --git a/apps/mobile/ios/Uniswap/Images.xcassets/SplashScreenBackground.imageset/background.png b/apps/mobile/ios/Uniswap/Images.xcassets/SplashScreenBackground.imageset/background.png new file mode 100644 index 00000000..ff3dd16f Binary files /dev/null and b/apps/mobile/ios/Uniswap/Images.xcassets/SplashScreenBackground.imageset/background.png differ diff --git a/apps/mobile/ios/Uniswap/Info.plist b/apps/mobile/ios/Uniswap/Info.plist new file mode 100644 index 00000000..ef886bd6 --- /dev/null +++ b/apps/mobile/ios/Uniswap/Info.plist @@ -0,0 +1,166 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + Uniswap + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconName + AppIcon + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleLocalizations + + en + zh-Hans + zh-Hant + fr + ja + pt + vi + + es-419 + es-BZ + es-CU + es-DO + es-GT + es-HN + es-MX + es-NI + es-PA + es-PE + es-PR + es-SV + es-US + + es-AR + es-BO + es-CL + es-CO + es-CR + es-EC + es-ES + es-PY + es-UY + es-VE + + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleSignature + ???? + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLName + uniswap + CFBundleURLSchemes + + uniswap + + + + CFBundleTypeRole + Viewer + CFBundleURLName + org.uniswap.wc + CFBundleURLSchemes + + wc + + + + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + ITSAppUsesNonExemptEncryption + + LSApplicationCategoryType + + LSApplicationQueriesSchemes + + itms-apps + itms-beta + + LSRequiresIPhoneOS + + LSMinimumSystemVersion + 15.1 + NSAdvertisingAttributionReportEndpoint + https://appsflyer-skadnetwork.com/ + NSAppTransportSecurity + + NSExceptionDomains + + localhost + + NSExceptionAllowsInsecureHTTPLoads + + + + + NSCameraUsageDescription + $(PRODUCT_NAME) Wallet needs access to your Camera to scan QR codes + NSFaceIDUsageDescription + Enabling Face ID helps $(PRODUCT_NAME) Wallet keep your assets secure. + NSLocationAlwaysAndWhenInUseUsageDescription + $(PRODUCT_NAME) Wallet does not require access to your location. + NSLocationWhenInUseUsageDescription + $(PRODUCT_NAME) Wallet does not require access to your location. + NSMicrophoneUsageDescription + $(PRODUCT_NAME) Wallet does not require access to the microphone. + NSPhotoLibraryUsageDescription + $(PRODUCT_NAME) Wallet needs access to your Camera Roll to choose an avatar for your username + NSUbiquitousContainers + + iCloud.Uniswap + + NSUbiquitousContainerIsDocumentScopePublic + + NSUbiquitousContainerName + Uniswap + NSUbiquitousContainerSupportedFolderLevels + Any + + + NSUserActivityTypes + + TokenPriceConfigurationIntent + + OneSignal_app_groups_key + group.com.uniswap.mobile.onesignal + OneSignal_suppress_launch_urls + + UIAppFonts + + InputMono-Regular.ttf + Basel-Grotesk-Book.otf + Basel-Grotesk-Medium.otf + + UIBackgroundModes + + remote-notification + + UILaunchStoryboardName + SplashScreen.storyboard + UIRequiredDeviceCapabilities + + arm64 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + + UIViewControllerBasedStatusBarAppearance + + + diff --git a/apps/mobile/ios/Uniswap/Notifications/SilentPushEventEmitter.m b/apps/mobile/ios/Uniswap/Notifications/SilentPushEventEmitter.m new file mode 100644 index 00000000..343b5119 --- /dev/null +++ b/apps/mobile/ios/Uniswap/Notifications/SilentPushEventEmitter.m @@ -0,0 +1,18 @@ +// +// SilentPushEventEmitter.m +// Uniswap +// +// Created by John Short on 9/29/25. +// + +#import +#import +#import + +@interface RCT_EXTERN_MODULE(SilentPushEventEmitter, RCTEventEmitter) + +RCT_EXTERN_METHOD(supportedEvents) +RCT_EXTERN_METHOD(addListener:(NSString *)eventName) +RCT_EXTERN_METHOD(removeListeners:(nonnull NSNumber *)count) + +@end diff --git a/apps/mobile/ios/Uniswap/Notifications/SilentPushEventEmitter.swift b/apps/mobile/ios/Uniswap/Notifications/SilentPushEventEmitter.swift new file mode 100644 index 00000000..c79a3fb0 --- /dev/null +++ b/apps/mobile/ios/Uniswap/Notifications/SilentPushEventEmitter.swift @@ -0,0 +1,31 @@ +// +// SilentPushEventEmitter.swift +// Uniswap +// +// Created by John Short on 9/29/25. +// + +import React + +@objc(SilentPushEventEmitter) +open class SilentPushEventEmitter: RCTEventEmitter { + + public static weak var emitter: RCTEventEmitter? + + override init() { + super.init() + SilentPushEventEmitter.emitter = self + } + + open override func supportedEvents() -> [String] { + ["SilentPushReceived"] + } + + @objc(emitEventWithPayload:) + public static func emitEvent(with payload: [String: Any]) { + guard let emitter = emitter else { + return + } + emitter.sendEvent(withName: "SilentPushReceived", body: payload) + } +} diff --git a/apps/mobile/ios/Uniswap/Onboarding/Backup/MnemonicConfirmationManager.m b/apps/mobile/ios/Uniswap/Onboarding/Backup/MnemonicConfirmationManager.m new file mode 100644 index 00000000..9ffa4934 --- /dev/null +++ b/apps/mobile/ios/Uniswap/Onboarding/Backup/MnemonicConfirmationManager.m @@ -0,0 +1,31 @@ +// +// RNTMnemonicTestManager.m +// Uniswap +// +// Created by Thomas Thachil 8/1/2022. +// + +#import "Uniswap-Swift.h" +#import +#import "RNSwiftUI-Bridging-Header.h" + +@interface MnemonicConfirmationManager : RCTViewManager +@end + +@implementation MnemonicConfirmationManager +RCT_EXPORT_MODULE() + +RCT_EXPORT_SWIFTUI_PROPERTY(mnemonicId, NSString, MnemonicConfirmationView); +RCT_EXPORT_SWIFTUI_PROPERTY(shouldShowSmallText, BOOL, MnemonicConfirmationView); +RCT_EXPORT_SWIFTUI_CALLBACK(onConfirmComplete, RCTDirectEventBlock, MnemonicConfirmationView); +RCT_EXPORT_SWIFTUI_PROPERTY(selectedWordPlaceholder, NSString, MnemonicConfirmationView); + +- (UIView *)view +{ + MnemonicConfirmationView *proxy = [[MnemonicConfirmationView alloc] init]; + UIView *view = [proxy view]; + NSMutableDictionary *storage = [MnemonicConfirmationView storage]; + storage[[NSValue valueWithNonretainedObject:view]] = proxy; + return view; +} +@end diff --git a/apps/mobile/ios/Uniswap/Onboarding/Backup/MnemonicConfirmationView.swift b/apps/mobile/ios/Uniswap/Onboarding/Backup/MnemonicConfirmationView.swift new file mode 100644 index 00000000..39c09d73 --- /dev/null +++ b/apps/mobile/ios/Uniswap/Onboarding/Backup/MnemonicConfirmationView.swift @@ -0,0 +1,166 @@ +// +// MnemonicConfirmationView.swift +// Uniswap +// +// Created by Thomas Thachil on 8/1/22. +// + +import React +import SwiftUI + +@objcMembers class MnemonicConfirmationView: NSObject { + private var vc = UIHostingController(rootView: MnemonicConfirmation()) + + static let storage = NSMutableDictionary() + + var mnemonicId: String { + set { vc.rootView.setMnemonicId(mnemonicId: newValue) } + get { return vc.rootView.props.mnemonicId } + } + + var selectedWordPlaceholder: String { + set { vc.rootView.props.selectedWordPlaceholder = newValue} + get { return vc.rootView.props.selectedWordPlaceholder } + } + + var shouldShowSmallText: Bool { + set { vc.rootView.props.shouldShowSmallText = newValue} + get { return vc.rootView.props.shouldShowSmallText } + } + + var onConfirmComplete: RCTDirectEventBlock { + set { vc.rootView.props.onConfirmComplete = newValue } + get { return vc.rootView.props.onConfirmComplete } + } + + var view: UIView { + vc.view.backgroundColor = .clear + return vc.view + } +} + +class MnemonicConfirmationProps : ObservableObject { + @Published var mnemonicId: String = "" + @Published var selectedWordPlaceholder: String = "" + @Published var shouldShowSmallText: Bool = false + @Published var onConfirmComplete: RCTDirectEventBlock = { _ in } + @Published var mnemonicWords: [String] = Array(repeating: "", count: 12) + @Published var scrambledWords: [String] = Array(repeating: "", count: 12) + @Published var typedWordIndexes: [Int] = Array(repeating: -1, count: 12) + @Published var selectedIndex: Int = 0 +} + +struct MnemonicConfirmation: View { + + @ObservedObject var props = MnemonicConfirmationProps() + + let rnEthersRS = RNEthersRS() + + func setMnemonicId(mnemonicId: String) { + props.mnemonicId = mnemonicId + if let mnemonic = rnEthersRS.retrieveMnemonic(mnemonicId: mnemonicId) { + props.mnemonicWords = mnemonic.components(separatedBy: " ") + props.scrambledWords = mnemonic.components(separatedBy: " ").shuffled() + } + } + + func onSuggestionTapped(tappedIndex: Int) { + props.typedWordIndexes[props.selectedIndex] = tappedIndex + + // Check if typed words match mnemonic words only if all fields are filled + if (props.selectedIndex == props.mnemonicWords.count - 1) { + if (isMnemonicMatch()) { + props.onConfirmComplete([:]) + } + } else if (props.mnemonicWords[props.selectedIndex] == props.scrambledWords[tappedIndex] && props.selectedIndex < props.mnemonicWords.count - 1) { + props.selectedIndex += 1 + } + } + + func isMnemonicMatch() -> Bool { + for i in 0.. String { + guard index >= 0 && index < props.typedWordIndexes.count else { + return "" + } + + let scrambledWordIndex = props.typedWordIndexes[index] + if scrambledWordIndex == -1 { + return "" + } + + return props.scrambledWords[scrambledWordIndex] + } + + func getFieldText(index: Int) -> String { + let typedWord = getTypedWord(index: index) + return typedWord.isEmpty ? props.selectedWordPlaceholder : typedWord + } + + func getFieldStatus(index: Int) -> MnemonicInputStatus { + let typedWord = getTypedWord(index: index) + + if (typedWord.isEmpty) { + return MnemonicInputStatus.noInput + } else if (props.mnemonicWords[index] != typedWord) { + return MnemonicInputStatus.wrongInput + } + return MnemonicInputStatus.correctInput + } + + var body: some View { + let end = props.mnemonicWords.count - 1 + let middle = end / 2 + + VStack(alignment: .leading, spacing: 0) { + HStack(alignment: .center, spacing: 24) { + VStack(alignment: .leading, spacing: 8) { + ForEach((0...middle), id: \.self) {index in + MnemonicTextField(index: index + 1, + word: getFieldText(index: index), + status: getFieldStatus(index: index), + shouldShowSmallText: props.shouldShowSmallText + ) + .frame(maxWidth: .infinity, alignment: .leading) + } + }.frame(maxWidth: .infinity) + VStack(alignment: .leading, spacing: 8) { + ForEach((middle + 1...end), id: \.self) {index in + MnemonicTextField(index: index + 1, + word: getFieldText(index: index), + status: getFieldStatus(index: index), + shouldShowSmallText: props.shouldShowSmallText + ) + .frame(maxWidth: .infinity, alignment: .leading) + } + }.frame(maxWidth: .infinity) + }.frame(maxWidth: .infinity) + .padding(EdgeInsets(top: 24, leading: 32, bottom: 24, trailing: 32)) + .background(Colors.surface2) + .cornerRadius(20) + .overlay( + RoundedRectangle(cornerRadius: 20) + .stroke(Colors.surface3, lineWidth: 1) + ) + + MnemonicConfirmationWordBankView( + words: props.scrambledWords, + usedWordIndexes: props.typedWordIndexes, + labelCallback: onSuggestionTapped, + shouldShowSmallText: props.shouldShowSmallText + ) + .frame(maxWidth: .infinity) + .padding(.top, 24) + + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + } +} diff --git a/apps/mobile/ios/Uniswap/Onboarding/Backup/MnemonicConfirmationWordBankView.swift b/apps/mobile/ios/Uniswap/Onboarding/Backup/MnemonicConfirmationWordBankView.swift new file mode 100644 index 00000000..9c6a7854 --- /dev/null +++ b/apps/mobile/ios/Uniswap/Onboarding/Backup/MnemonicConfirmationWordBankView.swift @@ -0,0 +1,94 @@ +// +// MnemonicTagsView.swift +// Uniswap +// +// Created by Thomas Thachil on 8/8/22. +// + +import SwiftUI + +struct BankWord: Hashable { + var index: Int + var word: String = "" + var used: Bool = false +} + +struct MnemonicConfirmationWordBankView: View { + let smallFont = UIFont(name: "BaselGrotesk-Book", size: 14) + let mediumFont = UIFont(name: "BaselGrotesk-Book", size: 16) + + var groupedWords: [[BankWord]] = [[BankWord]]() + let screenWidth = UIScreen.main.bounds.width // Used to calculate max number of tags per row + var labelCallback: ((Int) -> Void)? + let shouldShowSmallText: Bool + + init(words: [String], usedWordIndexes: [Int], labelCallback: @escaping (Int) -> Void, shouldShowSmallText: Bool) { + self.labelCallback = labelCallback + self.shouldShowSmallText = shouldShowSmallText + + // Ensure that proper words are displayed as used in case of duplicates + var wordStructs = words.enumerated().map { index, word in BankWord(index: index, word: word) } + usedWordIndexes.forEach{ idx in + if (idx != -1) { + wordStructs[idx].used = true + } + } + + // Set up grouped words + self.groupedWords = createGroupedWords(wordStructs) + } + + private func createGroupedWords(_ items: [BankWord]) -> [[BankWord]] { + var groupedItems: [[BankWord]] = [[BankWord]]() + var tempItems: [BankWord] = [BankWord]() + var width: CGFloat = 0 + + for word in items { + let label = UILabel() + label.text = word.word + label.sizeToFit() + + let labelWidth = label.frame.size.width + 32 + + if (width + labelWidth + 32) < screenWidth { + width += labelWidth + tempItems.append(word) + } else { + width = labelWidth + groupedItems.append(tempItems) + tempItems.removeAll() + tempItems.append(word) + } + } + + groupedItems.append(tempItems) + return groupedItems + } + + var body: some View { + VStack(alignment: .center) { + ForEach(groupedWords, id: \.self) { subItems in + HStack(spacing: shouldShowSmallText ? 4 : 8) { + ForEach(subItems, id: \.self) { bankWord in + Text(bankWord.word) + .font(Font((shouldShowSmallText ? smallFont : mediumFont)!)) + .fixedSize() + .padding(shouldShowSmallText ? EdgeInsets(top: 8, leading: 10, bottom: 8, trailing: 10) : EdgeInsets(top: 10, leading: 12, bottom: 10, trailing: 12)) + .background(Colors.surface1) + .foregroundColor(Colors.neutral1) + .clipShape(RoundedRectangle(cornerRadius: 100, style: .continuous)) + .shadow(color: Color.black.opacity(0.04), radius: 10) + .overlay( + RoundedRectangle(cornerRadius: 50) + .stroke(Colors.surface3, lineWidth: 1) + ) + .onTapGesture { + labelCallback?(bankWord.index) + } + .opacity(bankWord.used ? 0.5 : 1) + } + } + } + } + } +} diff --git a/apps/mobile/ios/Uniswap/Onboarding/Backup/MnemonicDisplayManager.m b/apps/mobile/ios/Uniswap/Onboarding/Backup/MnemonicDisplayManager.m new file mode 100644 index 00000000..b5e7d1fc --- /dev/null +++ b/apps/mobile/ios/Uniswap/Onboarding/Backup/MnemonicDisplayManager.m @@ -0,0 +1,32 @@ +// +// RNTMnemonicManager.m +// Uniswap +// +// Created by Spencer Yen on 5/24/22. +// + +#import "Uniswap-Swift.h" +#import +#import "RNSwiftUI-Bridging-Header.h" + +@interface MnemonicDisplayManager : RCTViewManager +@end + +@implementation MnemonicDisplayManager +RCT_EXPORT_MODULE(MnemonicDisplay) + +RCT_EXPORT_SWIFTUI_PROPERTY(mnemonicId, NSString, MnemonicDisplayView); +RCT_EXPORT_SWIFTUI_PROPERTY(copyText, NSString, MnemonicDisplayView); +RCT_EXPORT_SWIFTUI_PROPERTY(copiedText, NSString, MnemonicDisplayView); +RCT_EXPORT_SWIFTUI_CALLBACK(onHeightMeasured, RCTDirectEventBlock, MnemonicDisplayView) +RCT_EXPORT_SWIFTUI_CALLBACK(onEmptyMnemonic, RCTDirectEventBlock, MnemonicDisplayView) + +- (UIView *)view { + MnemonicDisplayView *proxy = [[MnemonicDisplayView alloc] init]; + UIView *view = [proxy view]; + NSMutableDictionary *storage = [MnemonicDisplayView storage]; + storage[[NSValue valueWithNonretainedObject:view]] = proxy; + return view; +} + +@end diff --git a/apps/mobile/ios/Uniswap/Onboarding/Backup/MnemonicDisplayView.swift b/apps/mobile/ios/Uniswap/Onboarding/Backup/MnemonicDisplayView.swift new file mode 100644 index 00000000..f214f4c8 --- /dev/null +++ b/apps/mobile/ios/Uniswap/Onboarding/Backup/MnemonicDisplayView.swift @@ -0,0 +1,155 @@ +// +// MnemonicDisplayView.swift +// Uniswap +// +// Created by Gary Ye on 8/31/23. +// + +import SwiftUI + +@objcMembers class MnemonicDisplayView: NSObject { + private var vc = UIHostingController(rootView: MnemonicDisplay()) + + static let storage = NSMutableDictionary() + + var mnemonicId: String { + set { vc.rootView.setMnemonicId(mnemonicId: newValue) } + get { return vc.rootView.props.mnemonicId } + } + + var copyText: String { + set { vc.rootView.props.copyText = newValue } + get { return vc.rootView.props.copyText } + } + + var copiedText: String { + set { vc.rootView.props.copiedText = newValue } + get { return vc.rootView.props.copiedText } + } + + var onHeightMeasured: RCTDirectEventBlock? { + didSet { + vc.rootView.props.onHeightMeasured = { [weak self] height in + self?.onHeightMeasured?([ "height": height ]) + } + } + } + + var onEmptyMnemonic: RCTDirectEventBlock? { + didSet { + vc.rootView.props.onEmptyMnemonic = { [weak self] mnemonicId in + self?.onEmptyMnemonic?(["mnemonicId": mnemonicId]) + } + } + } + + var view: UIView { + vc.view.backgroundColor = .clear + return vc.view + } +} + +class MnemonicDisplayProps: ObservableObject { + @Published var mnemonicId: String = "" + @Published var copyText: String = "" + @Published var copiedText: String = "" + @Published var mnemonicWords: [String] = Array(repeating: "", count: 12) + var onHeightMeasured: ((CGFloat) -> Void)? + var onEmptyMnemonic: ((String) -> Void)? +} + +struct MnemonicDisplay: View { + @ObservedObject var props = MnemonicDisplayProps() + @State private var buttonPadding: CGFloat = 20 + + let rnEthersRS = RNEthersRS() + let interFont = UIFont(name: "BaselGrotesk-Medium", size: 20) + + func setMnemonicId(mnemonicId: String) { + props.mnemonicId = mnemonicId + if let mnemonic = rnEthersRS.retrieveMnemonic(mnemonicId: mnemonicId) { + props.mnemonicWords = mnemonic.components(separatedBy: " ") + } + } + + var body: some View { + if (props.mnemonicWords.count > 12) { + ScrollView { + content + }.fadeOutBottom(fadeLength: 50) + } else { + content + } + } + + @ViewBuilder + var content: some View { + let end = props.mnemonicWords.count - 1 + let middle = end / 2 + + VStack(alignment: .leading, spacing: 0) { + ZStack { + HStack(alignment: .center, spacing: 24) { + VStack(alignment: .leading, spacing: 8) { + ForEach((0...middle), id: \.self) { index in + MnemonicTextField(index: index + 1, + word: props.mnemonicWords[index] + ) + .frame(maxWidth: .infinity, alignment: .leading) + } + }.frame(maxWidth: .infinity) + VStack(alignment: .leading, spacing: 8) { + ForEach((middle + 1...end), id: \.self) { index in + MnemonicTextField(index: index + 1, + word: props.mnemonicWords[index] + ) + .frame(maxWidth: .infinity, alignment: .leading) + } + }.frame(maxWidth: .infinity) + } + } + .frame(maxWidth: .infinity) + .padding(EdgeInsets(top: 24, leading: 32, bottom: 24, trailing: 32)) + .background(Colors.surface2) + .cornerRadius(20) + .overlay( + RoundedRectangle(cornerRadius: 20) + .stroke(Colors.surface3, lineWidth: 1) + ) + .overlay( + HStack { + Spacer() + RelativeOffsetView(y: -0.5, onOffsetCalculated: { _, offsetY in + buttonPadding = abs(offsetY) + }) { + CopyButton( + copyButtonText: props.copyText, + copiedButtonText: props.copiedText, + textToCopy: props.mnemonicWords.joined(separator: " ") + ) + } + Spacer() + }, + alignment: .top + ) + } + .frame(maxWidth: .infinity, alignment: .top) + .padding(.top, buttonPadding) + .overlay( + GeometryReader { geometry in + Color.clear + .onAppear { + props.onHeightMeasured?(geometry.size.height) + } + .onChange(of: geometry.size.height) { newValue in + props.onHeightMeasured?(newValue) + } + } + ) + .onAppear { + if props.mnemonicWords.isEmpty || props.mnemonicWords.allSatisfy({ $0.isEmpty }) { + props.onEmptyMnemonic?(props.mnemonicId) + } + } + } +} diff --git a/apps/mobile/ios/Uniswap/Onboarding/Backup/MnemonicTextField.swift b/apps/mobile/ios/Uniswap/Onboarding/Backup/MnemonicTextField.swift new file mode 100644 index 00000000..aaf29e73 --- /dev/null +++ b/apps/mobile/ios/Uniswap/Onboarding/Backup/MnemonicTextField.swift @@ -0,0 +1,61 @@ +// +// MnemonicTextField.swift +// Uniswap +// +// Created by Thomas Thachil on 8/8/22. +// + +import SwiftUI + +enum MnemonicInputStatus { + case noInput + case correctInput + case wrongInput +} + +struct MnemonicTextField: View { + let smallFont = UIFont(name: "BaselGrotesk-Book", size: 14) + let mediumFont = UIFont(name: "BaselGrotesk-Book", size: 16) + + var index: Int + var word = "" + var shouldShowSmallText: Bool + var status: MnemonicInputStatus + + init(index: Int, + word: String, + status: MnemonicInputStatus = .correctInput, + shouldShowSmallText: Bool = false + ) { + self.index = index + self.word = word + self.status = status + self.shouldShowSmallText = shouldShowSmallText + } + + func getLabelColor() -> Color { + switch (status) { + case .noInput: + return Colors.neutral3 + case .correctInput: + return Colors.neutral1 + case .wrongInput: + return Colors.statusCritical + } + } + + var body: some View { + HStack(alignment: VerticalAlignment.center, spacing: 18) { + Text(String(index)) + .font(Font((shouldShowSmallText ? smallFont : mediumFont)!)) + .foregroundColor(Colors.neutral2) + .frame(width: shouldShowSmallText ? 14 : 16, alignment: Alignment.leading) + + Text(word) + .font(Font((shouldShowSmallText ? smallFont : mediumFont)!)) + .foregroundColor(getLabelColor()) + .multilineTextAlignment(.leading) + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} diff --git a/apps/mobile/ios/Uniswap/Onboarding/Backup/RNSwiftUI-Bridging-Header.h b/apps/mobile/ios/Uniswap/Onboarding/Backup/RNSwiftUI-Bridging-Header.h new file mode 100644 index 00000000..6b1512bd --- /dev/null +++ b/apps/mobile/ios/Uniswap/Onboarding/Backup/RNSwiftUI-Bridging-Header.h @@ -0,0 +1,40 @@ +// +// RNSwiftUI-Bridging-Header.h +// Uniswap +// +// Created by Thomas Thachil on 8/3/22. +// +// + +#ifndef RNSwiftUI_Bridging_Header_h +#define RNSwiftUI_Bridging_Header_h + + +#import "React/RCTViewManager.h" +#import "React/RCTConvert.h" +#import "React/RCTComponentData.h" +#import "React/RCTBridgeModule.h" +#import "React/UIView+React.h" + +#define RCT_EXPORT_SWIFTUI_PROPERTY(name, type, proxyClass) \ +RCT_CUSTOM_VIEW_PROPERTY(name, type, proxyClass) { \ + NSMutableDictionary *storage = [proxyClass storage]; \ + proxyClass *proxy = storage[[NSValue valueWithNonretainedObject:view]]; \ + proxy.name = [RCTConvert type:json]; \ +} + +#define RCT_EXPORT_SWIFTUI_CALLBACK(name, type, proxyClass) \ +RCT_REMAP_VIEW_PROPERTY(name, __custom__, type) \ +- (void)set_##name:(id)json forView:(UIView *)view withDefaultView:(UIView *)defaultView RCT_DYNAMIC { \ + NSMutableDictionary *storage = [proxyClass storage]; \ + proxyClass *proxy = storage[[NSValue valueWithNonretainedObject:view]]; \ + void (^eventHandler)(NSDictionary *event) = ^(NSDictionary *event) { \ + RCTComponentEvent *componentEvent = [[RCTComponentEvent alloc] initWithName:@""#name \ + viewTag:view.reactTag \ + body:event]; \ + [self.bridge.eventDispatcher sendEvent:componentEvent]; \ + }; \ + proxy.name = eventHandler; \ +} + +#endif /* RNSwiftUI_Bridging_Header_h */ diff --git a/apps/mobile/ios/Uniswap/Onboarding/EmbeddedWallet/EmbeddedWallet.m b/apps/mobile/ios/Uniswap/Onboarding/EmbeddedWallet/EmbeddedWallet.m new file mode 100644 index 00000000..25731f36 --- /dev/null +++ b/apps/mobile/ios/Uniswap/Onboarding/EmbeddedWallet/EmbeddedWallet.m @@ -0,0 +1,21 @@ +// +// EmbeddedWallet.m +// Uniswap +// +// Created by Bruno R. Nunes on 11/5/24. +// + +#import +#import + +@interface RCT_EXTERN_MODULE(EmbeddedWallet, RCTEventEmitter) + +RCT_EXTERN_METHOD(decryptMnemonicForPublicKey:(NSString *)encryptedMnemonic + publicKeyBase64:(NSString *)publicKeyBase64 + resolver:(RCTPromiseResolveBlock)resolver + rejecter:(RCTPromiseRejectBlock)rejecter) + +RCT_EXTERN_METHOD(generateKeyPair:(RCTPromiseResolveBlock)resolver + rejecter:(RCTPromiseRejectBlock)rejecter) + +@end diff --git a/apps/mobile/ios/Uniswap/Onboarding/EmbeddedWallet/EmbeddedWallet.swift b/apps/mobile/ios/Uniswap/Onboarding/EmbeddedWallet/EmbeddedWallet.swift new file mode 100644 index 00000000..373437e5 --- /dev/null +++ b/apps/mobile/ios/Uniswap/Onboarding/EmbeddedWallet/EmbeddedWallet.swift @@ -0,0 +1,199 @@ +// +// EmbeddedWallet.swift +// Uniswap +// +// Created by Bruno R. Nunes on 11/5/24. +// + +import Foundation + +@objc(EmbeddedWallet) +class EmbeddedWallet: NSObject { + + private let queue = DispatchQueue(label: "com.uniswap.EmbeddedWallet", qos: .background) + + private var publicKeyPairMap: [String: SecKey] = [:] + + @objc static func requiresMainQueueSetup() -> Bool { + return false + } + + /** + Decrypts an encrypted mnemonic using the private key corresponding to the provided public key. + + - parameter encryptedMnemonic: The Base64 encoded encrypted mnemonic. + - parameter publicKeyBase64: The Base64 encoded public key in SPKI format. + - returns: Resolves with the decrypted mnemonic. + */ + @objc func decryptMnemonicForPublicKey(_ encryptedMnemonic: String, publicKeyBase64: String, resolver: @escaping RCTPromiseResolveBlock, rejecter: @escaping RCTPromiseRejectBlock) { + queue.async { + guard let privateKey = self.publicKeyPairMap[publicKeyBase64] else { + let error = NSError(domain: "EmbeddedWalletModule", code: 404, userInfo: [NSLocalizedDescriptionKey: "Key pair not found for public key \(publicKeyBase64)"]) + rejecter("KEY_PAIR_NOT_FOUND", "Key pair not found for public key \(publicKeyBase64)", error) + return + } + + do { + let decryptedSeedPhrase = try decryptMnemonic(encryptedMnemonic: encryptedMnemonic, privateKey: privateKey) + self.publicKeyPairMap.removeValue(forKey: publicKeyBase64) + resolver(decryptedSeedPhrase) + } catch { + rejecter("DECRYPTION_FAILED", "Failed to decrypt mnemonic", error) + } + } + } + + /** + Generates an RSA key pair for encrypting/decrypting seed phrases. + Stores the private key in memory in publicKeyPairMap, to be used later for decryption. + + - returns: Resolves with the Base64 encoded public key in SPKI format. + */ + @objc func generateKeyPair(_ resolver: @escaping RCTPromiseResolveBlock, rejecter: @escaping RCTPromiseRejectBlock) { + queue.async { + do { + let (publicKey, privateKey) = try generateRSAKeyPair() + self.publicKeyPairMap[publicKey] = privateKey + resolver(publicKey) + } catch { + rejecter("KEY_PAIR_GENERATION_FAILED", "Failed to generate RSA key pair", error) + } + } + } +} + +enum EmbeddedWalletError: Error { + case keyGenerationFailed + case keyExportFailed + case invalidEncryptedData + case decryptionFailed + case invalidDecryptedData +} + +/** + Generates an RSA key pair for encrypting/decrypting seed phrases. + Matches the web implementation using RSA-OAEP with SHA-256. + + - returns: A tuple containing the Base64 encoded public key in SPKI format and the SecKeyPair for later decryption. + */ +func generateRSAKeyPair() throws -> (publicKeyBase64: String, privateKey: SecKey) { + // Define key pair attributes + let keyPairAttr: [String: Any] = [ + kSecAttrKeyType as String: kSecAttrKeyTypeRSA, + kSecAttrKeySizeInBits as String: 2048, + kSecPrivateKeyAttrs as String: [ + kSecAttrIsPermanent as String: false + ] + ] + + var error: Unmanaged? + + // Generate private key using SecKeyCreateRandomKey + guard let privateKey = SecKeyCreateRandomKey(keyPairAttr as CFDictionary, &error) else { + if let error = error?.takeRetainedValue() { + throw error + } + throw EmbeddedWalletError.keyGenerationFailed + } + + // Obtain the public key from the private key + guard let publicKey = SecKeyCopyPublicKey(privateKey) else { + throw EmbeddedWalletError.keyGenerationFailed + } + + // Export the public key in SPKI format + let spkiData = try exportPublicKeySPKI(publicKey: publicKey) + let publicKeyBase64 = spkiData.base64EncodedString() + + return (publicKeyBase64, privateKey) +} + +/** + Decrypts an encrypted seed phrase using the provided RSA private key. + + - parameter encryptedMnemonic: The Base64 encoded encrypted seed phrase. + - parameter privateKey: The RSA private key for decryption. + - returns: The decrypted seed phrase string. + */ +func decryptMnemonic(encryptedMnemonic: String, privateKey: SecKey) throws -> String { + guard let encryptedData = Data(base64Encoded: encryptedMnemonic) else { + throw EmbeddedWalletError.invalidEncryptedData + } + + var error: Unmanaged? + guard let decryptedData = SecKeyCreateDecryptedData(privateKey, .rsaEncryptionOAEPSHA256, encryptedData as CFData, &error) as Data? else { + throw EmbeddedWalletError.decryptionFailed + } + + guard let decryptedString = String(data: decryptedData, encoding: .utf8) else { + throw EmbeddedWalletError.invalidDecryptedData + } + + return decryptedString +} + +enum ASN1Error: Error { + case encodingFailed +} + +// OID for rsaEncryption: 1.2.840.113549.1.1.1 +let rsaOID: [UInt8] = [ + 0x30, 0x0D, // SEQUENCE, length 13 + 0x06, 0x09, // OBJECT IDENTIFIER, length 9 + 0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x01, 0x01, + 0x05, 0x00 // NULL +] + +func encodeASN1Length(_ length: Int) -> Data { + if length < 128 { + // Short form: single byte + return Data([UInt8(length)]) + } else { + // Determine the number of bytes needed to represent the length + var tempLength = length + var lengthBytes: [UInt8] = [] + while tempLength > 0 { + lengthBytes.insert(UInt8(tempLength & 0xFF), at: 0) + tempLength >>= 8 + } + let lengthOfLength = UInt8(lengthBytes.count) + // First byte: 0x80 | number of length bytes + var encoded = Data([0x80 | lengthOfLength]) + encoded.append(contentsOf: lengthBytes) + return encoded + } +} + +func encodeASN1Sequence(_ sequence: Data) -> Data { + var encoded = Data([0x30]) // SEQUENCE tag + let length = encodeASN1Length(sequence.count) + encoded.append(length) + encoded.append(sequence) + return encoded +} + +func encodeASN1BitString(_ bitString: Data) -> Data { + var encoded = Data([0x03]) // BIT STRING tag + // Add a leading 0x00 to indicate no unused bits + let bitStringWithPadding = Data([0x00]) + bitString + let length = encodeASN1Length(bitStringWithPadding.count) + encoded.append(length) + encoded.append(bitStringWithPadding) + return encoded +} + +func exportPublicKeySPKI(publicKey: SecKey) throws -> Data { + // Retrieve the raw public key data (PKCS#1) + guard let keyData = SecKeyCopyExternalRepresentation(publicKey, nil) as Data? else { + throw ASN1Error.encodingFailed + } + + // Encode the AlgorithmIdentifier (rsaEncryption OID with NULL parameters) + let algorithmIdentifier = Data(rsaOID) + // Encode the BIT STRING + let bitString = encodeASN1BitString(keyData) + // Combine AlgorithmIdentifier and BIT STRING into SubjectPublicKeyInfo SEQUENCE + let subjectPublicKeyInfo = encodeASN1Sequence(algorithmIdentifier + bitString) + + return subjectPublicKeyInfo +} diff --git a/apps/mobile/ios/Uniswap/Onboarding/Import/SeedPhraseInputManager.m b/apps/mobile/ios/Uniswap/Onboarding/Import/SeedPhraseInputManager.m new file mode 100644 index 00000000..9ee0cff4 --- /dev/null +++ b/apps/mobile/ios/Uniswap/Onboarding/Import/SeedPhraseInputManager.m @@ -0,0 +1,25 @@ +// +// SeedPhraseInputManager.swift +// Uniswap +// +// Created by Gary Ye on 9/7/23. +// + +#import "React/RCTViewManager.h" + +@interface RCT_EXTERN_MODULE(SeedPhraseInputManager, RCTViewManager) + +RCT_EXPORT_VIEW_PROPERTY(targetMnemonicId, NSString?) +RCT_EXPORT_VIEW_PROPERTY(strings, NSDictionary) +RCT_EXPORT_VIEW_PROPERTY(onInputValidated, RCTDirectEventBlock); +RCT_EXPORT_VIEW_PROPERTY(onMnemonicStored, RCTDirectEventBlock); +RCT_EXPORT_VIEW_PROPERTY(onPasteStart, RCTDirectEventBlock); +RCT_EXPORT_VIEW_PROPERTY(onPasteEnd, RCTDirectEventBlock); +RCT_EXPORT_VIEW_PROPERTY(onSubmitError, RCTDirectEventBlock); +RCT_EXPORT_VIEW_PROPERTY(onHeightMeasured, RCTDirectEventBlock); +RCT_EXPORT_VIEW_PROPERTY(testID, NSString?) +RCT_EXTERN_METHOD(handleSubmit: (nonnull NSNumber *)node) +RCT_EXTERN_METHOD(focus: (nonnull NSNumber *)node) +RCT_EXTERN_METHOD(blur: (nonnull NSNumber *)node) + +@end diff --git a/apps/mobile/ios/Uniswap/Onboarding/Import/SeedPhraseInputManager.swift b/apps/mobile/ios/Uniswap/Onboarding/Import/SeedPhraseInputManager.swift new file mode 100644 index 00000000..462ef0dc --- /dev/null +++ b/apps/mobile/ios/Uniswap/Onboarding/Import/SeedPhraseInputManager.swift @@ -0,0 +1,48 @@ +// +// SeedPhraseInputManager.swift +// Uniswap +// +// Created by Gary Ye on 9/15/23. +// + +// Using a view manager written in Swift instead of bridging headers +// because couldn't get RCT_EXTERN_METHOD to work with that approach +@objc(SeedPhraseInputManager) +class SeedPhraseInputManager: RCTViewManager { + + override func view() -> UIView! { + return SeedPhraseInputView() + } + + // Required by RN to initialize on main thread + override class func requiresMainQueueSetup() -> Bool { + true + } + + @objc func focus(_ node: NSNumber) -> Void { + DispatchQueue.main.async { + let component = self.bridge.uiManager.view(forReactTag: node) as? SeedPhraseInputView + + component?.focus() + } + } + + @objc func blur(_ node: NSNumber) -> Void { + DispatchQueue.main.async { + let component = self.bridge.uiManager.view(forReactTag: node) as? SeedPhraseInputView + + component?.blur() + } + } + + @objc func handleSubmit(_ node: NSNumber) -> Void { + DispatchQueue.main.async { + let component = self.bridge.uiManager.view( + forReactTag: node + ) as? SeedPhraseInputView + + component?.handleSubmit() + // TODO garydebug add error logging for view not found + } + } +} diff --git a/apps/mobile/ios/Uniswap/Onboarding/Import/SeedPhraseInputView.swift b/apps/mobile/ios/Uniswap/Onboarding/Import/SeedPhraseInputView.swift new file mode 100644 index 00000000..da443a53 --- /dev/null +++ b/apps/mobile/ios/Uniswap/Onboarding/Import/SeedPhraseInputView.swift @@ -0,0 +1,321 @@ +// +// SeedPhraseInputView.swift +// Uniswap +// +// Created by Gary Ye on 9/7/23. +// + +import React +import SwiftUI + +class SeedPhraseInputView: UIView { + private let vc = UIHostingController(rootView: SeedPhraseInput()) + + override init(frame: CGRect) { + super.init(frame: frame) + + vc.view.translatesAutoresizingMaskIntoConstraints = false + vc.view.backgroundColor = .clear + + self.addSubview(vc.view) + + NSLayoutConstraint.activate([ + vc.view.topAnchor.constraint(equalTo: self.topAnchor), + vc.view.bottomAnchor.constraint(equalTo: self.bottomAnchor), + vc.view.leadingAnchor.constraint(equalTo: self.leadingAnchor), + vc.view.trailingAnchor.constraint(equalTo: self.trailingAnchor) + ]) + } + + required init?(coder aDecoder: NSCoder) { + // Used to load view into storyboarder + fatalError("init(coder:) has not been implemented") + } + + override func reactSetFrame(_ frame: CGRect) { + super.reactSetFrame(frame); + vc.view.frame = frame + } + + @objc + var targetMnemonicId: String? { + get { vc.rootView.viewModel.targetMnemonicId } + set { vc.rootView.viewModel.targetMnemonicId = newValue } + } + + @objc + var strings: Dictionary { + get { vc.rootView.viewModel.rawRNStrings } + set { vc.rootView.viewModel.rawRNStrings = newValue } + } + + @objc + var onInputValidated: RCTDirectEventBlock { + get { vc.rootView.viewModel.onInputValidated } + set { vc.rootView.viewModel.onInputValidated = newValue } + } + + @objc + var onMnemonicStored: RCTDirectEventBlock { + set { vc.rootView.viewModel.onMnemonicStored = newValue } + get { return vc.rootView.viewModel.onMnemonicStored } + } + + @objc + var onPasteStart: RCTDirectEventBlock { + set { vc.rootView.viewModel.onPasteStart = newValue } + get { return vc.rootView.viewModel.onPasteStart } + } + + @objc + var onPasteEnd: RCTDirectEventBlock { + set { vc.rootView.viewModel.onPasteEnd = newValue } + get { return vc.rootView.viewModel.onPasteEnd } + } + + @objc + var onSubmitError: RCTDirectEventBlock { + set { vc.rootView.viewModel.onSubmitError = newValue } + get { return vc.rootView.viewModel.onSubmitError } + } + + @objc + func focus() { + vc.rootView.focusInput() + } + + @objc + func blur() { + vc.rootView.blurInput() + } + + @objc + var onHeightMeasured: RCTDirectEventBlock { + set { vc.rootView.viewModel.onHeightMeasured = newValue } + get { return vc.rootView.viewModel.onHeightMeasured } + } + + @objc + var testID: String? { + get { vc.rootView.viewModel.testID } + set { vc.rootView.viewModel.testID = newValue } + } + + @objc + var handleSubmit: () -> Void { + get { return vc.rootView.viewModel.handleSubmit } + } +} + +struct TextEditModifier: ViewModifier { + func body(content: Content) -> some View { + if #available(iOS 16.0, *) { + content + .scrollContentBackground(.hidden) + } else { + content + .onAppear { + UITextView.appearance().backgroundColor = .clear + } + } + } +} + +// We use UIKit and UIViewRepresentable instead of SwiftUI to have better control of focus state here +// At the time of updating this, there were limitations of @FocusState in SwiftUI, particularly in relation to dynamic updates + interactions from outside the view +// So, we use this approach to maintain the focus state +struct SeedPhraseTextView: UIViewRepresentable { + @Binding var text: String + @Binding var isFocused: Bool + + class Coordinator: NSObject, UITextViewDelegate { + var parent: SeedPhraseTextView + + init(_ parent: SeedPhraseTextView) { + self.parent = parent + } + + func textViewDidChange(_ textView: UITextView) { + parent.text = textView.text + } + + func textViewDidBeginEditing(_ textView: UITextView) { + // Sync isFocused binding when user focuses the input + DispatchQueue.main.async { + self.parent.isFocused = true + } + } + + func textViewDidEndEditing(_ textView: UITextView) { + // Sync isFocused binding when user blurs the input + DispatchQueue.main.async { + self.parent.isFocused = false + } + } + } + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + func makeUIView(context: Context) -> UITextView { + let textView = UITextView() + + textView.delegate = context.coordinator + textView.font = UIFont(name: "BaselGrotesk-Book", size: 17) + textView.autocorrectionType = .no + textView.autocapitalizationType = .none + textView.backgroundColor = UIColor.clear + + return textView + } + + func updateUIView(_ uiView: UITextView, context: Context) { + uiView.text = text + + if isFocused { + if !uiView.isFirstResponder, uiView.window != nil { + uiView.becomeFirstResponder() + } + } else { + if uiView.isFirstResponder { + uiView.resignFirstResponder() + } + } + } +} + +struct SeedPhraseInput: View { + @ObservedObject var viewModel = SeedPhraseInputViewModel() + + private var font = Font(UIFont(name: "BaselGrotesk-Book", size: 17)!) + private var subtitleFont = Font(UIFont(name: "BaselGrotesk-Book", size: 17)!) + private var labelFont = Font(UIFont(name: "BaselGrotesk-Book", size: 15)!) + private var buttonFont = Font(UIFont(name: "BaselGrotesk-Medium", size: 15)!) + + func focusInput() { + viewModel.isFocused = true + } + + func blurInput() { + viewModel.isFocused = false + } + + var body: some View { + VStack(spacing: 12) { + VStack { + VStack { + ZStack(alignment: .topLeading) { + SeedPhraseTextView(text: $viewModel.input, isFocused: $viewModel.isFocused) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + .modifier(TextEditModifier()) + .frame(minHeight: 96) // 120 - 2 * 12 for padding + .accessibility(identifier: viewModel.testID ?? "import-account-input") + if (viewModel.input.isEmpty) { + Text(viewModel.strings.inputPlaceholder) + .font(subtitleFont) + .padding(.horizontal, 6) + .padding(.vertical, 8) + .foregroundColor(Colors.neutral3) + .allowsHitTesting(false) // Prevents capturing touch events by the placeholder instead of input + } + } + .fixedSize(horizontal: false, vertical: true) + .background(Colors.surface1) + .padding(12) // Adds to default TextEditor padding 8 + .cornerRadius(16) + .overlay( + RoundedRectangle(cornerRadius: 16) + .inset(by: 1) + .stroke(mapStatusToColor(status: viewModel.status), lineWidth: 1) + ) + .onTapGesture { + self.focusInput() + } + .overlay( + Group { + if viewModel.input.isEmpty { + HStack { + Spacer() + RelativeOffsetView(y: 0.5) { + PasteButton( + pasteButtonText: viewModel.strings.pasteButton, + onPaste: handlePaste, + onPasteStart: { + viewModel.onPasteStart([:]) + }, + onPasteEnd: { + viewModel.onPasteEnd([:]) + } + ) + } + Spacer() + } + } + }, + alignment: .bottom + ) + }.padding(.bottom, 12) + + + if (errorMessage() != nil) { + HStack(spacing: 4) { + AlertTriangleIcon() + .frame(width: 14, height: 14) + .foregroundColor(Colors.statusCritical) + Text(errorMessage() ?? "") + .font(labelFont) + .foregroundColor(Colors.statusCritical) + } + .frame(alignment: .center) + } + } + .overlay( + GeometryReader { geometry in + Color.clear + .onAppear { + viewModel.onHeightMeasured(["height": geometry.size.height]) + } + .onChange(of: geometry.size.height) { newValue in + viewModel.onHeightMeasured(["height": newValue]) + } + } + ) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .font(font) + } + + private func mapStatusToColor(status: SeedPhraseInputViewModel.Status) -> Color { + switch viewModel.status { + case .none: + return Colors.surface3 + case .valid: + return Colors.statusSuccess + case .error: + return Colors.statusCritical + } + } + + private func errorMessage() -> String? { + switch viewModel.error { + case .invalidPhrase: + return viewModel.strings.errorInvalidPhrase + case .invalidWord(let word): + return "\(viewModel.strings.errorInvalidWord) \(word)" + case .tooManyWords, .notEnoughWords: + return viewModel.strings.errorPhraseLength + case .wrongRecoveryPhrase: + return viewModel.strings.errorWrongPhrase + case .wordIsAddress: + return viewModel.strings.errorWordIsAddress + default: + return nil + } + } + + private func handlePaste(pastedText: String) { + viewModel.input = pastedText + } +} diff --git a/apps/mobile/ios/Uniswap/Onboarding/Import/SeedPhraseInputViewModel.swift b/apps/mobile/ios/Uniswap/Onboarding/Import/SeedPhraseInputViewModel.swift new file mode 100644 index 00000000..b7d31668 --- /dev/null +++ b/apps/mobile/ios/Uniswap/Onboarding/Import/SeedPhraseInputViewModel.swift @@ -0,0 +1,219 @@ +// +// SeedPhraseInputViewModel.swift +// Uniswap +// +// Created by Gary Ye on 9/10/23. +// + +import Foundation + +class SeedPhraseInputViewModel: ObservableObject { + + enum Status: String { + case none + case valid + case error + } + + enum MnemonicError: Equatable { + case invalidPhrase + case invalidWord(String) + case notEnoughWords + case tooManyWords + case wrongRecoveryPhrase + case wordIsAddress + } + + struct ReactNativeStrings { + var inputPlaceholder: String + var pasteButton: String + var errorInvalidWord: String + var errorPhraseLength: String + var errorWrongPhrase: String + var errorInvalidPhrase: String + var errorWordIsAddress: String + } + + let rnEthersRS = RNEthersRS() + + // Following block of variables will come from RN + @Published var targetMnemonicId: String? = nil + + @Published var testID: String? = nil + + @Published var rawRNStrings: Dictionary = Dictionary() { + didSet { + strings = ReactNativeStrings( + inputPlaceholder: rawRNStrings["inputPlaceholder"] ?? "", + pasteButton: rawRNStrings["pasteButton"] ?? "", + errorInvalidWord: rawRNStrings["errorInvalidWord"] ?? "", + errorPhraseLength: rawRNStrings["errorPhraseLength"] ?? "", + errorWrongPhrase: rawRNStrings["errorWrongPhrase"] ?? "", + errorInvalidPhrase: rawRNStrings["errorInvalidPhrase"] ?? "", + errorWordIsAddress: rawRNStrings["errorWordIsAddress"] ?? "" + ) + } + } + @Published var strings: ReactNativeStrings = ReactNativeStrings( + inputPlaceholder: "", + pasteButton: "", + errorInvalidWord: "", + errorPhraseLength: "", + errorWrongPhrase: "", + errorInvalidPhrase: "", + errorWordIsAddress: "" + ) + @Published var onInputValidated: RCTDirectEventBlock = { _ in } + @Published var onMnemonicStored: RCTDirectEventBlock = { _ in } + @Published var onPasteStart: RCTDirectEventBlock = { _ in } + @Published var onPasteEnd: RCTDirectEventBlock = { _ in } + @Published var onSubmitError: RCTDirectEventBlock = { _ in } + @Published var onHeightMeasured: RCTDirectEventBlock = { _ in } + + private var lastWordValidationTimer: Timer? + private let lastWordValidationTimeout: TimeInterval = 1.0 + + @Published var input = "" { + didSet { + handleInputChange() + } + } + + @Published var isFocused = false + + @Published var skipLastWord = true + @Published var status: Status = .none + @Published var error: MnemonicError? = nil + + private let minCount = 12 + private let maxCount = 24 + + func handleSubmit() { + lastWordValidationTimer?.invalidate() + + let normalized = normalizeInput(value: input) + let mnemonic = trimInput(value: normalized) + let words = mnemonic.components(separatedBy: " ") + let valid = rnEthersRS.validateMnemonic(mnemonic: mnemonic) + + error = nil + if (words.count < minCount || minCount + 1 ..< maxCount ~= words.count) { + status = .error + error = .notEnoughWords + } else if (words.count > maxCount) { + status = .error + error = .tooManyWords + } else if (!valid) { + status = .error + error = .invalidPhrase + } else { + submitMnemonic(mnemonic: mnemonic) + } + + if (error != nil) { + onSubmitError([:]) + } + } + + private func submitMnemonic(mnemonic: String) { + if (targetMnemonicId != nil) { + rnEthersRS.generateAddressForMnemonic( + mnemonic: mnemonic, + derivationIndex: 0, + resolve: { mnemonicId in + if (targetMnemonicId == String(describing: mnemonicId ?? "")) { + storeMnemonic(mnemonic: mnemonic) + } else { + status = .error + error = .wrongRecoveryPhrase + onSubmitError([:]) + } + }, reject: { code, message, error in + onSubmitError([:]) + print("SeedPhraseInputView model error while generating address: \(message ?? "")") + }) + } else { + storeMnemonic(mnemonic: mnemonic) + } + } + + private func storeMnemonic(mnemonic: String) { + rnEthersRS.importMnemonic( + mnemonic: mnemonic, + resolve: { mnemonicId in + onMnemonicStored(["mnemonicId": String(describing: mnemonicId ?? "")]) + }, + reject: { code, message, error in + onSubmitError([:]) + print("SeedPhraseInputView model error while storing mnemonic: \(message ?? "")") + } + ) + } + + private func normalizeInput(value: String) -> String { + return value.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression).lowercased() + } + + private func isAddress(value: String) -> Bool { + return value.starts(with: "0x") && value.count == 42 + } + + private func trimInput(value: String) -> String { + return value.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private func handleInputChange() { + let normalized = normalizeInput(value: input) + let skipLastWord = normalized.last != " " + let skipInvalidWord = skipLastWord && !isAddress(value: normalized) + validateInput(normalizedInput: normalized, skipInvalidWord: skipInvalidWord) + + lastWordValidationTimer?.invalidate() + + if (skipLastWord) { + lastWordValidationTimer = Timer.scheduledTimer( + withTimeInterval: lastWordValidationTimeout, + repeats: false) { _ in + DispatchQueue.global(qos: .background).async { + self.validateInput(normalizedInput: normalized, skipInvalidWord: false) + } + } + } + } + + private func validateInput(normalizedInput: String, skipInvalidWord: Bool) { + let mnemonic = trimInput(value: normalizedInput) + + let words = mnemonic.components(separatedBy: " ") + + let isValidLength = words.count >= minCount && words.count <= maxCount + let firstInvalidWord = rnEthersRS.findInvalidWord(mnemonic: mnemonic) + + let isAddress = mnemonic.starts(with: "0x") && mnemonic.count == 42 + let isFirstWordInvalid = firstInvalidWord == words.last && skipInvalidWord + let isInvalidLengthError = (error == .notEnoughWords || error == .tooManyWords) && !isValidLength + + if (isFirstWordInvalid) { + return + } else if (isAddress) { + status = .error + error = .wordIsAddress + } else if (firstInvalidWord != "") { + status = .error + error = .invalidWord(firstInvalidWord) + } else if (isInvalidLengthError) { + return + } else if (firstInvalidWord == "" && isValidLength) { + status = .valid + } else{ + status = .none + } + + if (status != .error) { + error = nil + } + + let canSubmit = error == nil && mnemonic != "" + onInputValidated(["canSubmit": canSubmit]) + } +} diff --git a/apps/mobile/ios/Uniswap/Onboarding/Scantastic/EncryptionUtils.swift b/apps/mobile/ios/Uniswap/Onboarding/Scantastic/EncryptionUtils.swift new file mode 100644 index 00000000..716cadc9 --- /dev/null +++ b/apps/mobile/ios/Uniswap/Onboarding/Scantastic/EncryptionUtils.swift @@ -0,0 +1,139 @@ +// +// EncryptionUtils.swift +// Uniswap +// +// Created by Christine Legge on 1/23/24. +// + +import CryptoKit +import Foundation + +enum EncryptionError: Error { + case invalidModulus + case invalidExponent + case invalidPublicKey + case unknown +} + +// Convert Base64URL to Base64 and add padding if necessary +func Base64URLToBase64(base64url: String) -> String { + var base64 = base64url + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + if base64.count % 4 != 0 { + base64.append(String(repeating: "=", count: 4 - base64.count % 4)) + } + return base64 +} + +// Calculate the length field for an ASN.1 sequence. +func lengthField(of valueField: [UInt8]) throws -> [UInt8] { + var count = valueField.count + + if count < 128 { + return [ UInt8(count) ] + } + + // The number of bytes needed to encode count. + let lengthBytesCount = Int((log2(Double(count)) / 8) + 1) + + // The first byte in the length field encoding the number of remaining bytes. + let firstLengthFieldByte = UInt8(128 + lengthBytesCount) + + var lengthField: [UInt8] = [] + for _ in 0..> 8 + } + + // Include the first byte. + lengthField.insert(firstLengthFieldByte, at: 0) + + return lengthField +} + +func generatePublicRSAKey(modulus: String, exponent: String) throws -> SecKey { + // Lets encode them from b64 url to b64 + let encodedModulus = Base64URLToBase64(base64url: modulus) + let encodedExponent = Base64URLToBase64(base64url: exponent) + + // First we need to get our modulus and exponent into UInt8 arrays + // We can do this by decoding the Base64 strings (URL safe) into Data + // and then converting the Data into UInt8 arrays + guard let modulusData = Data(base64Encoded: encodedModulus) else { + throw EncryptionError.invalidModulus + } + + guard let exponentData = Data(base64Encoded: encodedExponent) else { + throw EncryptionError.invalidExponent + } + + var modulus = modulusData.withUnsafeBytes { Data(Array($0)).withUnsafeBytes { Array($0) } } + let exponent = exponentData.withUnsafeBytes { Data(Array($0)).withUnsafeBytes { Array($0) } } + + // Lets add 0x00 at the front of the modulus + modulus.insert(0x00, at: 0) + + var sequenceEncoded: [UInt8] = [] + do { + // encode as integers + var modulusEncoded: [UInt8] = [] + modulusEncoded.append(0x02) + modulusEncoded.append(contentsOf: try lengthField(of: modulus)) + modulusEncoded.append(contentsOf: modulus) + + var exponentEncoded: [UInt8] = [] + exponentEncoded.append(0x02) + exponentEncoded.append(contentsOf: try lengthField(of: exponent)) + exponentEncoded.append(contentsOf: exponent) + + sequenceEncoded.append(0x30) + sequenceEncoded.append(contentsOf: try lengthField(of: (modulusEncoded + exponentEncoded))) + sequenceEncoded.append(contentsOf: (modulusEncoded + exponentEncoded)) + } catch { + throw EncryptionError.invalidPublicKey + } + + let keyData = Data(sequenceEncoded) + + // RSA key size is the number of bits of the modulus. + let keySize = (modulus.count * 8) + + let attributes: [String: Any] = [ + kSecAttrKeyType as String: kSecAttrKeyTypeRSA, + kSecAttrKeyClass as String: kSecAttrKeyClassPublic, + kSecAttrKeySizeInBits as String: keySize + ] + + guard let publicKey = SecKeyCreateWithData(keyData as CFData, attributes as CFDictionary, nil) else { + throw EncryptionError.invalidPublicKey + } + + return publicKey +} + +func encryptForStorage(plaintext: String, publicKey: SecKey) throws -> Data +{ + // Encrypt the plaintext + let plaintextData = Data(plaintext.utf8) + let algorithm: SecKeyAlgorithm = .rsaEncryptionOAEPSHA256 + + guard SecKeyIsAlgorithmSupported(publicKey, .encrypt, algorithm) else { + throw EncryptionError.invalidPublicKey + } + + var error: Unmanaged? + guard let ciphertextData = SecKeyCreateEncryptedData(publicKey, algorithm, plaintextData as CFData, &error) else { + if let error = error { + throw error.takeRetainedValue() as Error + } else { + throw EncryptionError.unknown + } + } + + return ciphertextData as Data +} diff --git a/apps/mobile/ios/Uniswap/Onboarding/Scantastic/ScantasticEncryption.m b/apps/mobile/ios/Uniswap/Onboarding/Scantastic/ScantasticEncryption.m new file mode 100644 index 00000000..2612b523 --- /dev/null +++ b/apps/mobile/ios/Uniswap/Onboarding/Scantastic/ScantasticEncryption.m @@ -0,0 +1,19 @@ +// +// ScantasticEncryption.m +// Uniswap +// +// Created by Christine Legge on 1/23/24. +// + +#import +#import + +@interface RCT_EXTERN_MODULE(ScantasticEncryption, RCTEventEmitter) + +RCT_EXTERN_METHOD(getEncryptedMnemonic: (NSString *)mnemonicId + n: (NSString *)n + e: (NSString *)e + resolve: (RCTPromiseResolveBlock)resolve + reject: (RCTPromiseRejectBlock)reject) + +@end diff --git a/apps/mobile/ios/Uniswap/Onboarding/Scantastic/ScantasticEncryption.swift b/apps/mobile/ios/Uniswap/Onboarding/Scantastic/ScantasticEncryption.swift new file mode 100644 index 00000000..edabb1f3 --- /dev/null +++ b/apps/mobile/ios/Uniswap/Onboarding/Scantastic/ScantasticEncryption.swift @@ -0,0 +1,62 @@ +// +// ScantasticEncryption.swift +// Uniswap +// +// Created by Christine Legge on 1/23/24. +// + +import Foundation +import CryptoKit + +enum ScantasticError: String, Error { + case publicKeyError = "publicKeyError" + case cipherTextError = "cipherTextError" +} + +@objc(ScantasticEncryption) +class ScantasticEncryption: RCTEventEmitter { + let rnEthersRS = RNEthersRS() + + @objc override static func requiresMainQueueSetup() -> Bool { + return false + } + + override func supportedEvents() -> [String]! { + return [] + } + + /** + Retrieves encrypted mnemonic + + - parameter mnemonicId: key string associated with mnemonic to backup + - parameter n: base64encoded value + - parameter e: base64encoded value + */ + @objc(getEncryptedMnemonic:n:e:resolve:reject:) + func getEncryptedMnemonic( + mnemonicId: String, n: String, e: String, resolve: RCTPromiseResolveBlock, + reject: RCTPromiseRejectBlock + ) { + + guard let mnemonic = rnEthersRS.retrieveMnemonic(mnemonicId: mnemonicId) else { + return reject(RNEthersRSError.retrieveMnemonicError.rawValue, "Failed to retrieve mnemonic", RNEthersRSError.retrieveMnemonicError) + } + + let publicKey: SecKey + do { + publicKey = try generatePublicRSAKey(modulus: n, exponent: e) + } catch { + return reject(ScantasticError.publicKeyError.rawValue, "Failed to generate public Key ", ScantasticError.publicKeyError) + } + + let encodedCiphertext: Data + do { + encodedCiphertext = try encryptForStorage(plaintext:mnemonic,publicKey:publicKey) + } catch { + return reject(ScantasticError.cipherTextError.rawValue, "Failed to encrypt the mnemonic", ScantasticError.cipherTextError) + } + + let b64encodedCiphertext = encodedCiphertext.base64EncodedString() + return resolve(b64encodedCiphertext) + } +} diff --git a/apps/mobile/ios/Uniswap/PrivacyInfo.xcprivacy b/apps/mobile/ios/Uniswap/PrivacyInfo.xcprivacy new file mode 100644 index 00000000..02a8a056 --- /dev/null +++ b/apps/mobile/ios/Uniswap/PrivacyInfo.xcprivacy @@ -0,0 +1,50 @@ + + + + + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryFileTimestamp + NSPrivacyAccessedAPITypeReasons + + C617.1 + 0A2A.1 + 3B52.1 + + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryUserDefaults + NSPrivacyAccessedAPITypeReasons + + CA92.1 + 1C8F.1 + C56D.1 + + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryDiskSpace + NSPrivacyAccessedAPITypeReasons + + E174.1 + 85F4.1 + + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategorySystemBootTime + NSPrivacyAccessedAPITypeReasons + + 35F9.1 + + + + NSPrivacyCollectedDataTypes + + NSPrivacyTracking + + + diff --git a/apps/mobile/ios/Uniswap/RNCloudBackupsManager/EncryptionHelper.swift b/apps/mobile/ios/Uniswap/RNCloudBackupsManager/EncryptionHelper.swift new file mode 100644 index 00000000..da1fe62b --- /dev/null +++ b/apps/mobile/ios/Uniswap/RNCloudBackupsManager/EncryptionHelper.swift @@ -0,0 +1,85 @@ +// +// EncryptionHelper.swift +// Uniswap +// +// Created by Spencer Yen on 7/26/22. +// + +import CryptoKit +import Argon2Swift + +/** + Encrypts given secret using AES-GCM cipher secured by symmetric key derived from given password. + + - parameter secret: plaintext secret to encrypt + - parameter password: password to generate encryption key + - parameter salt: randomized data string used with password to generate encryption key + - returns: encrypted secret string + */ +func encrypt(secret: String, password: String, salt: String) throws -> String { + let key = try keyFromPassword(password: password, salt: salt) + let secretData = secret.data(using: .utf8)! + + // Encrypt data into SealedBox, return as string + let sealedBox = try AES.GCM.seal(secretData, using: key) + let encryptedData = sealedBox.combined + let encryptedSecret = encryptedData!.base64EncodedString() + + return encryptedSecret +} + +/** + Attempts to decrypt AES-GCM encrypted secret using symmetric key derived from given user pin. + + - parameter encryptedSecret: secret in cipher encrypted form + - parameter password: password to generate encryption key + - parameter salt: randomized data string used when secret was originally encrypted + - returns: decrypted secret string + */ +func decrypt(encryptedSecret: String, password: String, salt: String) throws -> String { + let key = try keyFromPassword(password: password, salt: salt) + + // Recreate SealedBox from encrypted string + let encryptedData = Data(base64Encoded: encryptedSecret)! + let sealedBox = try AES.GCM.SealedBox(combined: encryptedData) + + // Decrypt SealedBox and decode result to string + let decryptedData = try AES.GCM.open(sealedBox, using: key) + let decryptedSecret = String(data: decryptedData, encoding: .utf8)! + + return decryptedSecret +} + +/** + Generate encryption key from user specified password and randomized salt using argon2id. + + The parameters used for Argon2 are based on recommended values from the security audit and the Argon2 RFC (https://datatracker.ietf.org/doc/rfc9106/) + The memory and iterations values are tuned based on benchmark timing tests to take ~1s (see EncryptionHelperTests) + - Mode: argon2id + - Parallelism: 4 + - Memory: 128MiB (2^17 KiB) + - Hash length: 32 bytes + - Iterations: 3 + + - parameter password: password to generate encryption key + - parameter salt: randomized data string used with password to generate encryption key + - returns: SymmetricKey to be used with CryptoKit encryption functions + */ + +func keyFromPassword(password: String, salt: String) throws -> SymmetricKey { + let derivedKey = try Argon2Swift.hashPasswordString(password: password, salt: Salt(bytes: Data(salt.utf8)), iterations: 3, memory: 2 << 16, parallelism: 4, length: 32, type: .id) + let key = SymmetricKey(data: derivedKey.hashData()) + return key +} + +/** + Generate random data string of specified byte length + + - parameter length:number of bytes for random data string + - returns: randomized string + */ +func generateSalt(length: Int) -> String { + var bytes = [UInt8](repeating: 0, count: length) + _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) + return Data(bytes).base64EncodedString() +} diff --git a/apps/mobile/ios/Uniswap/RNCloudBackupsManager/RNCloudStorageBackupsManager.m b/apps/mobile/ios/Uniswap/RNCloudBackupsManager/RNCloudStorageBackupsManager.m new file mode 100644 index 00000000..eb3c5a69 --- /dev/null +++ b/apps/mobile/ios/Uniswap/RNCloudBackupsManager/RNCloudStorageBackupsManager.m @@ -0,0 +1,33 @@ +// +// RNCloudStorageBackupsManager.m +// Uniswap +// +// Created by Spencer Yen on 7/13/22. +// + +#import +#import + +@interface RCT_EXTERN_MODULE(RNCloudStorageBackupsManager, RCTEventEmitter) + +RCT_EXTERN_METHOD(isCloudStorageAvailable: (RCTPromiseResolveBlock)resolve + reject: (RCTPromiseRejectBlock)reject) + +RCT_EXTERN_METHOD(backupMnemonicToCloudStorage: (NSString *)mnemonicId + password: (NSString *)password + resolve: (RCTPromiseResolveBlock)resolve + reject: (RCTPromiseRejectBlock)reject) + +RCT_EXTERN_METHOD(restoreMnemonicFromCloudStorage: (NSString *)mnemonicId + password: (NSString *)password + resolve: (RCTPromiseResolveBlock)resolve + reject: (RCTPromiseRejectBlock)reject) + +RCT_EXTERN_METHOD(deleteCloudStorageMnemonicBackup: (NSString *)mnemonicId + resolve: (RCTPromiseResolveBlock)resolve + reject: (RCTPromiseRejectBlock)reject) + +RCT_EXTERN_METHOD(getCloudBackupList: (RCTPromiseResolveBlock)resolve + reject: (RCTPromiseRejectBlock)reject) + +@end diff --git a/apps/mobile/ios/Uniswap/RNCloudBackupsManager/RNCloudStorageBackupsManager.swift b/apps/mobile/ios/Uniswap/RNCloudBackupsManager/RNCloudStorageBackupsManager.swift new file mode 100644 index 00000000..8a598ff6 --- /dev/null +++ b/apps/mobile/ios/Uniswap/RNCloudBackupsManager/RNCloudStorageBackupsManager.swift @@ -0,0 +1,232 @@ +// +// RNCloudStorageBackupsManager.swift +// Uniswap +// +// Created by Spencer Yen on 7/13/22. +// + +import Foundation +import CryptoKit + +struct CloudStorageMnemonicBackup: Codable { + let mnemonicId: String + let encryptedMnemonic: String + let encryptionSalt: String + let createdAt: Double +} + +enum ICloudBackupError: String, Error { + case backupNotFoundError = "backupNotFoundError" + case backupEncryptionError = "backupEncryptionError" + case backupDecryptionError = "backupDecryptionError" + case backupIncorrectPasswordError = "backupIncorrectPasswordError" + case deleteBackupError = "deleteBackupError" + case iCloudContainerError = "iCloudContainerError" + case iCloudError = "iCloudError" +} + +@objc(RNCloudStorageBackupsManager) +class RNCloudStorageBackupsManager: NSObject, RCTBridgeModule { + + let rnEthersRS = RNEthersRS() + + static func moduleName() -> String! { + return "RNCloudStorageBackupsManager" + } + + @objc static func requiresMainQueueSetup() -> Bool { + return false + } + + /** + Determine if iCloud Documents is available on device + + - returns: boolean if iCloud Documents is available or not + */ + @objc(isCloudStorageAvailable:reject:) + func isCloudStorageAvailable(resolve: RCTPromiseResolveBlock, + reject: RCTPromiseRejectBlock + ) { + if FileManager.default.ubiquityIdentityToken == nil { + return resolve(false) + } else { + return resolve(true) + } + } + + /** + Stores mnemonic to iCloud Documents + + - parameter mnemonicId: key string associated with mnemonic to backup + - parameter password: user provided password to encrypt the mnemonic + - returns: true if successful, otherwise throws an error + */ + @objc(backupMnemonicToCloudStorage:password:resolve:reject:) + func backupMnemonicToCloudStorage( + mnemonicId: String, password: String, resolve: RCTPromiseResolveBlock, + reject: RCTPromiseRejectBlock + ) { + guard let mnemonic = rnEthersRS.retrieveMnemonic(mnemonicId: mnemonicId) else { + return reject(RNEthersRSError.retrieveMnemonicError.rawValue, "Failed to retrieve mnemonic", RNEthersRSError.retrieveMnemonicError) + } + + // Access Uniswap iCloud Documents container + guard let containerUrl = FileManager.default.url(forUbiquityContainerIdentifier: nil) else { + return reject(ICloudBackupError.iCloudError.rawValue, "Failed to find iCloud container", ICloudBackupError.iCloudError) + } + + // Create iCloud container if empty + if !FileManager.default.fileExists(atPath: containerUrl.path, isDirectory: nil) { + do { + try FileManager.default.createDirectory(at: containerUrl, withIntermediateDirectories: true, attributes: nil) + } catch { + return reject(ICloudBackupError.iCloudError.rawValue, "Failed to create iCloud container \(error)", ICloudBackupError.iCloudError) + } + } + + let encryptedMnemonic: String + let encryptionSalt: String + do { + encryptionSalt = generateSalt(length: 16) + encryptedMnemonic = try encrypt(secret: mnemonic, password: password, salt: encryptionSalt) + } catch { + return reject(ICloudBackupError.backupEncryptionError.rawValue, "Failed to password encrypt mnemonic", ICloudBackupError.backupEncryptionError) + } + + // Write backup file to iCloud + let iCloudFileURL = containerUrl.appendingPathComponent("\(mnemonicId).json") + do { + let backup = CloudStorageMnemonicBackup(mnemonicId: mnemonicId, encryptedMnemonic: encryptedMnemonic, encryptionSalt: encryptionSalt, createdAt: Date().timeIntervalSince1970) + try JSONEncoder().encode(backup).write(to: iCloudFileURL) + return resolve(true) + } catch { + return reject(ICloudBackupError.iCloudError.rawValue, "Failed to write backup file to iCloud", ICloudBackupError.iCloudError) + } + } + + /** + + Attempts to restore mnemonic into native keychain from iCloud backup file. Assumes that the backup file `[mnemonicId].json` has already been downloaded from iCloud Documents using `RNCloudStorageBackupsManager` + + - parameter mnemonicId: key string associated with JSON backup file stored in iCloud + - parameter password: user inputted password used to decrypt backup + - returns: true if mnemonic successfully restored, otherwise a relevant error will be thrown + */ + @objc(restoreMnemonicFromCloudStorage:password:resolve:reject:) + func restoreMnemonicFromCloudStorage(mnemonicId: String, password: String, resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock + ) { + // Access Uniswap iCloud Documents container + guard let containerUrl = FileManager.default.url(forUbiquityContainerIdentifier: nil) else { + return reject(ICloudBackupError.iCloudError.rawValue, "Failed to find iCloud container", ICloudBackupError.iCloudError) + } + + // Fetch backup file from iCloud + let iCloudFileURL = containerUrl.appendingPathComponent("\(mnemonicId).json") + + guard FileManager.default.fileExists(atPath: iCloudFileURL.path) else { + return reject(ICloudBackupError.iCloudError.rawValue, "Failed to locate iCloud backup", ICloudBackupError.iCloudError) + } + + let data = try? Data(contentsOf: iCloudFileURL) + guard let backup = try? JSONDecoder().decode(CloudStorageMnemonicBackup.self, from: data!) else { + return reject(ICloudBackupError.iCloudError.rawValue, "Failed to load iCloud backup", ICloudBackupError.iCloudError) + } + + let decryptedMnemonic: String + do { + decryptedMnemonic = try decrypt(encryptedSecret: backup.encryptedMnemonic, password: password, salt: backup.encryptionSalt) + } catch CryptoKitError.authenticationFailure { + return reject(ICloudBackupError.backupIncorrectPasswordError.rawValue, "Invalid password. Please try again.", ICloudBackupError.backupIncorrectPasswordError) + } catch { + return reject(ICloudBackupError.backupDecryptionError.rawValue, "Failed to password decrypt mnemonic", ICloudBackupError.backupDecryptionError) + } + + // Restore mnemonic from backup into native keychain + let res = rnEthersRS.storeNewMnemonic(mnemonic: decryptedMnemonic, address: backup.mnemonicId) + if res == nil { + return reject(RNEthersRSError.storeMnemonicError.rawValue, "Failed to restore mnemonic into native keychain", RNEthersRSError.storeMnemonicError) + } + + return resolve(true) + } + + /** + Deletes mnemonic backup in iCloud Documents container + + - parameter mnemonicId: mnemonic backup filename to delete + - returns: boolean if deletion successful, otherwise throws error + */ + @objc(deleteCloudStorageMnemonicBackup:resolve:reject:) + func deleteCloudStorageMnemonicBackup(mnemonicId: String, resolve: @escaping RCTPromiseResolveBlock, + reject: @escaping RCTPromiseRejectBlock) { + // Access Uniswap iCloud Documents container + guard let containerUrl = FileManager.default.url(forUbiquityContainerIdentifier: nil) else { + return reject(ICloudBackupError.iCloudContainerError.rawValue, "Failed to find iCloud container", ICloudBackupError.iCloudContainerError) + } + + // Ensure backup file exists + let iCloudFileURL = containerUrl.appendingPathComponent("\(mnemonicId).json") + guard FileManager.default.fileExists(atPath: iCloudFileURL.path) else { + return reject(ICloudBackupError.backupNotFoundError.rawValue, "Failed to locate iCloud backup", ICloudBackupError.backupNotFoundError) + } + + // Delete backup file from iCloud + DispatchQueue.global(qos: .default).async { + let fileCoordinator = NSFileCoordinator(filePresenter: nil) + fileCoordinator.coordinate(writingItemAt: URL(fileURLWithPath: iCloudFileURL.path), options: NSFileCoordinator.WritingOptions.forDeleting, error: nil) { + url in + do { + try FileManager.default.removeItem(at: url) + return resolve(true) + } catch { + return reject(ICloudBackupError.deleteBackupError.rawValue, "Failed to delete iCloud backup", ICloudBackupError.deleteBackupError) + } + } + } + } + + /** + Starts NSMetadataQuery to discover backup files stored in iCloud Documents. Initializes listeners to handle downloading and sending found backups to JS. + + Referenced sample implementation here: https://developer.apple.com/documentation/uikit/documents_data_and_pasteboard/synchronizing_documents_in_the_icloud_environment + */ + @objc(getCloudBackupList:reject:) + func getCloudBackupList(resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) { + // Access Uniswap iCloud Documents container + guard let containerUrl = FileManager.default.url(forUbiquityContainerIdentifier: nil) else { + return reject(ICloudBackupError.iCloudError.rawValue, "Failed to find iCloud container", ICloudBackupError.iCloudError) + } + + // Try to list all JSON files in the iCloud Documents container + do { + let directoryContents = try FileManager.default.contentsOfDirectory(at: containerUrl, includingPropertiesForKeys: nil) + + // Filter only .json files + let jsonFiles = directoryContents.filter { $0.pathExtension == "json" } + + if jsonFiles.isEmpty { + return reject(ICloudBackupError.iCloudError.rawValue, "No backup files found", ICloudBackupError.iCloudError) + } + + // Serializable type to send it via bridge + var backups = [[String : Any]]() + + for file in jsonFiles { + if let data = try? Data(contentsOf: file.absoluteURL), + let backup = try? JSONDecoder().decode(CloudStorageMnemonicBackup.self, from: data) { + backups.append([ + "mnemonicId": backup.mnemonicId, + "createdAt": backup.createdAt + ]) + } else { + print("Error reading or decoding iCloud backup JSON at \(file.absoluteURL)") + } + } + + return resolve(backups) + + } catch { + return reject(ICloudBackupError.iCloudError.rawValue, "Failed to read iCloud directory", ICloudBackupError.iCloudError) + } + } +} diff --git a/apps/mobile/ios/Uniswap/RNEthersRs/KeychainConstants.swift b/apps/mobile/ios/Uniswap/RNEthersRs/KeychainConstants.swift new file mode 100644 index 00000000..c94ad656 --- /dev/null +++ b/apps/mobile/ios/Uniswap/RNEthersRs/KeychainConstants.swift @@ -0,0 +1,12 @@ +// +// KeychainConstants.swift +// Uniswap +// +// Created by Thomas Thachil on 2/3/25. +// + +let prefix = "com.uniswap.mobile" +let mnemonicPrefix = ".mnemonic." +let privateKeyPrefix = ".privateKey." +let entireMnemonicPrefix = prefix + mnemonicPrefix +let entirePrivateKeyPrefix = prefix + privateKeyPrefix diff --git a/apps/mobile/ios/Uniswap/RNEthersRs/KeychainSwiftDistrib.swift b/apps/mobile/ios/Uniswap/RNEthersRs/KeychainSwiftDistrib.swift new file mode 100644 index 00000000..9017d78e --- /dev/null +++ b/apps/mobile/ios/Uniswap/RNEthersRs/KeychainSwiftDistrib.swift @@ -0,0 +1,486 @@ +// +// Keychain helper for iOS/Swift. +// +// https://github.com/evgenyneu/keychain-swift +// +// This file was automatically generated by combining multiple Swift source files. +// + +// ---------------------------- +// +// KeychainSwift.swift +// +// ---------------------------- +import Security +import Foundation + +/** +A collection of helper functions for saving text and data in the keychain. +*/ +open class KeychainSwift { + + var lastQueryParameters: [String: Any]? // Used by the unit tests + + /// Contains result code from the last operation. Value is noErr (0) for a successful result. + open var lastResultCode: OSStatus = noErr + + var keyPrefix = "" // Can be useful in test. + + /** + Specify an access group that will be used to access keychain items. Access groups can be used to share keychain items between applications. When access group value is nil all application access groups are being accessed. Access group name is used by all functions: set, get, delete and clear. + */ + open var accessGroup: String? + + + /** + + Specifies whether the items can be synchronized with other devices through iCloud. Setting this property to true will + add the item to other devices with the `set` method and obtain synchronizable items with the `get` command. Deleting synchronizable items will remove them from all devices. In order for keychain synchronization to work the user must enable "Keychain" in iCloud settings. + + Does not work on macOS. + + */ + open var synchronizable: Bool = false + + private let lock = NSLock() + + + /// Instantiate a KeychainSwift object + public init() { } + + /** + + - parameter keyPrefix: a prefix that is added before the key in get/set methods. Note that `clear` method still clears everything from the Keychain. + */ + public init(keyPrefix: String) { + self.keyPrefix = keyPrefix + } + + /** + + Stores the text value in the keychain item under the given key. + + - parameter key: Key under which the text value is stored in the keychain. + - parameter value: Text string to be written to the keychain. + - parameter withAccess: Value that indicates when your app needs access to the text in the keychain item. By default the .AccessibleWhenUnlocked option is used that permits the data to be accessed only while the device is unlocked by the user. + + - returns: True if the text was successfully written to the keychain. + */ + @discardableResult + open func set(_ value: String, forKey key: String, + withAccess access: KeychainSwiftAccessOptions? = nil) -> Bool { + + if let value = value.data(using: String.Encoding.utf8) { + return set(value, forKey: key, withAccess: access) + } + + return false + } + + /** + + Stores the data in the keychain item under the given key. + + - parameter key: Key under which the data is stored in the keychain. + - parameter value: Data to be written to the keychain. + - parameter withAccess: Value that indicates when your app needs access to the text in the keychain item. By default the .AccessibleWhenUnlocked option is used that permits the data to be accessed only while the device is unlocked by the user. + + - returns: True if the text was successfully written to the keychain. + + */ + @discardableResult + open func set(_ value: Data, forKey key: String, + withAccess access: KeychainSwiftAccessOptions? = nil) -> Bool { + + // The lock prevents the code to be run simultaneously + // from multiple threads which may result in crashing + lock.lock() + defer { lock.unlock() } + + deleteNoLock(key) // Delete any existing key before saving it + let accessible = access?.value ?? KeychainSwiftAccessOptions.defaultOption.value + + let prefixedKey = keyWithPrefix(key) + + var query: [String : Any] = [ + KeychainSwiftConstants.klass : kSecClassGenericPassword, + KeychainSwiftConstants.attrAccount : prefixedKey, + KeychainSwiftConstants.valueData : value, + KeychainSwiftConstants.accessible : accessible + ] + + query = addAccessGroupWhenPresent(query) + query = addSynchronizableIfRequired(query, addingItems: true) + lastQueryParameters = query + + lastResultCode = SecItemAdd(query as CFDictionary, nil) + + return lastResultCode == noErr + } + + /** + Stores the boolean value in the keychain item under the given key. + - parameter key: Key under which the value is stored in the keychain. + - parameter value: Boolean to be written to the keychain. + - parameter withAccess: Value that indicates when your app needs access to the value in the keychain item. By default the .AccessibleWhenUnlocked option is used that permits the data to be accessed only while the device is unlocked by the user. + - returns: True if the value was successfully written to the keychain. + */ + @discardableResult + open func set(_ value: Bool, forKey key: String, + withAccess access: KeychainSwiftAccessOptions? = nil) -> Bool { + + let bytes: [UInt8] = value ? [1] : [0] + let data = Data(bytes) + + return set(data, forKey: key, withAccess: access) + } + + /** + + Retrieves the text value from the keychain that corresponds to the given key. + + - parameter key: The key that is used to read the keychain item. + - returns: The text value from the keychain. Returns nil if unable to read the item. + + */ + open func get(_ key: String) -> String? { + if let data = getData(key) { + + if let currentString = String(data: data, encoding: .utf8) { + return currentString + } + + lastResultCode = -67853 // errSecInvalidEncoding + } + + return nil + } + + /** + + Retrieves the data from the keychain that corresponds to the given key. + + - parameter key: The key that is used to read the keychain item. + - parameter asReference: If true, returns the data as reference (needed for things like NEVPNProtocol). + - returns: The text value from the keychain. Returns nil if unable to read the item. + + */ + open func getData(_ key: String, asReference: Bool = false) -> Data? { + // The lock prevents the code to be run simultaneously + // from multiple threads which may result in crashing + lock.lock() + defer { lock.unlock() } + + let prefixedKey = keyWithPrefix(key) + + var query: [String: Any] = [ + KeychainSwiftConstants.klass : kSecClassGenericPassword, + KeychainSwiftConstants.attrAccount : prefixedKey, + KeychainSwiftConstants.matchLimit : kSecMatchLimitOne + ] + + if asReference { + query[KeychainSwiftConstants.returnReference] = kCFBooleanTrue + } else { + query[KeychainSwiftConstants.returnData] = kCFBooleanTrue + } + + query = addAccessGroupWhenPresent(query) + query = addSynchronizableIfRequired(query, addingItems: false) + lastQueryParameters = query + + var result: AnyObject? + + lastResultCode = withUnsafeMutablePointer(to: &result) { + SecItemCopyMatching(query as CFDictionary, UnsafeMutablePointer($0)) + } + + if lastResultCode == noErr { + return result as? Data + } + + return nil + } + + /** + Retrieves the boolean value from the keychain that corresponds to the given key. + - parameter key: The key that is used to read the keychain item. + - returns: The boolean value from the keychain. Returns nil if unable to read the item. + */ + open func getBool(_ key: String) -> Bool? { + guard let data = getData(key) else { return nil } + guard let firstBit = data.first else { return nil } + return firstBit == 1 + } + + /** + Deletes the single keychain item specified by the key. + + - parameter key: The key that is used to delete the keychain item. + - returns: True if the item was successfully deleted. + + */ + @discardableResult + open func delete(_ key: String) -> Bool { + // The lock prevents the code to be run simultaneously + // from multiple threads which may result in crashing + lock.lock() + defer { lock.unlock() } + + return deleteNoLock(key) + } + + /** + Return all keys from keychain + + - returns: An string array with all keys from the keychain. + + */ + public var allKeys: [String] { + var query: [String: Any] = [ + KeychainSwiftConstants.klass : kSecClassGenericPassword, + KeychainSwiftConstants.returnData : true, + KeychainSwiftConstants.returnAttributes: true, + KeychainSwiftConstants.returnReference: true, + KeychainSwiftConstants.matchLimit: KeychainSwiftConstants.secMatchLimitAll + ] + + query = addAccessGroupWhenPresent(query) + query = addSynchronizableIfRequired(query, addingItems: false) + + var result: AnyObject? + + let lastResultCode = withUnsafeMutablePointer(to: &result) { + SecItemCopyMatching(query as CFDictionary, UnsafeMutablePointer($0)) + } + + if lastResultCode == noErr { + return (result as? [[String: Any]])?.compactMap { + $0[KeychainSwiftConstants.attrAccount] as? String } ?? [] + } + + return [] + } + + /** + + Same as `delete` but is only accessed internally, since it is not thread safe. + + - parameter key: The key that is used to delete the keychain item. + - returns: True if the item was successfully deleted. + + */ + @discardableResult + func deleteNoLock(_ key: String) -> Bool { + let prefixedKey = keyWithPrefix(key) + + var query: [String: Any] = [ + KeychainSwiftConstants.klass : kSecClassGenericPassword, + KeychainSwiftConstants.attrAccount : prefixedKey + ] + + query = addAccessGroupWhenPresent(query) + query = addSynchronizableIfRequired(query, addingItems: false) + lastQueryParameters = query + + lastResultCode = SecItemDelete(query as CFDictionary) + + return lastResultCode == noErr + } + + /** + + Deletes all Keychain items used by the app. Note that this method deletes all items regardless of the prefix settings used for initializing the class. + + - returns: True if the keychain items were successfully deleted. + + */ + @discardableResult + open func clear() -> Bool { + // The lock prevents the code to be run simultaneously + // from multiple threads which may result in crashing + lock.lock() + defer { lock.unlock() } + + var query: [String: Any] = [ kSecClass as String : kSecClassGenericPassword ] + query = addAccessGroupWhenPresent(query) + query = addSynchronizableIfRequired(query, addingItems: false) + lastQueryParameters = query + + lastResultCode = SecItemDelete(query as CFDictionary) + + return lastResultCode == noErr + } + + /// Returns the key with currently set prefix. + func keyWithPrefix(_ key: String) -> String { + return "\(keyPrefix)\(key)" + } + + func addAccessGroupWhenPresent(_ items: [String: Any]) -> [String: Any] { + guard let accessGroup = accessGroup else { return items } + + var result: [String: Any] = items + result[KeychainSwiftConstants.accessGroup] = accessGroup + return result + } + + /** + + Adds kSecAttrSynchronizable: kSecAttrSynchronizableAny` item to the dictionary when the `synchronizable` property is true. + + - parameter items: The dictionary where the kSecAttrSynchronizable items will be added when requested. + - parameter addingItems: Use `true` when the dictionary will be used with `SecItemAdd` method (adding a keychain item). For getting and deleting items, use `false`. + + - returns: the dictionary with kSecAttrSynchronizable item added if it was requested. Otherwise, it returns the original dictionary. + + */ + func addSynchronizableIfRequired(_ items: [String: Any], addingItems: Bool) -> [String: Any] { + if !synchronizable { return items } + var result: [String: Any] = items + result[KeychainSwiftConstants.attrSynchronizable] = addingItems == true ? true : kSecAttrSynchronizableAny + return result + } +} + + +// ---------------------------- +// +// TegKeychainConstants.swift +// +// ---------------------------- +import Foundation +import Security + +/// Constants used by the library +public struct KeychainSwiftConstants { + /// Specifies a Keychain access group. Used for sharing Keychain items between apps. + public static var accessGroup: String { return toString(kSecAttrAccessGroup) } + + /** + + A value that indicates when your app needs access to the data in a keychain item. The default value is AccessibleWhenUnlocked. For a list of possible values, see KeychainSwiftAccessOptions. + + */ + public static var accessible: String { return toString(kSecAttrAccessible) } + + /// Used for specifying a String key when setting/getting a Keychain value. + public static var attrAccount: String { return toString(kSecAttrAccount) } + + /// Used for specifying synchronization of keychain items between devices. + public static var attrSynchronizable: String { return toString(kSecAttrSynchronizable) } + + /// An item class key used to construct a Keychain search dictionary. + public static var klass: String { return toString(kSecClass) } + + /// Specifies the number of values returned from the keychain. The library only supports single values. + public static var matchLimit: String { return toString(kSecMatchLimit) } + + /// A return data type used to get the data from the Keychain. + public static var returnData: String { return toString(kSecReturnData) } + + /// Used for specifying a value when setting a Keychain value. + public static var valueData: String { return toString(kSecValueData) } + + /// Used for returning a reference to the data from the keychain + public static var returnReference: String { return toString(kSecReturnPersistentRef) } + + /// A key whose value is a Boolean indicating whether or not to return item attributes + public static var returnAttributes : String { return toString(kSecReturnAttributes) } + + /// A value that corresponds to matching an unlimited number of items + public static var secMatchLimitAll : String { return toString(kSecMatchLimitAll) } + + static func toString(_ value: CFString) -> String { + return value as String + } +} + + +// ---------------------------- +// +// KeychainSwiftAccessOptions.swift +// +// ---------------------------- +import Security + +/** +These options are used to determine when a keychain item should be readable. The default value is AccessibleWhenUnlocked. +*/ +public enum KeychainSwiftAccessOptions { + + /** + + The data in the keychain item can be accessed only while the device is unlocked by the user. + + This is recommended for items that need to be accessible only while the application is in the foreground. Items with this attribute migrate to a new device when using encrypted backups. + + This is the default value for keychain items added without explicitly setting an accessibility constant. + + */ + case accessibleWhenUnlocked + + /** + + The data in the keychain item can be accessed only while the device is unlocked by the user. + + This is recommended for items that need to be accessible only while the application is in the foreground. Items with this attribute do not migrate to a new device. Thus, after restoring from a backup of a different device, these items will not be present. + + */ + case accessibleWhenUnlockedThisDeviceOnly + + /** + + The data in the keychain item cannot be accessed after a restart until the device has been unlocked once by the user. + + After the first unlock, the data remains accessible until the next restart. This is recommended for items that need to be accessed by background applications. Items with this attribute migrate to a new device when using encrypted backups. + + */ + case accessibleAfterFirstUnlock + + /** + + The data in the keychain item cannot be accessed after a restart until the device has been unlocked once by the user. + + After the first unlock, the data remains accessible until the next restart. This is recommended for items that need to be accessed by background applications. Items with this attribute do not migrate to a new device. Thus, after restoring from a backup of a different device, these items will not be present. + + */ + case accessibleAfterFirstUnlockThisDeviceOnly + + /** + + The data in the keychain can only be accessed when the device is unlocked. Only available if a passcode is set on the device. + + This is recommended for items that only need to be accessible while the application is in the foreground. Items with this attribute never migrate to a new device. After a backup is restored to a new device, these items are missing. No items can be stored in this class on devices without a passcode. Disabling the device passcode causes all items in this class to be deleted. + + */ + case accessibleWhenPasscodeSetThisDeviceOnly + + static var defaultOption: KeychainSwiftAccessOptions { + return .accessibleWhenUnlocked + } + + var value: String { + switch self { + case .accessibleWhenUnlocked: + return toString(kSecAttrAccessibleWhenUnlocked) + + case .accessibleWhenUnlockedThisDeviceOnly: + return toString(kSecAttrAccessibleWhenUnlockedThisDeviceOnly) + + case .accessibleAfterFirstUnlock: + return toString(kSecAttrAccessibleAfterFirstUnlock) + + case .accessibleAfterFirstUnlockThisDeviceOnly: + return toString(kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly) + + case .accessibleWhenPasscodeSetThisDeviceOnly: + return toString(kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly) + } + } + + func toString(_ value: CFString) -> String { + return KeychainSwiftConstants.toString(value) + } +} + diff --git a/apps/mobile/ios/Uniswap/RNEthersRs/KeychainUtils.swift b/apps/mobile/ios/Uniswap/RNEthersRs/KeychainUtils.swift new file mode 100644 index 00000000..dd57e2dc --- /dev/null +++ b/apps/mobile/ios/Uniswap/RNEthersRs/KeychainUtils.swift @@ -0,0 +1,21 @@ +import CryptoKit +import Foundation + +@objcMembers +class KeychainUtils: NSObject { + private static let CAN_CLEAR_KEYCHAIN_ON_REINSTALL_FLAG = "can_clear_keychain_on_reinstall" + private static let keychain = KeychainSwift(keyPrefix: prefix) + + @objc static func clearKeychain() { + keychain.clear() + } + + @objc static func getCanClearKeychainOnReinstall() -> Bool { + return (keychain.getBool(CAN_CLEAR_KEYCHAIN_ON_REINSTALL_FLAG) == true) + } + + @objc static func setCanClearKeychainOnReinstall() { + keychain.set( + true, forKey: CAN_CLEAR_KEYCHAIN_ON_REINSTALL_FLAG, withAccess: .accessibleWhenUnlockedThisDeviceOnly) + } +} diff --git a/apps/mobile/ios/Uniswap/RNEthersRs/RNEthersRS-Bridging-Header.h b/apps/mobile/ios/Uniswap/RNEthersRs/RNEthersRS-Bridging-Header.h new file mode 100644 index 00000000..ff3d1408 --- /dev/null +++ b/apps/mobile/ios/Uniswap/RNEthersRs/RNEthersRS-Bridging-Header.h @@ -0,0 +1,12 @@ +// +// RNEthersRS-Bridging-Header.h +// Uniswap +// +// Created by Connor McEwen on 10/28/21. +// + +#import +#import +#import "libethers_ffi.h" +#import +#import diff --git a/apps/mobile/ios/Uniswap/RNEthersRs/RNEthersRS.swift b/apps/mobile/ios/Uniswap/RNEthersRs/RNEthersRS.swift new file mode 100644 index 00000000..50c2886e --- /dev/null +++ b/apps/mobile/ios/Uniswap/RNEthersRs/RNEthersRS.swift @@ -0,0 +1,262 @@ +// +// RNEthers.swift +// Uniswap +// +// Created by Connor McEwen on 10/28/21. + +/** + Provides the generation, storage, and signing logic for mnemonics and private keys so that they never passed to JS. + + Mnemonics and private keys are stored and accessed in the native iOS secure keychain key-value store via associated keys formed from concatenating a constant prefix with the associated public address. + + Uses KeychainSwift as a wrapper utility to interface with the native iOS secure keychain. */ + +import Foundation +import CryptoKit + + +enum RNEthersRSError: String, Error { + case storeMnemonicError = "storeMnemonicError" + case retrieveMnemonicError = "retrieveMnemonicError" +} + +@objc(RNEthersRS) + +class RNEthersRS: NSObject { + private let keychain = KeychainSwift(keyPrefix: prefix) + // TODO: [MOB-208] LRU cache to ensure we don't create too many (unlikely to happen) + private var walletCache: [String: OpaquePointer] = [:] + + @objc static func requiresMainQueueSetup() -> Bool { + return false + } + + func findInvalidWord(mnemonic: String) -> String { + let firstInvalidMnemonic = find_invalid_word(mnemonic) + return String(cString: firstInvalidMnemonic!) + } + + func validateMnemonic(mnemonic: String) -> Bool { + return validate_mnemonic(mnemonic) + } + + /** + Fetches all mnemonic IDs, which are used as keys to access the actual mnemonics in the native keychain secure key-value store. + + - returns: array of mnemonic IDs + */ + @objc(getMnemonicIds:reject:) + func getMnemonicIds(resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock) { + let mnemonicIds = keychain.allKeys.filter { key in + key.contains(mnemonicPrefix) + }.map { key in + key.replacingOccurrences(of: entireMnemonicPrefix, with: "") + } + resolve(mnemonicIds) + } + + /** + Derives private key from mnemonic with derivation index 0 and retrieves associated public address. Stores imported mnemonic in native keychain with the mnemonic ID key as the public address. + + - parameter mnemonic: The mnemonic phrase to import + - returns: public address from the mnemonic's first derived private key + */ + @objc(importMnemonic:resolve:reject:) + func importMnemonic( + mnemonic: String, resolve: RCTPromiseResolveBlock, + reject: RCTPromiseRejectBlock + ) { + let private_key = private_key_from_mnemonic( + mnemonic, UInt32(exactly: 0)!) + let address = String(cString: private_key.address!) + + let res = storeNewMnemonic(mnemonic: mnemonic, address: address) + if res != nil { + resolve(res) + return + } + + reject("Unable to import new mnemonic", "Failed store new mnemonic in ethers library", nil) + return + } + + @objc(removeMnemonic:resolve:reject:) + func removeMnemonic( + mnemonicId: String, + resolve: RCTPromiseResolveBlock, + reject: RCTPromiseRejectBlock + ) { + let res = keychain.delete(keychainKeyForMnemonicId(mnemonicId: mnemonicId)) + resolve(res) + } + + /** + Generates a new mnemonic and retrieves associated public address. Stores new mnemonic in native keychain with the mnemonic ID key as the public address. + + - returns: public address from the mnemonic's first derived private key + */ + @objc(generateAndStoreMnemonic:reject:) + func generateAndStoreMnemonic(resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock) { + let mnemonic_ptr = generate_mnemonic() + let mnemonic_str = String(cString: mnemonic_ptr.mnemonic!) + let address_str = String(cString: mnemonic_ptr.address!) + let res = storeNewMnemonic(mnemonic: mnemonic_str, address: address_str) + mnemonic_free(mnemonic_ptr) + resolve(res) + } + + /** + Stores mnemonic phrase in Native Keychain under the address + + - returns: public address if successfully stored in native keychain + */ + func storeNewMnemonic(mnemonic: String, address: String) -> String? { + let checkStored = retrieveMnemonic(mnemonicId: address) + + if checkStored == nil { + let newMnemonicKey = keychainKeyForMnemonicId(mnemonicId: address); + keychain.set(mnemonic, forKey: newMnemonicKey, withAccess: .accessibleWhenUnlockedThisDeviceOnly) + return address + } + + return address + } + + func keychainKeyForMnemonicId(mnemonicId: String) -> String { + return mnemonicPrefix + mnemonicId + } + + func retrieveMnemonic(mnemonicId: String) -> String? { + return keychain.get(keychainKeyForMnemonicId(mnemonicId: mnemonicId)) + } + + /** + Fetches all public addresses from private keys stored under `privateKeyPrefix` in native keychain. Used from React Native to verify the native keychain has the private key for an account that is attempting create a NativeSigner that calls native signing methods + + - returns: public addresses for all stored private keys + */ + @objc(getAddressesForStoredPrivateKeys:reject:) + func getAddressesForStoredPrivateKeys( + resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock + ) { + let addresses = keychain.allKeys.filter { key in + key.contains(privateKeyPrefix) + }.map { key in + key.replacingOccurrences(of: entirePrivateKeyPrefix, with: "") + } + resolve(addresses) + } + + func storeNewPrivateKey(address: String, privateKey: String) { + let newKey = keychainKeyForPrivateKey(address: address); + keychain.set(privateKey, forKey: newKey, withAccess: .accessibleWhenUnlockedThisDeviceOnly) + } + + /** + Derives public address from mnemonic for given `derivationIndex`. + + - parameter mnemonic: mnemonic to generate public key for + - parameter derivationIndex: number used to specify a which derivation index to use for deriving a private key from the mnemonic + - returns: public address associated with private key generated from the mnemonic at given derivation index + */ + @objc(generateAddressForMnemonic:derivationIndex:resolve:reject:) + func generateAddressForMnemonic( + mnemonic: String, derivationIndex: Int, resolve: RCTPromiseResolveBlock, + reject: RCTPromiseRejectBlock + ) { + let private_key = private_key_from_mnemonic( + mnemonic, UInt32(exactly: derivationIndex)!) + let address = String(cString: private_key.address!) + private_key_free(private_key) + resolve(address) + } + + /** + Derives private key and public address from mnemonic associated with `mnemonicId` for given `derivationIndex`. Stores the private key in native keychain with key. + + - parameter mnemonicId: key string associated with mnemonic to generate private key for (currently convention is to use public address associated with mnemonic) + - parameter derivationIndex: number used to specify a which derivation index to use for deriving a private key from the mnemonic + - returns: public address associated with private key generated from the mnemonic at given derivation index + */ + @objc(generateAndStorePrivateKey:derivationIndex:resolve:reject:) + func generateAndStorePrivateKey( + mnemonicId: String, derivationIndex: Int, resolve: RCTPromiseResolveBlock, + reject: RCTPromiseRejectBlock + ) { + let mnemonic = retrieveMnemonic(mnemonicId: mnemonicId) + + if (mnemonic == nil) { + reject("Mnemonic not found", "Could not find mnemonic for given mnemonicId", nil) + return + } + + let private_key = private_key_from_mnemonic( + mnemonic, UInt32(exactly: derivationIndex)!) + let xprv = String(cString: private_key.private_key!) + let address = String(cString: private_key.address!) + storeNewPrivateKey(address: address, privateKey: xprv) + private_key_free(private_key) + resolve(address) + } + + @objc(removePrivateKey:resolve:reject:) + func removePrivateKey( + address: String, resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock + ) { + let res = keychain.delete(keychainKeyForPrivateKey(address: address)) + resolve(res) + } + + @objc(signTransactionHashForAddress:hash:chainId:resolve:reject:) + func signTransactionForAddress( + address: String, hash: String, chainId: NSNumber, resolve: RCTPromiseResolveBlock, + reject: RCTPromiseRejectBlock + ) { + let wallet = retrieveOrCreateWalletForAddress(address: address) + let signedHash = sign_tx_with_wallet(wallet, hash, UInt64(chainId)) + let result = String(cString: signedHash.signature!) + resolve(result); + } + + @objc(signMessageForAddress:message:resolve:reject:) + func signMessageForAddress( + address: String, message: String, resolve: RCTPromiseResolveBlock, + reject: RCTPromiseRejectBlock + ) { + let wallet = retrieveOrCreateWalletForAddress(address: address) + let signedMessage = sign_message_with_wallet(wallet, message) + let result = String(cString: signedMessage!) + string_free(signedMessage) + resolve(result) + } + + @objc(signHashForAddress:hash:chainId:resolve:reject:) + func signHashForAddress( + address: String, hash: String, chainId: NSNumber, resolve: RCTPromiseResolveBlock, + reject: RCTPromiseRejectBlock + ) { + let wallet = retrieveOrCreateWalletForAddress(address: address) + let signedHash = sign_hash_with_wallet(wallet, hash, UInt64(chainId)) + let result = String(cString: signedHash!) + string_free(signedHash) + resolve(result) + } + + func retrieveOrCreateWalletForAddress(address: String) -> OpaquePointer { + if walletCache[address] != nil { + return walletCache[address]! + } + let privateKey = retrievePrivateKey(address: address) + let wallet = wallet_from_private_key(privateKey) + walletCache[address] = wallet + return wallet! + } + + func retrievePrivateKey(address: String) -> String? { + return keychain.get(keychainKeyForPrivateKey(address: address)) + } + + func keychainKeyForPrivateKey(address: String) -> String { + return privateKeyPrefix + address + } +} diff --git a/apps/mobile/ios/Uniswap/RNEthersRs/RnEthersRS.m b/apps/mobile/ios/Uniswap/RNEthersRs/RnEthersRS.m new file mode 100644 index 00000000..12b0a610 --- /dev/null +++ b/apps/mobile/ios/Uniswap/RNEthersRs/RnEthersRS.m @@ -0,0 +1,60 @@ +// +// RnEthersBridge.m +// Uniswap +// +// Created by Connor McEwen on 10/28/21. +// + +#import + +@interface RCT_EXTERN_MODULE(RNEthersRS, NSObject) + +RCT_EXTERN_METHOD(getMnemonicIds: (RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject) + +RCT_EXTERN_METHOD(importMnemonic: (NSString *)mnemonic + resolve: (RCTPromiseResolveBlock)resolve + reject: (RCTPromiseRejectBlock)reject) + +RCT_EXTERN_METHOD(removeMnemonic: (NSString *)mnemonicId + resolve: (RCTPromiseResolveBlock)resolve + reject: (RCTPromiseRejectBlock)reject) + +RCT_EXTERN_METHOD(generateAndStoreMnemonic: (RCTPromiseResolveBlock)resolve + reject: (RCTPromiseRejectBlock)reject) + +RCT_EXTERN_METHOD(getAddressesForStoredPrivateKeys: (RCTPromiseResolveBlock)resolve + reject: (RCTPromiseRejectBlock)reject) + +RCT_EXTERN_METHOD(generateAddressForMnemonic: (NSString *)mnemonic + derivationIndex: (NSInteger)index + resolve: (RCTPromiseResolveBlock)resolve + reject: (RCTPromiseRejectBlock)reject) + +RCT_EXTERN_METHOD(generateAndStorePrivateKey: (NSString *)mnemonicId + derivationIndex: (NSInteger)index + resolve: (RCTPromiseResolveBlock)resolve + reject: (RCTPromiseRejectBlock)reject) + +RCT_EXTERN_METHOD(removePrivateKey: (NSString *)address + resolve: (RCTPromiseResolveBlock)resolve + reject: (RCTPromiseRejectBlock)reject) + +RCT_EXTERN_METHOD(signTransactionHashForAddress: (NSString *)address + hash: (NSString *)hash + chainId: NSNumber + resolve: (RCTPromiseResolveBlock)resolve + reject: (RCTPromiseRejectBlock)reject) + +RCT_EXTERN_METHOD(signMessageForAddress: (NSString *)address + message: (NSString *)message + resolve: (RCTPromiseResolveBlock)resolve + reject: (RCTPromiseRejectBlock)reject) + +RCT_EXTERN_METHOD(signHashForAddress: (NSString *)address + hash: (NSString *)hash + chainId: NSNumber + resolve: (RCTPromiseResolveBlock)resolve + reject: (RCTPromiseRejectBlock)reject) + +@end diff --git a/apps/mobile/ios/Uniswap/SplashScreen.storyboard b/apps/mobile/ios/Uniswap/SplashScreen.storyboard new file mode 100644 index 00000000..61161c37 --- /dev/null +++ b/apps/mobile/ios/Uniswap/SplashScreen.storyboard @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/mobile/ios/Uniswap/Uniswap-Bridging-Header.h b/apps/mobile/ios/Uniswap/Uniswap-Bridging-Header.h new file mode 100644 index 00000000..8a24474f --- /dev/null +++ b/apps/mobile/ios/Uniswap/Uniswap-Bridging-Header.h @@ -0,0 +1,23 @@ +// +// Uniswap-Bridging-Header.h +// Uniswap +// +// Bridging header for Swift/Objective-C interoperability +// + +#import +#import +#import +#import +#import +#import +#import +#import +#import +#import +#import +#import +#import +#import "libethers_ffi.h" + +// Import any other Objective-C headers that need to be accessible from Swift \ No newline at end of file diff --git a/apps/mobile/ios/Uniswap/Uniswap.entitlements b/apps/mobile/ios/Uniswap/Uniswap.entitlements new file mode 100644 index 00000000..1476f888 --- /dev/null +++ b/apps/mobile/ios/Uniswap/Uniswap.entitlements @@ -0,0 +1,36 @@ + + + + + aps-environment + development + com.apple.developer.associated-domains + + applinks:uniswap.org + applinks:app.uniswap.org + applinks:app.corn-staging.com + webcredentials:app.uniswap.org + webcredentials:uniswap.org + webcredentials:unihq.org + + com.apple.developer.devicecheck.appattest-environment + production + com.apple.developer.icloud-container-identifiers + + iCloud.Uniswap + + com.apple.developer.icloud-services + + CloudDocuments + + com.apple.developer.ubiquity-container-identifiers + + iCloud.Uniswap + + com.apple.security.application-groups + + group.com.uniswap.widgets + group.com.uniswap.mobile.onesignal + + + diff --git a/apps/mobile/ios/Uniswap/WalletConnect/RNWalletConnect.h b/apps/mobile/ios/Uniswap/WalletConnect/RNWalletConnect.h new file mode 100644 index 00000000..0c777d65 --- /dev/null +++ b/apps/mobile/ios/Uniswap/WalletConnect/RNWalletConnect.h @@ -0,0 +1,15 @@ +// +// RNWalletConnect.h +// Uniswap +// +// Created by Tina Zheng on 3/4/22. +// + +#ifndef RNWalletConnect_h +#define RNWalletConnect_h + +#import +@interface RNWalletConnect : NSObject +@end + +#endif /* RNWalletConnect_h */ diff --git a/apps/mobile/ios/Uniswap/WalletConnect/RNWalletConnect.m b/apps/mobile/ios/Uniswap/WalletConnect/RNWalletConnect.m new file mode 100644 index 00000000..8ed7c0bf --- /dev/null +++ b/apps/mobile/ios/Uniswap/WalletConnect/RNWalletConnect.m @@ -0,0 +1,14 @@ +// +// RNWalletConnect.m +// Uniswap +// +// Created by Tina Zheng on 3/7/22. +// + +#import + +@interface RCT_EXTERN_MODULE(RNWalletConnect, NSObject) + +RCT_EXTERN_METHOD(returnToPreviousApp) + +@end diff --git a/apps/mobile/ios/Uniswap/WalletConnect/RNWalletConnect.swift b/apps/mobile/ios/Uniswap/WalletConnect/RNWalletConnect.swift new file mode 100644 index 00000000..bd65ceed --- /dev/null +++ b/apps/mobile/ios/Uniswap/WalletConnect/RNWalletConnect.swift @@ -0,0 +1,47 @@ +// +// RNWalletConnect.swift +// Uniswap +// +// Created by Tina Zheng on 3/4/22. +// + +import Foundation + +// Used to return to previously opened app (wallet to dapp in mobile browser) +@objc private protocol PrivateSelectors: NSObjectProtocol { + var destinations: [NSNumber] { get } + func sendResponseForDestination(_ destination: NSNumber) +} + +@objc(RNWalletConnect) +class RNWalletConnect: NSObject { + + /* + * Open the previously opened app that deep linked to Uniswap app + * (eg. Dapp website in Safari -> Wallet -> Dapp website in Safari). + * Returns false and does nothing if there is no previous opened app to link back to. + * Returns true if successfully opened previous app + */ + @objc + func returnToPreviousApp() -> Bool { + let sys = "_system" + let nav = "Navigation" + let action = "Action" + guard + let sysNavIvar = class_getInstanceVariable(UIApplication.self, sys + nav + action), + let action = object_getIvar(UIApplication.shared, sysNavIvar) as? NSObject, + let destinations = action.perform(#selector(getter: PrivateSelectors.destinations)).takeUnretainedValue() as? [NSNumber], + let firstDestination = destinations.first + else { + return false + } + + action.perform(#selector(PrivateSelectors.sendResponseForDestination), with: firstDestination) + return true + } + + @objc static func requiresMainQueueSetup() -> Bool { + return false + } + +} diff --git a/apps/mobile/ios/Uniswap/Widget/RNWidgets.m b/apps/mobile/ios/Uniswap/Widget/RNWidgets.m new file mode 100644 index 00000000..faa9d953 --- /dev/null +++ b/apps/mobile/ios/Uniswap/Widget/RNWidgets.m @@ -0,0 +1,15 @@ +// +// RNWidgets.m +// Uniswap +// +// Created by Eric Huang on 8/2/23. +// + +#import + +@interface RCT_EXTERN_MODULE(RNWidgets, NSObject) + +RCT_EXTERN_METHOD(hasWidgetsInstalled: (RCTPromiseResolveBlock *)resolve + reject:(RCTPromiseRejectBlock *)reject) + +@end diff --git a/apps/mobile/ios/Uniswap/Widget/RNWidgets.swift b/apps/mobile/ios/Uniswap/Widget/RNWidgets.swift new file mode 100644 index 00000000..74eadaa4 --- /dev/null +++ b/apps/mobile/ios/Uniswap/Widget/RNWidgets.swift @@ -0,0 +1,29 @@ +// +// RNWidgets.swift +// Uniswap +// +// Created by Eric Huang on 8/2/23. +// + +import Foundation +import WidgetKit + +@objc(RNWidgets) +class RNWidgets: NSObject { + + @objc + func hasWidgetsInstalled(_ resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) { + WidgetCenter.shared.getCurrentConfigurations() { result in + if case .success(let config) = result { + resolve(config.count > 0) + } else { + resolve(false) + } + } + } + + @objc + static func requiresMainQueueSetup() -> Bool { + return false + } +} diff --git a/apps/mobile/ios/UniswapTests/EncryptionHelperTests.swift b/apps/mobile/ios/UniswapTests/EncryptionHelperTests.swift new file mode 100644 index 00000000..dc237c0f --- /dev/null +++ b/apps/mobile/ios/UniswapTests/EncryptionHelperTests.swift @@ -0,0 +1,107 @@ +// +// EncryptionHelperTests.swift +// UniswapTests +// +// Created by Spencer Yen on 7/27/22. +// + +import XCTest +import Argon2Swift +@testable import Uniswap + +class EncryptionHelperTests: XCTestCase { + + private let secret = "student zone flight quote trial case shadow alien yard choose quiz produce" + private let password = "012345" + private let saltLength = 16 + + func testEncryptAndDecrypt() throws { + let salt = generateSalt(length: saltLength) + print("Secret: \(secret)") + print("Password: \(password)") + print("Salt: \(salt)") + + let encryptedSecret = try encrypt(secret: secret, password: password, salt: salt) + XCTAssertNotNil(encryptedSecret, "Failed to encrypt secret") + print("Encrypted Secret: \(encryptedSecret)") + + let decryptedSecret = try decrypt(encryptedSecret: encryptedSecret, password: password, salt: salt) + XCTAssertEqual(secret, decryptedSecret, "Decrypted secret does not match plaintext secret") + print("Decrypted Secret: \(decryptedSecret)") + } + + func testEncryptAndDecryptFail() throws { + let salt = generateSalt(length: saltLength) + + let encryptedSecret = try encrypt(secret: secret, password: password, salt: salt) + XCTAssertNotNil(encryptedSecret, "Failed to encrypt secret") + + XCTAssertThrowsError(try decrypt(encryptedSecret: encryptedSecret, password: "wrong", salt: salt), "No error thrown when decrypting with invalid password") + } + + func testArgon2KDF1Iteration1GBMemory() throws { + measure { + do { + let iterations = 1 + let memory = 2 << 19 // 2^20 KiB = 1024MiB + let salt = generateSalt(length: saltLength) + let _ = try Argon2Swift.hashPasswordString(password: password, salt: Salt(bytes: Data(salt.utf8)), iterations: iterations, memory: memory, parallelism: 4, length: 32, type: .id) + } catch { + XCTAssertNil(error, "Error hashing password with Argon2") + } + } + } + + func testArgon2KDF3Iteration512MBMemory() throws { + measure { + do { + let iterations = 3 + let memory = 2 << 18 // 2^19 KiB = 512MiB + let salt = generateSalt(length: saltLength) + let _ = try Argon2Swift.hashPasswordString(password: password, salt: Salt(bytes: Data(salt.utf8)), iterations: iterations, memory: memory, parallelism: 4, length: 32, type: .id) + } catch { + XCTAssertNil(error, "Error hashing password with Argon2") + } + } + } + + func testArgon2KDF3Iteration256MBMemory() throws { + measure { + do { + let iterations = 3 + let memory = 2 << 17 // 2^18 KiB = 256MiB + let salt = generateSalt(length: saltLength) + let _ = try Argon2Swift.hashPasswordString(password: password, salt: Salt(bytes: Data(salt.utf8)), iterations: iterations, memory: memory, parallelism: 4, length: 32, type: .id) + } catch { + XCTAssertNil(error, "Error hashing password with Argon2") + } + } + } + + func testArgon2KDF3Iteration128MBMemory() throws { + measure { + do { + let iterations = 3 + let memory = 2 << 16 // 2^17 KiB = 128MiB + let salt = generateSalt(length: saltLength) + let _ = try Argon2Swift.hashPasswordString(password: password, salt: Salt(bytes: Data(salt.utf8)), iterations: iterations, memory: memory, parallelism: 4, length: 32, type: .id) + } catch { + XCTAssertNil(error, "Error hashing password with Argon2") + } + } + } + + func testArgon2KDF3Iteration64MBMemory() throws { + measure { + do { + let iterations = 3 + let memory = 2 << 15 // 2^16 KiB = 64MiB + let salt = generateSalt(length: saltLength) + let _ = try Argon2Swift.hashPasswordString(password: password, salt: Salt(bytes: Data(salt.utf8)), iterations: iterations, memory: memory, parallelism: 4, length: 32, type: .id) + } catch { + XCTAssertNil(error, "Error hashing password with Argon2") + } + } + } + +} diff --git a/apps/mobile/ios/UniswapTests/Info.plist b/apps/mobile/ios/UniswapTests/Info.plist new file mode 100644 index 00000000..a7d41dcd --- /dev/null +++ b/apps/mobile/ios/UniswapTests/Info.plist @@ -0,0 +1,24 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + BNDL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleSignature + ???? + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + + diff --git a/apps/mobile/ios/UniswapTests/UniswapTests.m b/apps/mobile/ios/UniswapTests/UniswapTests.m new file mode 100644 index 00000000..9de5f53e --- /dev/null +++ b/apps/mobile/ios/UniswapTests/UniswapTests.m @@ -0,0 +1,65 @@ +#import +#import + +#import +#import + +#define TIMEOUT_SECONDS 600 +#define TEXT_TO_LOOK_FOR @"Welcome to React" + +@interface UniswapTests : XCTestCase + +@end + +@implementation UniswapTests + +- (BOOL)findSubviewInView:(UIView *)view matching:(BOOL(^)(UIView *view))test +{ + if (test(view)) { + return YES; + } + for (UIView *subview in [view subviews]) { + if ([self findSubviewInView:subview matching:test]) { + return YES; + } + } + return NO; +} + +- (void)testRendersWelcomeScreen +{ + UIViewController *vc = [[[RCTSharedApplication() delegate] window] rootViewController]; + NSDate *date = [NSDate dateWithTimeIntervalSinceNow:TIMEOUT_SECONDS]; + BOOL foundElement = NO; + + __block NSString *redboxError = nil; +#ifdef DEBUG + RCTSetLogFunction(^(RCTLogLevel level, RCTLogSource source, NSString *fileName, NSNumber *lineNumber, NSString *message) { + if (level >= RCTLogLevelError) { + redboxError = message; + } + }); +#endif + + while ([date timeIntervalSinceNow] > 0 && !foundElement && !redboxError) { + [[NSRunLoop mainRunLoop] runMode:NSDefaultRunLoopMode beforeDate:[NSDate dateWithTimeIntervalSinceNow:0.1]]; + [[NSRunLoop mainRunLoop] runMode:NSRunLoopCommonModes beforeDate:[NSDate dateWithTimeIntervalSinceNow:0.1]]; + + foundElement = [self findSubviewInView:vc.view matching:^BOOL(UIView *view) { + if ([view.accessibilityLabel isEqualToString:TEXT_TO_LOOK_FOR]) { + return YES; + } + return NO; + }]; + } + +#ifdef DEBUG + RCTSetLogFunction(RCTDefaultLogFunction); +#endif + + XCTAssertNil(redboxError, @"RedBox error: %@", redboxError); + XCTAssertTrue(foundElement, @"Couldn't find element with text '%@' in %d seconds", TEXT_TO_LOOK_FOR, TIMEOUT_SECONDS); +} + + +@end diff --git a/apps/mobile/ios/WidgetIntentExtension/Info.plist b/apps/mobile/ios/WidgetIntentExtension/Info.plist new file mode 100644 index 00000000..0bb4a443 --- /dev/null +++ b/apps/mobile/ios/WidgetIntentExtension/Info.plist @@ -0,0 +1,24 @@ + + + + + NSExtension + + NSExtensionAttributes + + IntentsRestrictedWhileLocked + + IntentsRestrictedWhileProtectedDataUnavailable + + IntentsSupported + + TokenPriceConfigurationIntent + + + NSExtensionPointIdentifier + com.apple.intents-service + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).IntentHandler + + + diff --git a/apps/mobile/ios/WidgetIntentExtension/IntentHandler.swift b/apps/mobile/ios/WidgetIntentExtension/IntentHandler.swift new file mode 100644 index 00000000..4034cde3 --- /dev/null +++ b/apps/mobile/ios/WidgetIntentExtension/IntentHandler.swift @@ -0,0 +1,90 @@ +// +// IntentHandler.swift +// WidgetIntentExtension +// +// Created by Eric Huang on 7/5/23. +// + +import Intents +import WidgetsCore +import OSLog + + +class IntentHandler: INExtension, TokenPriceConfigurationIntentHandling { + + enum Section: String { + case top = ".top" + case favorite = ".favorite" + case owned = ".owned" + } + + lazy var ETHTokenResponse = TokenResponse(chain: WidgetConstants.ethereumChain, symbol: WidgetConstants.ethereumSymbol, name: "Ethereum") + + func tokenResponseToIntentToken(_ result: TokenResponse, section: Section) -> IntentToken { + let intentToken: IntentToken = IntentToken(identifier: result.name + section.rawValue, display: "\(result.name)", subtitle: "\(result.symbol)", image: nil) + intentToken.name = result.name + intentToken.symbol = result.symbol + intentToken.address = result.address + intentToken.chain = result.chain + return intentToken + } + + // Dedupes the tokens list and keeps the first instance of the token in the list. + // If there are two of the same tokens and 1 is mainnet, use the mainnet token + func dedupeTokens(_ intentTokens: [IntentToken]) -> [IntentToken] { + var dedupedTokens: [IntentToken] = [] + for intentToken in intentTokens { + if let index = dedupedTokens.firstIndex(where: {$0.name == intentToken.name && $0.symbol == intentToken.symbol}) { + if intentToken.chain == WidgetConstants.ethereumChain { + dedupedTokens[index] = intentToken + } + } else { + dedupedTokens.append(intentToken) + } + } + return dedupedTokens + } + + func provideSelectedTokenOptionsCollection(for intent: TokenPriceConfigurationIntent) async throws -> INObjectCollection { +<<<<<<< HEAD + let favorites = LuxUserDefaults.readFavorites() + let addresses = LuxUserDefaults.readAccounts().accounts.filter{$0.isSigner}.map{$0.address} +======= + let favorites = UniswapUserDefaults.readFavorites() + let addresses = UniswapUserDefaults.readAccounts().accounts.filter{$0.isSigner}.map{$0.address} +>>>>>>> upstream/main + // [WAll-4969] In the future should read chains from app state + let chains = ["ETHEREUM", "POLYGON", "ARBITRUM", "OPTIMISM", "BASE", "BNB", "BLAST", "ZORA", "CELO", "AVALANCHE", "ZKSYNC", "WORLDCHAIN"] + + + async let pendingOwnedTokensResponses = try DataQueries.fetchWalletsTokensData(addresses: addresses, chains: chains) + async let pendingFavoriteTokenReponses = try DataQueries.fetchTokensData(tokenInputs: favorites.favorites) + async let pendingTopTokensResponse = try DataQueries.fetchTopTokensData() + let (ownedTokenResponses ,favoriteTokenReponses, topTokensResponse) = await (try pendingOwnedTokensResponses, try pendingFavoriteTokenReponses, try pendingTopTokensResponse) + + let ownedTokens = dedupeTokens(ownedTokenResponses.map {tokenResponseToIntentToken($0, section: Section.owned)}) + let favoriteTokens = favoriteTokenReponses.map {tokenResponseToIntentToken($0, section: Section.favorite)} + let topTokens = topTokensResponse.map { (result) -> IntentToken in + // replace wETH with ETH in the configuration + if (result.address == WidgetConstants.WETHAddress && result.chain == WidgetConstants.ethereumChain) { + return tokenResponseToIntentToken(ETHTokenResponse, section: Section.top) + } + return tokenResponseToIntentToken(result, section: Section.top) + } + + let ownedSection = INObjectSection(title: "Your Tokens", items: ownedTokens) + let favoriteSection = INObjectSection(title: "Favorite Tokens", items: favoriteTokens) + let topTokensSection = INObjectSection(title: "Top Tokens", items: topTokens) + + return INObjectCollection(sections: [ownedSection, favoriteSection, topTokensSection]) + } + + func defaultSelectedToken(for intent: TokenPriceConfigurationIntent) -> IntentToken? { + return tokenResponseToIntentToken(ETHTokenResponse, section: Section.top) + } + + override func handler(for intent: INIntent) -> Any { + return self + } + +} diff --git a/apps/mobile/ios/WidgetIntentExtension/WidgetIntentExtension.entitlements b/apps/mobile/ios/WidgetIntentExtension/WidgetIntentExtension.entitlements new file mode 100644 index 00000000..12074afa --- /dev/null +++ b/apps/mobile/ios/WidgetIntentExtension/WidgetIntentExtension.entitlements @@ -0,0 +1,14 @@ + + + + + com.apple.security.application-groups + +<<<<<<< HEAD + group.com.lux.widgets +======= + group.com.uniswap.widgets +>>>>>>> upstream/main + + + diff --git a/apps/mobile/ios/Widgets/Assets.xcassets/AccentColor.colorset/Contents.json b/apps/mobile/ios/Widgets/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 00000000..0afb3cf0 --- /dev/null +++ b/apps/mobile/ios/Widgets/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors": [ + { + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/apps/mobile/ios/Widgets/Assets.xcassets/AppIcon.appiconset/Contents.json b/apps/mobile/ios/Widgets/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..b121e3bc --- /dev/null +++ b/apps/mobile/ios/Widgets/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,13 @@ +{ + "images": [ + { + "idiom": "universal", + "platform": "ios", + "size": "1024x1024" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/apps/mobile/ios/Widgets/Assets.xcassets/Contents.json b/apps/mobile/ios/Widgets/Assets.xcassets/Contents.json new file mode 100644 index 00000000..74d6a722 --- /dev/null +++ b/apps/mobile/ios/Widgets/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/apps/mobile/ios/Widgets/Assets.xcassets/WidgetBackground.colorset/Contents.json b/apps/mobile/ios/Widgets/Assets.xcassets/WidgetBackground.colorset/Contents.json new file mode 100644 index 00000000..0afb3cf0 --- /dev/null +++ b/apps/mobile/ios/Widgets/Assets.xcassets/WidgetBackground.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors": [ + { + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/apps/mobile/ios/Widgets/Assets.xcassets/caret-up.imageset/Contents.json b/apps/mobile/ios/Widgets/Assets.xcassets/caret-up.imageset/Contents.json new file mode 100644 index 00000000..4062f18d --- /dev/null +++ b/apps/mobile/ios/Widgets/Assets.xcassets/caret-up.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images": [ + { + "filename": "caret-up.svg", + "idiom": "universal", + "scale": "1x" + }, + { + "idiom": "universal", + "scale": "2x" + }, + { + "idiom": "universal", + "scale": "3x" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/apps/mobile/ios/Widgets/Assets.xcassets/caret-up.imageset/caret-up.svg b/apps/mobile/ios/Widgets/Assets.xcassets/caret-up.imageset/caret-up.svg new file mode 100644 index 00000000..ede18540 --- /dev/null +++ b/apps/mobile/ios/Widgets/Assets.xcassets/caret-up.imageset/caret-up.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/ios/Widgets/Info.plist b/apps/mobile/ios/Widgets/Info.plist new file mode 100644 index 00000000..b18cf4bc --- /dev/null +++ b/apps/mobile/ios/Widgets/Info.plist @@ -0,0 +1,16 @@ + + + + + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + UIAppFonts + + Basel-Grotesk-Book.otf + Basel-Grotesk-Medium.otf + + + diff --git a/apps/mobile/ios/Widgets/TokenPriceWidget.intentdefinition b/apps/mobile/ios/Widgets/TokenPriceWidget.intentdefinition new file mode 100644 index 00000000..e596c4c5 --- /dev/null +++ b/apps/mobile/ios/Widgets/TokenPriceWidget.intentdefinition @@ -0,0 +1,223 @@ + + + + + INEnums + + INIntentDefinitionModelVersion + 1.2 + INIntentDefinitionNamespace + 88xZPY + INIntentDefinitionSystemVersion + 22F82 + INIntentDefinitionToolsBuildVersion + 14C18 + INIntentDefinitionToolsVersion + 14.2 + INIntents + + + INIntentCategory + information + INIntentDescription + Select a token to track its price + INIntentDescriptionID + tVvJ9c + INIntentEligibleForWidgets + + INIntentIneligibleForSuggestions + + INIntentLastParameterTag + 2 + INIntentName + TokenPriceConfiguration + INIntentParameters + + + INIntentParameterConfigurable + + INIntentParameterDisplayName + Selected Token + INIntentParameterDisplayNameID + 94tJAN + INIntentParameterDisplayPriority + 1 + INIntentParameterName + selectedToken + INIntentParameterObjectType + IntentToken + INIntentParameterObjectTypeNamespace + 88xZPY + INIntentParameterPromptDialogs + + + INIntentParameterPromptDialogCustom + + INIntentParameterPromptDialogType + Configuration + + + INIntentParameterPromptDialogCustom + + INIntentParameterPromptDialogType + Primary + + + INIntentParameterSupportsDynamicEnumeration + + INIntentParameterTag + 2 + INIntentParameterType + Object + + + INIntentResponse + + INIntentResponseCodes + + + INIntentResponseCodeName + success + INIntentResponseCodeSuccess + + + + INIntentResponseCodeName + failure + + + + INIntentTitle + Token Price Configuration + INIntentTitleID + gpCwrM + INIntentType + Custom + INIntentVerb + View + + + INTypes + + + INTypeDisplayName + Intent Token + INTypeDisplayNameID + QK0Gto + INTypeLastPropertyTag + 107 + INTypeName + IntentToken + INTypeProperties + + + INTypePropertyDefault + + INTypePropertyDisplayPriority + 1 + INTypePropertyName + identifier + INTypePropertyTag + 1 + INTypePropertyType + String + + + INTypePropertyDefault + + INTypePropertyDisplayPriority + 2 + INTypePropertyName + displayString + INTypePropertyTag + 2 + INTypePropertyType + String + + + INTypePropertyDefault + + INTypePropertyDisplayPriority + 3 + INTypePropertyName + pronunciationHint + INTypePropertyTag + 3 + INTypePropertyType + String + + + INTypePropertyDefault + + INTypePropertyDisplayPriority + 4 + INTypePropertyName + alternativeSpeakableMatches + INTypePropertySupportsMultipleValues + + INTypePropertyTag + 4 + INTypePropertyType + SpeakableString + + + INTypePropertyDisplayName + Symbol + INTypePropertyDisplayNameID + mO9gGm + INTypePropertyDisplayPriority + 5 + INTypePropertyName + symbol + INTypePropertyTag + 100 + INTypePropertyType + String + + + INTypePropertyDisplayName + Name + INTypePropertyDisplayNameID + aKn8VW + INTypePropertyDisplayPriority + 6 + INTypePropertyName + name + INTypePropertyTag + 105 + INTypePropertyType + String + + + INTypePropertyDisplayName + Address + INTypePropertyDisplayNameID + rCOb5U + INTypePropertyDisplayPriority + 7 + INTypePropertyName + address + INTypePropertyTag + 107 + INTypePropertyType + String + + + INTypePropertyDisplayName + Chain + INTypePropertyDisplayNameID + OOvm01 + INTypePropertyDisplayPriority + 8 + INTypePropertyName + chain + INTypePropertyTag + 103 + INTypePropertyType + String + + + + + + diff --git a/apps/mobile/ios/Widgets/TokenPriceWidget.swift b/apps/mobile/ios/Widgets/TokenPriceWidget.swift new file mode 100644 index 00000000..770f413e --- /dev/null +++ b/apps/mobile/ios/Widgets/TokenPriceWidget.swift @@ -0,0 +1,367 @@ +// +// TokenPriceWidget.swift +// TokenPriceWidget +// +// Created by Eric Huang on 6/22/23. +// + +import WidgetKit +import SwiftUI +import Intents +import WidgetsCore +import Apollo +import Charts + +let placeholderPriceHistory = [ + PriceHistory(timestamp: 1689792001, price: 2161), + PriceHistory(timestamp: 1689792245, price: 2160), + PriceHistory(timestamp: 1689792571, price: 2163), + PriceHistory(timestamp: 1689792894, price: 2164), + PriceHistory(timestamp: 1689793209, price: 2166), + PriceHistory(timestamp: 1689793465, price: 2163), + PriceHistory(timestamp: 1689793781, price: 2164), + PriceHistory(timestamp: 1689794035, price: 2163), + PriceHistory(timestamp: 1689794381, price: 2164), + PriceHistory(timestamp: 1689794701, price: 2167), + PriceHistory(timestamp: 1689794997, price: 2167), + PriceHistory(timestamp: 1689795264, price: 2165) +] +let previewEntry = TokenPriceEntry( + date: Date(), + configuration: TokenPriceConfigurationIntent(), + currency: WidgetConstants.currencyUsd, + spotPrice: 2165, + pricePercentChange: -9.87, + symbol: "ETH", + logo: UIImage(url: URL(string: "https://token-icons.s3.amazonaws.com/eth.png")), + backgroundColor: ColorExtraction.extractImageColorWithSpecialCase( + imageURL: "https://token-icons.s3.amazonaws.com/eth.png" + ), + tokenPriceHistory: TokenPriceHistoryResponse(priceHistory: placeholderPriceHistory, price: 2165, pricePercentChange24h: -9.87) +) + +let placeholderEntry = TokenPriceEntry( + date: previewEntry.date, + configuration: previewEntry.configuration, + currency: previewEntry.currency, + spotPrice: previewEntry.spotPrice, + pricePercentChange: previewEntry.pricePercentChange, + symbol: previewEntry.symbol, + logo: nil, + backgroundColor: nil, + tokenPriceHistory: previewEntry.tokenPriceHistory +) + +let refreshMinutes = 5 +let displayName = "Token Prices" +let description = "Keep up to date on your favorite tokens." + + +struct Provider: IntentTimelineProvider { + + func getEntry(configuration: TokenPriceConfigurationIntent, context: Context, isSnapshot: Bool) async throws -> TokenPriceEntry { + let entryDate = Date() + async let tokenPriceRequest = isSnapshot ? + await DataQueries.fetchTokenPriceData(chain: WidgetConstants.ethereumChain, address: nil) : + await DataQueries.fetchTokenPriceData(chain: configuration.selectedToken?.chain ?? "", address: configuration.selectedToken?.address) + async let conversionRequest = await DataQueries.fetchCurrencyConversion( +<<<<<<< HEAD + toCurrency: LuxUserDefaults.readI18n().currency) +======= + toCurrency: UniswapUserDefaults.readI18n().currency) +>>>>>>> upstream/main + + let (tokenPriceResponse, conversionResponse) = try await (tokenPriceRequest, conversionRequest) + + let spotPrice = tokenPriceResponse.spotPrice != nil ? + tokenPriceResponse.spotPrice! * conversionResponse.conversionRate : nil + let pricePercentChange = tokenPriceResponse.pricePercentChange + let symbol = tokenPriceResponse.symbol + let logo = UIImage(url: URL(string: tokenPriceResponse.logoUrl ?? "")) + var backgroundColor: UIColor? = nil + if let logoUrl = tokenPriceResponse.logoUrl { + backgroundColor = ColorExtraction.extractImageColorWithSpecialCase(imageURL: logoUrl) + } + var tokenPriceHistory: TokenPriceHistoryResponse? = nil + + tokenPriceHistory = isSnapshot ? + try await DataQueries.fetchTokenPriceHistoryData( + chain: WidgetConstants.ethereumChain, + address: nil) : + try await DataQueries.fetchTokenPriceHistoryData( + chain: configuration.selectedToken?.chain ?? WidgetConstants.ethereumChain, + address: configuration.selectedToken?.address) + + return TokenPriceEntry( + date: entryDate, + configuration: configuration, + currency: conversionResponse.currency, + spotPrice: tokenPriceHistory?.price ?? spotPrice, + pricePercentChange: tokenPriceHistory?.pricePercentChange24h ?? pricePercentChange, + symbol: symbol, + logo: logo, + backgroundColor: backgroundColor, + tokenPriceHistory: tokenPriceHistory + ) + } + + func placeholder(in context: Context) -> TokenPriceEntry { + return placeholderEntry + } + + func getSnapshot(for configuration: TokenPriceConfigurationIntent, in context: Context, completion: @escaping (TokenPriceEntry) -> ()) { + Task { + let entry = try await getEntry(configuration: configuration, context: context, isSnapshot: true) + completion(entry) + } + } + + func getTimeline(for configuration: TokenPriceConfigurationIntent, in context: Context, completion: @escaping (Timeline) -> ()) { + Metrics.logWidgetConfigurationChange() + Task { + let entry = try await getEntry(configuration: configuration, context: context, isSnapshot: false) + let nextDate = Calendar.current.date(byAdding: .minute, value: refreshMinutes, to: entry.date)! + let timeline = Timeline(entries: [entry], policy: .after(nextDate)) + completion(timeline) + } + } +} + +struct TokenPriceEntry: TimelineEntry { + let date: Date + let configuration: TokenPriceConfigurationIntent + let currency: String + let spotPrice: Double? + let pricePercentChange: Double? + let symbol: String + let logo: UIImage? + let backgroundColor: UIColor? + let tokenPriceHistory: TokenPriceHistoryResponse? +} + +struct TokenPriceWidgetEntryView: View { + @Environment(\.widgetFamily) var family + // redactionReasons stores context on how a widget is being asked to render covering data invalidation, loading and placeholders, and privacy reasons. + // We use any reason to mean we should render a full placeholder UI + @Environment(\.redactionReasons) var reasons + @Environment(\.colorScheme) var colorScheme + + var entry: Provider.Entry + + func widgetPriceHeader(isPlaceholder: Bool) -> some View { + return HStack(alignment: .top) { + if (!isPlaceholder) { + if let logo = entry.logo { + Image(uiImage: logo).withIconStyle(background: .white, border: entry.backgroundColor != nil ? Color(entry.backgroundColor!) : Color.widgetTokenShadow) + } else { + Placeholder.Circle(width: 40, height: 40) + } + Spacer() + Text(entry.symbol) + .withHeading3Style() + .padding(.vertical, 2) + } else { + Placeholder.Circle(width: 40, height: 40) + Spacer() + Placeholder.Rectangle(width: 24, height: 16) + } + } + } + + func priceSection(isPlaceholder: Bool) -> some View { + return VStack(alignment: .leading, spacing: 0) { + if (!isPlaceholder && entry.spotPrice != nil && entry.pricePercentChange != nil) { +<<<<<<< HEAD + let i18nSettings = LuxUserDefaults.readI18n() +======= + let i18nSettings = UniswapUserDefaults.readI18n() +>>>>>>> upstream/main + Text( + NumberFormatter.fiatTokenDetailsFormatter( + price: entry.spotPrice, + locale: Locale(identifier: i18nSettings.locale), + currencyCode: entry.currency + ) + ) + .withHeading1Style() + .frame(minHeight: 28) + .minimumScaleFactor(0.3) + .padding(.bottom, 4) + PricePercentChangeTextWithIcon(pricePercentChange: entry.pricePercentChange) + .padding(.bottom, 8) + .padding([.trailing, .leading], 4) + } else { + Placeholder.Rectangle(width: 108, height: 22) + .padding(.bottom, 4) + Placeholder.Rectangle(width: 75, height: 22) + .padding(.bottom, 8) + .padding(.trailing, 4) + } + } + } + + func timeStamp() -> some View { + return Text("\(Date().formatted(date: .omitted, time: .shortened).lowercased())") + .withHeading3Style() + } + + func smallWidget() -> some View { + let body = ZStack { + VStack(alignment: .leading, spacing: 0) { + widgetPriceHeader(isPlaceholder: false).padding(.bottom, 2) + Spacer() + priceSection(isPlaceholder: false).padding(.bottom, 2) + timeStamp() + } + .withMaxFrame() + } + + if #available(iOSApplicationExtension 17.0, *) { + return body + } else { + return body.padding(12) + } + } + + func smallWidgetPlaceholder() -> some View { + let body = ZStack { + VStack(alignment: .leading, spacing: 0) { + widgetPriceHeader(isPlaceholder: true).padding(.bottom, 12) + Spacer() + priceSection(isPlaceholder: true) + } + .withMaxFrame() + } + + if #available(iOSApplicationExtension 17.0, *) { + return body + } else { + return body.padding(12) + } + } + + func mediumWidget() -> some View { + let body = ZStack { + VStack(alignment: .leading, spacing: 0) { + widgetPriceHeader(isPlaceholder: false).padding(.bottom, 4) + Spacer() + HStack(alignment: .top, spacing: 32) { + if let spotPrice = entry.spotPrice { + widgetPriceHistoryChart(priceHistory: entry.tokenPriceHistory?.priceHistory ?? [], spotPrice: spotPrice) + .frame(width: 115.0, height: 50.0) + } else { + Placeholder.Rectangle(width: 115, height: 50) + } + priceSection(isPlaceholder: false) + } + .padding(.bottom, 2) + timeStamp() + } + .withMaxFrame() + } + + if #available(iOSApplicationExtension 17.0, *) { + return body + } else { + return body.padding(16) + } + } + + func mediumWidgetPlaceholder() -> some View { + let body = ZStack { + VStack(alignment: .leading, spacing: 0) { + widgetPriceHeader(isPlaceholder: true).padding(.bottom, 8) + Spacer() + HStack(alignment: .top, spacing: 32) { + Placeholder.Rectangle(width: 115, height: 50) + priceSection(isPlaceholder: true) + } + } + .withMaxFrame() + } + + if #available(iOSApplicationExtension 17.0, *) { + return body + } else { + return body.padding(16) + } + } + + func widgetColor() -> Color { + if let color = entry.backgroundColor { + return Color(color) + } else { + return Color.UNI + } + } + + func placeholderColor() -> Color { + Color(colorScheme == .light ? .white : UIColor(.surface1)) + } + + var body: some View { +<<<<<<< HEAD + let deeplinkURL = URL(string: "lux://widget/#/tokens/\(entry.configuration.selectedToken?.chain?.lowercased() ?? "")/\(entry.configuration.selectedToken?.address ?? "NATIVE")") +======= + let deeplinkURL = URL(string: "uniswap://widget/#/tokens/\(entry.configuration.selectedToken?.chain?.lowercased() ?? "")/\(entry.configuration.selectedToken?.address ?? "NATIVE")") +>>>>>>> upstream/main + let shouldRenderPlaceholder = !reasons.isEmpty + let body = ZStack { + switch family { + case .systemMedium: + if (!shouldRenderPlaceholder) { + mediumWidget() + } else { + mediumWidgetPlaceholder() + } + default: + if (!shouldRenderPlaceholder) { + smallWidget() + } else { + smallWidgetPlaceholder() + } + } + }.widgetURL(deeplinkURL) + + if #available(iOSApplicationExtension 17.0, *) { + return body.containerBackground(for: .widget) { + if (!shouldRenderPlaceholder) { + widgetColor() + } else { + placeholderColor() + } + } + } else { + if (!shouldRenderPlaceholder) { + return body.background(widgetColor()) + } else { + return body.background(placeholderColor()) + } + } + } +} + +struct TokenPriceWidget: Widget { + let kind: String = "TokenPriceWidget" + + var body: some WidgetConfiguration { + IntentConfiguration(kind: kind, intent: TokenPriceConfigurationIntent.self, provider: Provider()) { entry in + TokenPriceWidgetEntryView(entry: entry) + } + .configurationDisplayName(displayName) + .description(description) + .supportedFamilies([.systemSmall, .systemMedium]) + } +} + +struct TokenPriceWidget_Previews: PreviewProvider { + static var previews: some View { + Group{ + TokenPriceWidgetEntryView(entry: previewEntry) + .previewContext(WidgetPreviewContext(family: .systemSmall)) + TokenPriceWidgetEntryView(entry: previewEntry) + .previewContext(WidgetPreviewContext(family: .systemMedium)) + } + } +} + diff --git a/apps/mobile/ios/Widgets/Widgets.entitlements b/apps/mobile/ios/Widgets/Widgets.entitlements new file mode 100644 index 00000000..12074afa --- /dev/null +++ b/apps/mobile/ios/Widgets/Widgets.entitlements @@ -0,0 +1,14 @@ + + + + + com.apple.security.application-groups + +<<<<<<< HEAD + group.com.lux.widgets +======= + group.com.uniswap.widgets +>>>>>>> upstream/main + + + diff --git a/apps/mobile/ios/Widgets/WidgetsBundle.swift b/apps/mobile/ios/Widgets/WidgetsBundle.swift new file mode 100644 index 00000000..dc1d9714 --- /dev/null +++ b/apps/mobile/ios/Widgets/WidgetsBundle.swift @@ -0,0 +1,16 @@ +// +// WidgetsBundle.swift +// TokenPriceWidget +// +// Created by Eric Huang on 6/22/23. +// + +import WidgetKit +import SwiftUI + +@main +struct WidgetsBundle: WidgetBundle { + var body: some Widget { + TokenPriceWidget() + } +} diff --git a/apps/mobile/ios/WidgetsCore/.mobileschema_fingerprint b/apps/mobile/ios/WidgetsCore/.mobileschema_fingerprint new file mode 100644 index 00000000..71611557 --- /dev/null +++ b/apps/mobile/ios/WidgetsCore/.mobileschema_fingerprint @@ -0,0 +1 @@ +025ee5453cc59f194838e233659e0c7da667eff8a4440c563dcdbb9efd3e880a \ No newline at end of file diff --git a/apps/mobile/ios/WidgetsCore/MobileSchema/README.md b/apps/mobile/ios/WidgetsCore/MobileSchema/README.md new file mode 100644 index 00000000..1e031963 --- /dev/null +++ b/apps/mobile/ios/WidgetsCore/MobileSchema/README.md @@ -0,0 +1,69 @@ +# Swift Mobile GraphQL Schema + +This Framework contains autogenerated code that is generated from running Apollo iOS graphQL codegen. This code is not checked in, but the generated files are referenced for the Xcode build process. + +## Generating the Swift GraphQL Schema + +They run automatically as part of WidgetsCore build phases. + +If you encounter build errors after updating the GraphQL schema: + +1. **Missing file references**: If Xcode complains about missing files, run `bun mobile ios:prebuild` again to ensure all files are properly added to the project. + +2. **Script failures**: If the automatic file addition fails, you may see a warning. In this case, try running the script manually: + + ``` + cd apps/mobile && ruby ./scripts/update_apollo_files_in_xcode.rb + ``` + +3. **Manual file addition**: If the script consistently fails, you can add files manually (see the "Adding Generated Files Manually" section below). + +4. **Clean build**: Sometimes a clean build helps resolve reference issues: + + ``` + cd apps/mobile/ios && xcodebuild clean + ``` + +5. **Check for schema errors**: Ensure there are no errors in your GraphQL schema or queries. + +## Adding Generated Files Manually + +If you need to manually add new GraphQL queries or fragments to Swift: + +1. Ensure the file is listed in `apps/mobile/ios/apollo-codegen-config.json`'s `"operationSearchPaths"` and `"schemaSearchPaths"` +2. Add the needed generated files to the XCode project. To add new files: + 1. Right-click the `WidgetsCore` folder in XCode + 2. Select `add files to "Uniswap"...` + 3. Select the `MobileSchema` folder + 4. Keep `Action: Reference files in place` selected + 5. Keep `Groups: Create Groups` selected + 6. Keep `WidgetsCore` checked + 7. Click Finish + +## Implementation Details + +The automation for adding generated files to Xcode uses a Ruby script (`scripts/update_apollo_files_in_xcode.rb`) that: + +1. Removes any existing MobileSchema group from the Xcode project +2. Creates a new MobileSchema group with the proper structure +3. Scans the MobileSchema directory for Swift files +4. Adds files to the appropriate groups in the Xcode project +5. Updates the build phases to include the new files + +### Requirements + +The script requires the `xcodeproj` Ruby gem: + +``` +gem install xcodeproj +``` + +Note: This should already have been installed via the `mobile` app's setup instructions. + +### Debugging + +If the script fails, you can run it with debugging output: + +``` +DEBUG=1 ruby ./scripts/update_apollo_files_in_xcode.rb +``` diff --git a/apps/mobile/ios/WidgetsCore/Utils/Constants.swift b/apps/mobile/ios/WidgetsCore/Utils/Constants.swift new file mode 100644 index 00000000..53ef5534 --- /dev/null +++ b/apps/mobile/ios/WidgetsCore/Utils/Constants.swift @@ -0,0 +1,26 @@ +// +// +// Constants.swift +// WidgetsCore +// +// Created by Eric Huang on 8/9/23. +// + +import Foundation + +public struct WidgetConstants { + public static let ethereumChain = "ETHEREUM" + public static let WETHAddress = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2" + public static let ethereumSymbol = "ETH" + public static let currencyUsd = "USD" +} + +// Needed to handle different bundle ids, cannot map directly but handles arbitrary bundle ids that conform to the existing convention +func getBuildVariantString(bundleId: String) -> String { + let bundleComponents = bundleId.components(separatedBy: ".") + if (bundleComponents.count > 3 && (bundleComponents[3] == "dev" || bundleComponents[3] == "beta")) { + return bundleComponents[3] + } else { + return "prod" + } +} diff --git a/apps/mobile/ios/WidgetsCore/Utils/DataQueries.swift b/apps/mobile/ios/WidgetsCore/Utils/DataQueries.swift new file mode 100644 index 00000000..d2678ca7 --- /dev/null +++ b/apps/mobile/ios/WidgetsCore/Utils/DataQueries.swift @@ -0,0 +1,166 @@ +// +// Network.swift +// WidgetsCore +// +// Created by Eric Huang on 7/6/23. +// + +import Foundation +import Apollo +import OSLog + +public class DataQueries { + + static let cachePolicy: CachePolicy = CachePolicy.fetchIgnoringCacheData + + public static func fetchTokensData(tokenInputs: [TokenInput]) async throws -> [TokenResponse] { + return try await withCheckedThrowingContinuation { continuation in + let contractInputs = tokenInputs.map {MobileSchema.ContractInput(chain: GraphQLEnum(rawValue: $0.chain), address: $0.address == nil ? GraphQLNullable.null: GraphQLNullable(stringLiteral: $0.address!))} + Network.shared.apollo.fetch(query: MobileSchema.WidgetTokensQuery(contracts: contractInputs)) { result in + switch result { + case .success(let graphQLResult): + let tokens = graphQLResult.data?.tokens ?? [] + let tokenResponses = tokens.map { + let symbol = $0?.symbol + let name = $0?.name + let chain = $0?.chain + let address = $0?.address + return TokenResponse(chain: chain?.rawValue ?? "", address: address, symbol: symbol ?? "", name: name ?? "") + } + continuation.resume(returning: tokenResponses) + case .failure(let error): + continuation.resume(throwing: error) + } + } + } + } + + public static func fetchTopTokensData() async throws -> [TokenResponse] { + return try await withCheckedThrowingContinuation { continuation in + Network.shared.apollo.fetch(query: MobileSchema.TopTokensQuery(chain: GraphQLNullable(MobileSchema.Chain.ethereum)), cachePolicy: cachePolicy) { result in + switch result { + case .success(let graphQLResult): + let topTokens = graphQLResult.data?.topTokens ?? [] + let tokenResponses = topTokens.map { (tokenData) -> TokenResponse in + let symbol = tokenData?.symbol + let name = tokenData?.name + let chain = tokenData?.chain + let address = tokenData?.address + return TokenResponse(chain: chain?.rawValue ?? "", address: address, symbol: symbol ?? "", name: name ?? "") + } + continuation.resume(returning: tokenResponses) + case .failure(let error): + continuation.resume(throwing: error) + } + } + } + } + + public static func fetchTokenPriceData(chain: String, address: String?) async throws -> TokenPriceResponse { + return try await withCheckedThrowingContinuation { continuation in + Network.shared.apollo.fetch(query: MobileSchema.FavoriteTokenCardQuery(chain: GraphQLEnum(rawValue: chain), address: address == nil ? GraphQLNullable.null : GraphQLNullable(stringLiteral: address!)), cachePolicy: cachePolicy) { result in + switch result { + case .success(let graphQLResult): + let token = graphQLResult.data?.token + let symbol = token?.symbol + let name = token?.name + let logoUrl = token?.project?.logoUrl ?? nil + let market = token?.market + let spotPrice = market?.price?.value + let pricePercentChange = market?.pricePercentChange?.value + let tokenPriceResponse = TokenPriceResponse(chain: chain, address: address, symbol: symbol ?? "", name: name ?? "", logoUrl: logoUrl ?? "", spotPrice: spotPrice, pricePercentChange: pricePercentChange) + continuation.resume(returning: tokenPriceResponse) + case .failure(let error): + continuation.resume(throwing: error) + } + } + } + } + + public static func fetchTokenPriceHistoryData(chain: String, address: String?) async throws -> TokenPriceHistoryResponse { + return try await withCheckedThrowingContinuation { continuation in + Network.shared.apollo.fetch(query: MobileSchema.TokenPriceHistoryQuery(contract: MobileSchema.ContractInput(chain: GraphQLEnum(rawValue: chain), address: address == nil ? GraphQLNullable.null: GraphQLNullable(stringLiteral: address!))), cachePolicy: cachePolicy) { result in + switch result { + case .success(let graphQLResult): + let tokenProject = graphQLResult.data?.tokenProjects?[0] + let markets = tokenProject?.markets + let price = tokenProject?.markets?[0]?.price?.value + let pricePercentChange24h = tokenProject?.markets?[0]?.pricePercentChange24h?.value + let priceHistory = (markets != nil) && !markets!.isEmpty ? + tokenProject?.markets?[0]?.priceHistory?.map { (result) -> PriceHistory in + return PriceHistory(timestamp: result?.timestamp ?? 0 * 1000, price: result?.value ?? 0) + } : [] + let priceHistoryResponse = TokenPriceHistoryResponse(priceHistory: priceHistory ?? [], price: price, pricePercentChange24h: pricePercentChange24h) + continuation.resume(returning: priceHistoryResponse) + case .failure(let error): + continuation.resume(throwing: error) + } + } + } + } + + public static func fetchWalletsTokensData(addresses: [String], chains: [String], maxLength: Int = 25) async throws -> [TokenResponse] { + let gqlChains = chains.map { GraphQLEnum(MobileSchema.Chain(rawValue: $0)!) } + return try await withCheckedThrowingContinuation { continuation in + Network.shared.apollo.fetch(query: MobileSchema.MultiplePortfolioBalancesQuery(ownerAddresses: addresses, valueModifiers: GraphQLNullable.null, chains: gqlChains)){ result in + switch result { + case .success(let graphQLResult): + // Takes all the signer accounts and sums up the balances of the tokens, then sorts them by descending order, ignoring spam + var tokens: [TokenResponse: Double] = [:] + let portfolios = graphQLResult.data?.portfolios + portfolios?.forEach { + $0?.tokenBalances?.forEach { tokenBalance in + let value = tokenBalance?.denominatedValue?.value + let token = tokenBalance?.token + let tokenResponse = TokenResponse(chain: token?.chain.rawValue ?? "", address: token?.address, symbol: token?.symbol ?? "", name: token?.name ?? "") + let isSpam = token?.project?.isSpam ?? false + if (!isSpam) { + tokens[tokenResponse] = (tokens[tokenResponse] ?? 0) + (value ?? 0) + } + } + } + let tokenResponses = tokens.keys.sorted { tokens[$0]! > tokens[$1]!} + continuation.resume(returning: Array(tokenResponses.prefix(maxLength))) + case .failure(let error): + continuation.resume(throwing: error) + } + } + } + } + + public static func fetchCurrencyConversion(toCurrency: String) async throws -> CurrencyConversionResponse { + return try await withCheckedThrowingContinuation { continuation in + let usdResponse = CurrencyConversionResponse(conversionRate: 1, currency: WidgetConstants.currencyUsd) + + // Assuming all server currency amounts are in USD + if (toCurrency == WidgetConstants.currencyUsd) { + return continuation.resume(returning: usdResponse) + } + + Network.shared.apollo.fetch( + query: MobileSchema.ConvertQuery( + fromCurrency: GraphQLEnum(MobileSchema.Currency.usd), + toCurrency: GraphQLEnum(rawValue: toCurrency) + ) + ) { result in + switch result { + case .success(let graphQLResult): + let conversionRate = graphQLResult.data?.convert?.value + let currency = graphQLResult.data?.convert?.currency?.rawValue + + continuation.resume( + returning: conversionRate == nil || currency == nil ? usdResponse : + CurrencyConversionResponse( + conversionRate: conversionRate!, + currency: currency! + ) + ) + case .failure: + continuation.resume(returning: usdResponse) + } + } + } + } +} + + diff --git a/apps/mobile/ios/WidgetsCore/Utils/Format.swift b/apps/mobile/ios/WidgetsCore/Utils/Format.swift new file mode 100644 index 00000000..f037d92d --- /dev/null +++ b/apps/mobile/ios/WidgetsCore/Utils/Format.swift @@ -0,0 +1,69 @@ +// +// Format.swift +// WidgetsCore +// +// Created by Eric Huang on 7/24/23. +// +import Foundation + +<<<<<<< HEAD +// Based on https://www.notion.so/luxlabs/Number-standards-fbb9f533f10e4e22820722c2f66d23c0 +// React native code: https://github.com/luxfi/exchange/blob/main/packages/wallet/src/utils/format.ts +======= +// Based on https://www.notion.so/uniswaplabs/Number-standards-fbb9f533f10e4e22820722c2f66d23c0 +// React native code: https://github.com/Uniswap/universe/blob/main/packages/wallet/src/utils/format.ts +>>>>>>> upstream/main +extension NumberFormatter { + + static func formatShorthandWithDecimals(number: Double, fractionDigits: Int, locale: Locale, currencyCode: String, placeholder: String) -> String { + if (number < 1000000) { + return formatWithDecimals(number: number, fractionDigits: fractionDigits, locale: locale, currencyCode: currencyCode) + } + let maxNumber = 1000000000000000.0 + let maxed = number >= maxNumber + let limitedNumber = maxed ? maxNumber : number + + // Replace when Swift supports notation configuration for currency + // https://developer.apple.com/documentation/foundation/currencyformatstyleconfiguration + let compactFormatted = limitedNumber.formatted(.number.locale(locale).precision(.fractionLength(fractionDigits)).notation(.compactName)) + let currencyFormatted = limitedNumber.formatted(.currency(code: currencyCode).locale(locale).precision(.fractionLength(fractionDigits)).grouping(.never)) + + guard let numberRegex = try? NSRegularExpression(pattern: "(\\d+(\\\(locale.decimalSeparator!)\\d+)?)") else { + return placeholder + } + let output = numberRegex.stringByReplacingMatches(in: currencyFormatted, range: NSMakeRange(0, currencyFormatted.count), withTemplate: compactFormatted) + + return maxed ? ">\(output)" : "\(output)" + } + + static func formatWithDecimals(number: Double, fractionDigits: Int, locale: Locale, currencyCode: String) -> String { + return number.formatted(.currency(code: currencyCode).locale(locale).precision(.fractionLength(fractionDigits))) + } + + static func formatWithSigFigs(number: Double, sigFigsDigits: Int, locale: Locale, currencyCode: String) -> String { + return number.formatted(.currency(code: currencyCode).locale(locale).precision(.significantDigits(sigFigsDigits))) + } + + public static func fiatTokenDetailsFormatter(price: Double?, locale: Locale, currencyCode: String) -> String { + let placeholder = "--.--" + + guard let price = price else { + return placeholder + } + + if (price < 0.00000001) { + let formattedPrice = formatWithDecimals(number: price, fractionDigits: 8, locale: locale, currencyCode: currencyCode) + return "<\(formattedPrice)" + } + + if (price < 0.01) { + return formatWithSigFigs(number: price, sigFigsDigits: 3, locale: locale, currencyCode: currencyCode) + } else if (price < 1.05) { + return formatWithDecimals(number: price, fractionDigits: 3, locale: locale, currencyCode: currencyCode) + } else if (price < 1e6) { + return formatWithDecimals(number: price, fractionDigits: 2, locale: locale, currencyCode: currencyCode) + } else { + return formatShorthandWithDecimals(number: price, fractionDigits: 2, locale: locale, currencyCode: currencyCode, placeholder: placeholder) + } + } +} diff --git a/apps/mobile/ios/WidgetsCore/Utils/Logging.swift b/apps/mobile/ios/WidgetsCore/Utils/Logging.swift new file mode 100644 index 00000000..496a6d6b --- /dev/null +++ b/apps/mobile/ios/WidgetsCore/Utils/Logging.swift @@ -0,0 +1,78 @@ +// +// Logging.swift +// WidgetsCore +// +// Created by Eric Huang on 6/22/23. +// + +import Foundation +import os.log +import WidgetKit + +public extension Logger { + private static var subsystem = Bundle.main.bundleIdentifier! + + /// Logs the view cycles like viewDidLoad. + static let viewCycle = Logger(subsystem: subsystem, category: "viewcycle") +} + +public struct Differences { + var added: [WidgetInfoDecodable] + var removed: [WidgetInfoDecodable] +} + +public struct Metrics { + // finds the difference between the 2 widgetInfoDecodable arrays, with duplicate elements + static func findMultiDifferenceFromCache(current: [WidgetInfoDecodable], fromCached: [WidgetInfoDecodable]) -> Differences { + var output = Differences(added: [], removed: []) + var cachedElementCounts = [WidgetInfoDecodable:Int]() + for cached in fromCached { + cachedElementCounts[cached] = (cachedElementCounts[cached] ?? 0) + 1 + } + for element in current { + if (cachedElementCounts[element] == nil) { + cachedElementCounts[element] = 0 + } + cachedElementCounts[element]! -= 1 + } + for (key, value) in cachedElementCounts { + if (value > 0) { + for _ in 1 ... value { output.removed.append(key) } + } + else if (value < 0) { + for _ in 1 ... -value { output.added.append(key) } + } + } + + return output + } + + public static func logWidgetConfigurationChange() { + WidgetCenter.shared.getCurrentConfigurations { result in + if case .success(let config) = result { + let currConfig = WidgetDataConfiguration(config) +<<<<<<< HEAD + let cachedConfig = LuxUserDefaults.readConfiguration() + let diff = findMultiDifferenceFromCache(current: currConfig.configuration, fromCached: cachedConfig.configuration) + + var widgetEvents = LuxUserDefaults.readEventChanges() + var newEvents = diff.added.map {WidgetEvent(family: $0.family, kind: $0.kind, change: .added)} + newEvents.append(contentsOf: diff.removed.map {WidgetEvent(family: $0.family, kind: $0.kind, change: .removed)}) + widgetEvents.events.append(contentsOf: newEvents) + LuxUserDefaults.writeEventsChanges(data: widgetEvents) + LuxUserDefaults.writeConfiguration(data: currConfig) +======= + let cachedConfig = UniswapUserDefaults.readConfiguration() + let diff = findMultiDifferenceFromCache(current: currConfig.configuration, fromCached: cachedConfig.configuration) + + var widgetEvents = UniswapUserDefaults.readEventChanges() + var newEvents = diff.added.map {WidgetEvent(family: $0.family, kind: $0.kind, change: .added)} + newEvents.append(contentsOf: diff.removed.map {WidgetEvent(family: $0.family, kind: $0.kind, change: .removed)}) + widgetEvents.events.append(contentsOf: newEvents) + UniswapUserDefaults.writeEventsChanges(data: widgetEvents) + UniswapUserDefaults.writeConfiguration(data: currConfig) +>>>>>>> upstream/main + } + } + } +} diff --git a/apps/mobile/ios/WidgetsCore/Utils/Network.swift b/apps/mobile/ios/WidgetsCore/Utils/Network.swift new file mode 100644 index 00000000..cc371697 --- /dev/null +++ b/apps/mobile/ios/WidgetsCore/Utils/Network.swift @@ -0,0 +1,84 @@ +// +// Network.swift +// WidgetsCore +// +// Created by Eric Huang on 7/6/23. +// + +import Foundation +import Apollo + +public class Network { + public static let shared = Network() + +<<<<<<< HEAD + private let LUX_API_URL = "https://ios.wallet.gateway.lux.exchange/v1/graphql" +======= + private let UNISWAP_API_URL = "https://ios.wallet.gateway.uniswap.org/v1/graphql" +>>>>>>> upstream/main + + public lazy var apollo: ApolloClient = { + let cache = InMemoryNormalizedCache() + let store = ApolloStore(cache: cache) + let client = URLSessionClient() + + let provider = NetworkInterceptorProvider(store: store, client: client) +<<<<<<< HEAD + let url = URL(string: LUX_API_URL)! +======= + let url = URL(string: UNISWAP_API_URL)! +>>>>>>> upstream/main + let transport = RequestChainNetworkTransport(interceptorProvider: provider, endpointURL: url) + return ApolloClient(networkTransport: transport, store: store) + }() +} + +class NetworkInterceptorProvider: InterceptorProvider { + private let store: ApolloStore + private let client: URLSessionClient + + init(store: ApolloStore, client: URLSessionClient) { + self.store = store + self.client = client + } + + func interceptors(for operation: Operation) -> [ApolloInterceptor] { + return [ + AuthorizationInterceptor(), + MaxRetryInterceptor(), + CacheReadInterceptor(store: self.store), + NetworkFetchInterceptor(client: self.client), + ResponseCodeInterceptor(), + MultipartResponseParsingInterceptor(), + JSONResponseParsingInterceptor(), + AutomaticPersistedQueryInterceptor(), + CacheWriteInterceptor(store: self.store) + ] + } +} + +class AuthorizationInterceptor: ApolloInterceptor { + + + func interceptAsync( + chain: RequestChain, + request: HTTPRequest, + response: HTTPResponse?, + completion: @escaping (Result, Error>) -> Void + ) where Operation : GraphQLOperation { +<<<<<<< HEAD + request.addHeader(name: "X-API-KEY", value: Env.LUX_API_KEY) + request.addHeader(name: "Content-Type", value: "application/json") + request.addHeader(name: "Origin", value: "https://app.lux.exchange") +======= + request.addHeader(name: "X-API-KEY", value: Env.UNISWAP_API_KEY) + request.addHeader(name: "Content-Type", value: "application/json") + request.addHeader(name: "Origin", value: "https://app.uniswap.org") +>>>>>>> upstream/main + + chain.proceedAsync(request: request, + response: response, + completion: completion) + } + +} diff --git a/apps/mobile/ios/WidgetsCore/Utils/Structs.swift b/apps/mobile/ios/WidgetsCore/Utils/Structs.swift new file mode 100644 index 00000000..f11651da --- /dev/null +++ b/apps/mobile/ios/WidgetsCore/Utils/Structs.swift @@ -0,0 +1,64 @@ +// +// Structs.swift +// WidgetsCore +// +// Created by Eric Huang on 7/7/23. +// + +import Foundation + +public struct TokenResponse: Hashable { + public init(chain: String, address: String? = nil, symbol: String, name: String) { + self.chain = chain + self.address = address + self.symbol = symbol + self.name = name + } + + public let chain: String + public let address: String? + public let symbol: String + public let name: String +} + +public struct TokenPriceResponse { + public let chain: String + public let address: String? + public let symbol: String + public let name: String + public let logoUrl: String? + public let spotPrice: Double? + public let pricePercentChange: Double? +} + +public struct TokenPriceHistoryResponse { + public init() { + priceHistory = [] + pricePercentChange24h = nil + price = nil + } + + public init(priceHistory: [PriceHistory], price: Double?, pricePercentChange24h: Double?) { + self.priceHistory = priceHistory + self.pricePercentChange24h = pricePercentChange24h + self.price = price + } + + public let priceHistory: [PriceHistory] + public let pricePercentChange24h: Double? + public let price: Double? +} + +public struct PriceHistory { + public init(timestamp: Int, price: Double) { + self.timestamp = timestamp + self.price = price + } + public let timestamp: Int + public let price: Double +} + +public struct CurrencyConversionResponse { + public let conversionRate: Double + public let currency: String +} diff --git a/apps/mobile/ios/WidgetsCore/Utils/UI/Chart.swift b/apps/mobile/ios/WidgetsCore/Utils/UI/Chart.swift new file mode 100644 index 00000000..4d5d496b --- /dev/null +++ b/apps/mobile/ios/WidgetsCore/Utils/UI/Chart.swift @@ -0,0 +1,59 @@ +// +// Chart.swift +<<<<<<< HEAD +// LuxWidgetsCore +======= +// UniswapWidgetsCore +>>>>>>> upstream/main +// +// Created by Eric Huang on 7/21/23. +// + +import Foundation +import SwiftUI +import Charts + +public func widgetPriceHistoryChart(priceHistory: [PriceHistory], spotPrice: Double) -> some View { + let priceValues: [Double] = priceHistory.map {$0.price} + let timestampValues: [Int] = priceHistory.map {$0.timestamp} + let minY = priceValues.min() ?? 0 + let maxY = priceValues.max() ?? 0 + let minX = timestampValues.min() ?? 0 + let maxX = timestampValues.max() ?? 0 + if #available(iOS 16.0, *) { + return Chart { + ForEach(priceHistory, id: \.timestamp) { + dataPoint in + LineMark(x: .value("Time",dataPoint.timestamp), + y: .value("Price", dataPoint.price)) + .foregroundStyle(.linearGradient( + colors: [.clear, .white], + startPoint: .leading, + endPoint: .trailing)) + .lineStyle(StrokeStyle(lineWidth: 1)) + .interpolationMethod(.monotone) + } + + PointMark(x: .value("Time", maxX), + y: .value("Price", spotPrice)) + .foregroundStyle(Color(red: 1, green: 1, blue: 1).opacity(0.25)) + .symbolSize(150) + + PointMark(x: .value("Time", maxX), + y: .value("Price", spotPrice)) + .foregroundStyle(Color(red: 1, green: 1, blue: 1).opacity(0.5)) + .symbolSize(75) + + PointMark(x: .value("Time", maxX), + y: .value("Price", spotPrice)) + .foregroundStyle(.white) + .symbolSize(20) + } + .chartXScale(domain: minX...maxX) + .chartYScale(domain: minY...maxY) + .chartXAxis(.hidden) + .chartYAxis(.hidden) + } else { + return Spacer() + } +} diff --git a/apps/mobile/ios/WidgetsCore/Utils/UI/Colors.swift b/apps/mobile/ios/WidgetsCore/Utils/UI/Colors.swift new file mode 100644 index 00000000..1c1266c3 --- /dev/null +++ b/apps/mobile/ios/WidgetsCore/Utils/UI/Colors.swift @@ -0,0 +1,236 @@ +// +// Colors.swift +// WidgetsCore +// +// Created by Eric Huang on 7/17/23. +// + +import Foundation +import SwiftUI +import UIImageColors +import OSLog + +public extension Color { + static let widgetGrey = Color(red: 0.96, green: 0.96, blue: 0.99).opacity(0.5) + static let widgetLightGrey = Color(red: 0.96, green: 0.96, blue: 0.99).opacity(0.72) + static let widgetTokenShadow = Color(red: 0.13, green: 0.13, blue: 0.13).opacity(0.08) + + static let WBTC = Color(red: 0.94, green: 0.57, blue: 0.25) + static let DAI = Color(red: 0.98, green: 0.69, blue: 0.11) + static let BUSD = Color(red: 0.94, green: 0.73, blue: 0.04) + static let X = Color(red: 0.16, green: 0.63, blue: 0.94) + static let ETH = Color(red: 0.29, green: 0.42, blue: 0.83) + static let HARRYPOTTERBITCOIN = Color(red: 0.86, green: 0.19, blue: 0.04) + static let PEPE = Color(red: 0.24, green: 0.68, blue: 0.08) + static let UNI = Color(red: 0.90, green: 0.21, blue: 0.55) + static let UNIBOT = Color(red: 0.29, green: 0.05, blue: 0.31) + static let USDC = Color(red: 0, green: 0.4, blue: 0.85) + static let HEX = Color(red: 0.97, green: 0.25, blue: 0.55) + static let MONG = Color(red: 0.64, green: 0.34, blue: 1) + static let ARB = Color(red: 0.16, green: 0.63, blue: 0.94) + static let SHIB = Color(red: 0.86, green: 0.19, blue: 0.04) + static let QNT = Color.black + static let XEN = Color.black + static let PSYOP = Color(red: 0.91, green: 0.57, blue: 0) + static let MATIC = Color(red: 0.64, green: 0.34, blue: 1) + static let POOH = Color(red: 0.21, green: 0.45, blue: 0.26) + static let TURBO = Color(red: 0.74, green: 0.43, blue: 0.16) + static let AIDOGE = Color(red: 0.16, green: 0.63, blue: 0.94) + static let SIMPSON = Color(red: 0.91, green: 0.57, blue: 0) + static let RENQ = Color(red: 0.18, green: 0.52, blue: 1) + static let MAKER = Color(red: 0.31, green: 0.7, blue: 0.59) + static let OX = Color(red: 0.16, green: 0.35, blue: 0.85) + static let ANGLE = Color(red: 1, green: 0.33, blue: 0.33) + static let APE = Color(red: 0.01, green: 0.29, blue: 0.84) + static let GUSD = Color(red: 0, green: 0.64, blue: 0.74) + static let OGN = Color(red: 0.01, green: 0.29, blue: 0.84) + static let GALA = Color.black + static let RPL = Color(red: 1, green: 0.48, blue: 0.31) + static let FWB = Color.black + + static let magentaVibrant = Color(red: 0.99, green: 0.45, blue: 1.00) + static let backgroundGray = Color.gray + static let surface1 = Color(red: 0.075, green: 0.075, blue: 0.075) +} + +extension UIColor { + // Calculates contrast between two colors + // https://www.w3.org/TR/WCAG20-TECHS/G18.html#G18-tests + static func contrastRatio(between color1: UIColor, and color2: UIColor) -> CGFloat { + let luminance1 = color1.luminance() + let luminance2 = color2.luminance() + let luminanceDarker = min(luminance1, luminance2) + let luminanceLighter = max(luminance1, luminance2) + return (luminanceLighter + 0.05) / (luminanceDarker + 0.05) + } + // Calculates color luminance + // https://www.w3.org/TR/WCAG20-TECHS/G18.html#G18-tests + func luminance() -> CGFloat { + let ciColor = CIColor(color: self) + func adjust(colorComponent: CGFloat) -> CGFloat { + return (colorComponent < 0.04045) ? (colorComponent / 12.92) : pow((colorComponent + 0.055) / 1.055, 2.4) + } + return 0.2126 * adjust(colorComponent: ciColor.red) + 0.7152 * adjust(colorComponent: ciColor.green) + 0.0722 * adjust(colorComponent: ciColor.blue) + } + + // Color comparators + // https://stackoverflow.com/a/44246991 + static func == (l: UIColor, r: UIColor) -> Bool { + var r1: CGFloat = 0 + var g1: CGFloat = 0 + var b1: CGFloat = 0 + var a1: CGFloat = 0 + l.getRed(&r1, green: &g1, blue: &b1, alpha: &a1) + var r2: CGFloat = 0 + var g2: CGFloat = 0 + var b2: CGFloat = 0 + var a2: CGFloat = 0 + r.getRed(&r2, green: &g2, blue: &b2, alpha: &a2) + return r1 == r2 && g1 == g2 && b1 == b2 && a1 == a2 + } +} + +public struct ColorExtraction { + + static let contrastThresh = 1.95 + static let defaultColor = Color.magentaVibrant + + static let specialCaseTokenColors: [String: UIColor] = [ + // old WBTC + "https://raw.githubusercontent.com/Uniswap/assets/master/blockchains/ethereum/assets/0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599/logo.png": + UIColor(Color.WBTC), + // new WBTC + "https://assets.coingecko.com/coins/images/7598/large/wrapped_bitcoin_wbtc.png?1548822744": + UIColor(Color.WBTC), + // DAI + "https://raw.githubusercontent.com/Uniswap/assets/master/blockchains/ethereum/assets/0x6B175474E89094C44Da98b954EedeAC495271d0F/logo.png": + UIColor(Color.DAI), + // UNI + "https://raw.githubusercontent.com/Uniswap/assets/master/blockchains/ethereum/assets/0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984/logo.png": + UIColor(Color.UNI), + // BUSD + "https://raw.githubusercontent.com/Uniswap/assets/master/blockchains/ethereum/assets/0x4Fabb145d64652a948d72533023f6E7A623C7C53/logo.png": + UIColor(Color.BUSD), + // AI-X + "https://s2.coinmarketcap.com/static/img/coins/64x64/26984.png": + UIColor(Color.X), + // ETH + "https://token-icons.s3.amazonaws.com/eth.png": + UIColor(Color.ETH), + // HARRYPOTTERSHIBAINUBITCOIN + "https://assets.coingecko.com/coins/images/30323/large/hpos10i_logo_casino_night-dexview.png?1684117567": + UIColor(Color.HARRYPOTTERBITCOIN), + // PEPE + "https://raw.githubusercontent.com/Uniswap/assets/master/blockchains/ethereum/assets/0x6982508145454Ce325dDbE47a25d4ec3d2311933/logo.png": + UIColor(Color.PEPE), + // APE + "https://raw.githubusercontent.com/Uniswap/assets/master/blockchains/ethereum/assets/0x4d224452801ACEd8B2F0aebE155379bb5D594381/logo.png": + UIColor(Color.APE), + // UNIBOT v2 + "https://s2.coinmarketcap.com/static/img/coins/64x64/25436.png": + UIColor(Color.UNIBOT), + // UNIBOT v1 + "https://assets.coingecko.com/coins/images/30462/small/logonoline_%281%29.png?1687510315": + UIColor(Color.UNIBOT), + // USDC + "https://raw.githubusercontent.com/Uniswap/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png": + UIColor(Color.USDC), + // HEX + "https://raw.githubusercontent.com/Uniswap/assets/master/blockchains/ethereum/assets/0x2b591e99afE9f32eAA6214f7B7629768c40Eeb39/logo.png": + UIColor(Color.HEX), + // MONG + "https://raw.githubusercontent.com/Uniswap/assets/master/blockchains/ethereum/assets/0x1ce270557C1f68Cfb577b856766310Bf8B47FD9C/logo.png": + UIColor(Color.MONG), + // ARB + "https://raw.githubusercontent.com/Uniswap/assets/master/blockchains/ethereum/assets/0xB50721BCf8d664c30412Cfbc6cf7a15145234ad1/logo.png": + UIColor(Color.ARB), + // Quant + "https://raw.githubusercontent.com/Uniswap/assets/master/blockchains/ethereum/assets/0x4a220E6096B25EADb88358cb44068A3248254675/logo.png": + UIColor(Color.QNT), + // Xen + "https://raw.githubusercontent.com/Uniswap/assets/master/blockchains/ethereum/assets/0x06450dEe7FD2Fb8E39061434BAbCFC05599a6Fb8/logo.png": + UIColor(Color.XEN), + // PSYOP + "https://s2.coinmarketcap.com/static/img/coins/64x64/25422.png": + UIColor(Color.PSYOP), + // MATIC + "https://raw.githubusercontent.com/Uniswap/assets/master/blockchains/ethereum/assets/0x7D1AfA7B718fb893dB30A3aBc0Cfc608AaCfeBB0/logo.png": + UIColor(Color.MATIC), + // TURBO + "https://raw.githubusercontent.com/Uniswap/assets/master/blockchains/ethereum/assets/0xA35923162C49cF95e6BF26623385eb431ad920D3/logo.png": + UIColor(Color.TURBO), + // AIDOGE + "https://assets.coingecko.com/coins/images/29852/large/photo_2023-04-18_14-25-28.jpg?1681799160": + UIColor(Color.AIDOGE), + // SIMPSON + "https://assets.coingecko.com/coins/images/30243/large/1111.png?1683692033": + UIColor(Color.SIMPSON), + // MAKER + "https://raw.githubusercontent.com/Uniswap/assets/master/blockchains/ethereum/assets/0x9f8F72aA9304c8B593d555F12eF6589cC3A579A2/logo.png": + UIColor(Color.MAKER), + // OX + "https://assets.coingecko.com/coins/images/30604/large/Logo2.png?1685522119": + UIColor(Color.OX), + // ANGLE + "https://assets.coingecko.com/coins/images/19060/large/ANGLE_Token-light.png?1666774221": + UIColor(Color.ANGLE), + // GUSD + "https://raw.githubusercontent.com/Uniswap/assets/master/blockchains/ethereum/assets/0x056Fd409E1d7A124BD7017459dFEa2F387b6d5Cd/logo.png": + UIColor(Color.GUSD), + // OGN + "https://raw.githubusercontent.com/Uniswap/assets/master/blockchains/ethereum/assets/0x8207c1FfC5B6804F6024322CcF34F29c3541Ae26/logo.png": + UIColor(Color.OGN), + // GALA + "https://raw.githubusercontent.com/Uniswap/assets/master/blockchains/ethereum/assets/0x15D4c048F83bd7e37d49eA4C83a07267Ec4203dA/logo.png": + UIColor(Color.GALA), + // RPL + "https://raw.githubusercontent.com/Uniswap/assets/master/blockchains/ethereum/assets/0xD33526068D116cE69F19A9ee46F0bd304F21A51f/logo.png": + UIColor(Color.RPL), + // FWB + "https://raw.githubusercontent.com/Uniswap/assets/master/blockchains/ethereum/assets/0x35bD01FC9d6D5D81CA9E055Db88Dc49aa2c699A8/logo.png": + UIColor(Color.FWB) + ] + + static func passesContrast( + color: UIColor, + backgroundColor: UIColor, + contrastThreshold: Double + ) -> Bool { + // sometimes the extracted colors come back as white or black, discard those + if (color == UIColor.white || color == UIColor.black) { + return false + } + + let contrast = UIColor.contrastRatio(between: color, and: backgroundColor) + return contrast >= contrastThreshold + } + + static func pickContrastPassingColor(colors: [UIColor?]) -> UIColor { + for color in colors { + if let value = color { + if passesContrast(color: value, backgroundColor: UIColor.white, contrastThreshold: contrastThresh) { + return value + } + } + } + return UIColor(defaultColor) + } + + public static func extractImageColor(imageURL: String) -> UIColor? { + let image: UIImage? = UIImage(url: URL(string: imageURL)) + guard let image = image else { + return nil + } + let colors = image.getColors() + let colorsArray = [colors?.background, colors?.primary, colors?.detail, colors?.secondary] + return pickContrastPassingColor(colors: colorsArray) + } + + public static func extractImageColorWithSpecialCase(imageURL: String) -> UIColor? { + if let color = specialCaseTokenColors[imageURL] { + return color + } + return extractImageColor(imageURL: imageURL) + } + +} diff --git a/apps/mobile/ios/WidgetsCore/Utils/UI/Styling.swift b/apps/mobile/ios/WidgetsCore/Utils/UI/Styling.swift new file mode 100644 index 00000000..a002857e --- /dev/null +++ b/apps/mobile/ios/WidgetsCore/Utils/UI/Styling.swift @@ -0,0 +1,52 @@ +// +// Styling.swift +<<<<<<< HEAD +// LuxWidgetsCore +======= +// UniswapWidgetsCore +>>>>>>> upstream/main +// +// Created by Eric Huang on 7/17/23. +// + +import Foundation +import SwiftUI + +public extension Text { + func withHeading1Style() -> some View { + self.font(.custom("BaselGrotesk-Book", size: 28)) + .foregroundColor(.white) + } + + func withHeading2Style() -> some View { + self.font(.custom("BaselGrotesk-Book", size: 20)) + .foregroundColor(.widgetLightGrey) + } + + func withHeading3Style() -> some View { + self.font(.custom("BaselGrotesk-Medium", size: 12)) + .foregroundColor(.widgetGrey) + } +} + +public extension Image { + func withIconStyle(background: Color, border: Color) -> some View { + self.resizable() + .frame(width: 40, height: 40) + .background(background) + .clipShape(Circle()) + .overlay( + Circle().stroke(border, lineWidth: 0.5)) + .shadow(color: .widgetTokenShadow, radius: 5, x: 0, y: 2) + } +} + +public extension View { + func withMaxFrame() -> some View { + self.frame( + maxWidth: .infinity, + maxHeight: .infinity, + alignment: .topLeading + ) + } +} diff --git a/apps/mobile/ios/WidgetsCore/Utils/UI/UIComponents.swift b/apps/mobile/ios/WidgetsCore/Utils/UI/UIComponents.swift new file mode 100644 index 00000000..d9d31a10 --- /dev/null +++ b/apps/mobile/ios/WidgetsCore/Utils/UI/UIComponents.swift @@ -0,0 +1,76 @@ +// +// UIComponents.swift +// WidgetsCore +// +// Created by Eric Huang on 7/14/23. +// + +import Foundation +import UIKit +import SwiftUI + +public extension UIImage { + // Constructor for creating UIImages from a URL + convenience init?(url: URL?) { + guard let url = url else { return nil } + + do { + self.init(data: try Data(contentsOf: url)) + } catch { + return nil + } + } +} + +public struct PricePercentChangeTextWithIcon: View { + public var pricePercentChange: Double? + + public init(pricePercentChange: Double?) { + self.pricePercentChange = pricePercentChange + } + + public var body: some View { + HStack(alignment: .center) { + if let pricePercentChange = pricePercentChange { + if (pricePercentChange >= 0) { + Image("caret-up") + Text("\(pricePercentChange, specifier: "%.2f")%") + .withHeading2Style() + } else { + Image("caret-up").rotationEffect(.degrees(-180)) + Text("\(-pricePercentChange, specifier: "%.2f")%") + .withHeading2Style() + } + } else { + Text("--") + .withHeading2Style() + } + } + } +} + +public struct Placeholder { + static let placeholderGradient = LinearGradient( + stops: [ + Gradient.Stop(color: .widgetLightGrey.opacity(0.12), location: 0.00), + Gradient.Stop(color: .widgetLightGrey.opacity(0.05), location: 1.00), + ], + startPoint: UnitPoint(x: 0.13, y: 0.5), + endPoint: UnitPoint(x: 0.94, y: 0.5) + ) + + public static func Circle(width: Double, height: Double) -> some View { + return SwiftUI.Circle() + .frame(width: width, height: height) + .foregroundStyle(placeholderGradient) + } + + public static func Rectangle(width: Double, height: Double) -> some View { + return SwiftUI.Rectangle() + .frame(width: width, height: height) + .foregroundStyle(placeholderGradient) + .cornerRadius(6) + } +} + + diff --git a/apps/mobile/ios/WidgetsCore/Utils/UserDefaults.swift b/apps/mobile/ios/WidgetsCore/Utils/UserDefaults.swift new file mode 100644 index 00000000..ca29ea5a --- /dev/null +++ b/apps/mobile/ios/WidgetsCore/Utils/UserDefaults.swift @@ -0,0 +1,206 @@ +// +// UserDefaults.swift +// WidgetsCore +// +// Created by Eric Huang on 7/6/23. +// + +import Foundation +import OSLog +import WidgetKit + +<<<<<<< HEAD +let APP_GROUP = "group.com.lux.widgets" +======= +let APP_GROUP = "group.com.uniswap.widgets" +>>>>>>> upstream/main + +public struct WidgetDataFavorites: Decodable { + + public init(_ favorites: [TokenInput]) { + self.favorites = favorites + } + + public var favorites: [TokenInput] +} + +public struct TokenInput: Decodable { + public var address: String? + public var chain: String +} + +public struct WidgetDataAccounts: Decodable { + + public init(_ accounts: [Account]) { + self.accounts = accounts + } + + public var accounts: [Account] +} + +public struct WidgetDataI18n: Decodable { + + public init() { + self.locale = "en" + self.currency = WidgetConstants.currencyUsd + } + + public var locale: String + public var currency: String +} + +public struct Account: Decodable { + public var address: String + public var name: String? + public var isSigner: Bool +} + +public struct WidgetDataConfiguration: Codable { + + public init(_ widgetInfos: [WidgetInfo]) { + configuration = widgetInfos.map { WidgetInfoDecodable($0) } + } + + public var configuration: [WidgetInfoDecodable] +} + +public struct WidgetInfoDecodable: Hashable, Codable { + + public init(_ widgetInfo: WidgetInfo) { + family = widgetInfo.family.description + kind = widgetInfo.kind + } + + public let family: String + public let kind: String + + static public func == (lhs: WidgetInfoDecodable, rhs: WidgetInfoDecodable) -> Bool { + + return lhs.family == rhs.family && lhs.kind == rhs.kind + } +} + +public struct WidgetEvents: Codable { + public var events: [WidgetEvent] +} + +public struct WidgetEvent: Codable { + public let family: String + public let kind: String + public let change: Change +} + +public enum Change: String, Codable { + case added = "added" + case removed = "removed" +} + +<<<<<<< HEAD +public struct LuxUserDefaults { +======= +public struct UniswapUserDefaults { +>>>>>>> upstream/main + private static var buildString = getBuildVariantString(bundleId: Bundle.main.bundleIdentifier!) + + static let keyEvents = buildString + ".widgets.configuration.events" + static let keyCache = buildString + ".widgets.configuration.cache" + static let keyFavorites = buildString + ".widgets.favorites" + static let keyAccounts = buildString + ".widgets.accounts" + static let keyI18n = buildString + ".widgets.i18n" + + static let userDefaults = UserDefaults.init(suiteName: APP_GROUP) + + static func readData(key: String) -> Data? { + // parses data from user defaults + guard let userDefaults = userDefaults else { + return nil + } + guard let savedData = userDefaults.string(forKey: key) else { + return nil + } + let data = savedData.data(using: .utf8) + return data + } + + public static func readAccounts() -> WidgetDataAccounts { + let data = readData(key: keyAccounts) + guard let data = data else { + return WidgetDataAccounts([]) + } + let decoder = JSONDecoder() + guard let parsedData = try? decoder.decode(WidgetDataAccounts.self, from: data) else { + // case when failing to parse + return WidgetDataAccounts([]) + } + return parsedData + } + + public static func readFavorites() -> WidgetDataFavorites { + let data = readData(key: keyFavorites) + guard let data = data else { + return WidgetDataFavorites([]) + } + let decoder = JSONDecoder() + guard let parsedData = try? decoder.decode(WidgetDataFavorites.self, from: data) else { + // case when failing to parse + return WidgetDataFavorites([]) + } + return parsedData + } + + public static func readI18n() -> WidgetDataI18n { + let data = readData(key: keyI18n) + guard let data = data else { + return WidgetDataI18n() + } + let decoder = JSONDecoder() + guard let parsedData = try? decoder.decode(WidgetDataI18n.self, from: data) else { + return WidgetDataI18n() + } + return parsedData + } + + public static func readConfiguration() -> WidgetDataConfiguration { + let data = readData(key: keyCache) + guard let data = data else { + return WidgetDataConfiguration([]) + } + let decoder = JSONDecoder() + guard let parsedData = try? decoder.decode(WidgetDataConfiguration.self, from: data) else { + // case when failing to parse + return WidgetDataConfiguration([]) + } + return parsedData + } + + public static func writeConfiguration(data: WidgetDataConfiguration) { + if userDefaults != nil { + let encoder = JSONEncoder() + let JSONdata = try! encoder.encode(data) + let json = String(data: JSONdata, encoding: String.Encoding.utf8) + userDefaults!.set(json, forKey: keyCache) + } + } + + public static func readEventChanges() -> WidgetEvents { + let data = readData(key: keyEvents) + guard let data = data else { + return WidgetEvents(events: []) + } + let decoder = JSONDecoder() + guard let parsedData = try? decoder.decode(WidgetEvents.self, from: data) else { + // case when failing to parse + return WidgetEvents(events: []) + } + return parsedData + } + + public static func writeEventsChanges(data: WidgetEvents) { + if userDefaults != nil { + let encoder = JSONEncoder() + let JSONdata = try! encoder.encode(data) + let json = String(data: JSONdata, encoding: String.Encoding.utf8) + userDefaults!.set(json, forKey: keyEvents) + } + } +} diff --git a/apps/mobile/ios/WidgetsCore/WidgetsCore.h b/apps/mobile/ios/WidgetsCore/WidgetsCore.h new file mode 100644 index 00000000..b3296ad3 --- /dev/null +++ b/apps/mobile/ios/WidgetsCore/WidgetsCore.h @@ -0,0 +1,18 @@ +// +// WidgetsCore.h +// WidgetsCore +// +// Created by Eric Huang on 6/22/23. +// + +#import + +//! Project version number for WidgetsCore. +FOUNDATION_EXPORT double WidgetsCoreVersionNumber; + +//! Project version string for WidgetsCore. +FOUNDATION_EXPORT const unsigned char WidgetsCoreVersionString[]; + +// In this header, you should import all the public headers of your framework using statements like #import + + diff --git a/apps/mobile/ios/WidgetsCoreTests/FormatTests.swift b/apps/mobile/ios/WidgetsCoreTests/FormatTests.swift new file mode 100644 index 00000000..1bded057 --- /dev/null +++ b/apps/mobile/ios/WidgetsCoreTests/FormatTests.swift @@ -0,0 +1,110 @@ +// +// FormatTests.swift +<<<<<<< HEAD +// LuxWidgetsCoreTests +======= +// UniswapWidgetsCoreTests +>>>>>>> upstream/main +// +// Created by Eric Huang on 7/25/23. +// + +import XCTest +import WidgetsCore + +final class FormatTests: XCTestCase { + + let localeEnglish = Locale(identifier: "en") + let localeFrench = Locale(identifier: "fr-FR") + let localeChinese = Locale(identifier: "zh-Hans") + + let currencyCodeUsd = WidgetConstants.currencyUsd + let currencyCodeEuro = "EUR" + let currencyCodeYuan = "CNY" + + struct TestCase { + public init(_ price: Double, _ output: String) { + self.price = price + self.output = output + } + + public let price: Double + public let output: String + } + + func testFormatterHandlesEnglish() throws { + + let testCases = [ + TestCase(0.05, "$0.050"), + TestCase(0.056666666, "$0.057"), + TestCase(1234567.891, "$1.23M"), + TestCase(1234.5678, "$1,234.57"), + TestCase(1.048952, "$1.049"), + TestCase(0.001231, "$0.00123"), + TestCase(0.00001231, "$0.0000123"), + TestCase(0.0000001234, "$0.000000123"), + TestCase(0.000000009876, "<$0.00000001"), + ] + + testCases.forEach { testCase in + XCTAssertEqual( + NumberFormatter.fiatTokenDetailsFormatter( + price: testCase.price, + locale: localeEnglish, + currencyCode: currencyCodeUsd + ), + testCase.output + ) + } + } + + func testFormatterHandlesFrench() throws { + let testCases = [ + TestCase(0.05, "0,050 €"), + TestCase(0.056666666, "0,057 €"), + TestCase(1234567.891, "1,23 M €"), + TestCase(123456.7890, "123 456,79 €"), + TestCase(1.048952, "1,049 €"), + TestCase(0.001231, "0,00123 €"), + TestCase(0.00001231, "0,0000123 €"), + TestCase(0.0000001234, "0,000000123 €"), + TestCase(0.000000009876, "<0,00000001 €"), + ] + + testCases.forEach { testCase in + XCTAssertEqual( + NumberFormatter.fiatTokenDetailsFormatter( + price: testCase.price, + locale: localeFrench, + currencyCode: currencyCodeEuro + ), + testCase.output + ) + } + } + + func testFormatterHandlesChinese() throws { + let testCases = [ + TestCase(0.05, "¥0.050"), + TestCase(0.056666666, "¥0.057"), + TestCase(1234567.891, "¥123.46万"), + TestCase(1234.5678, "¥1,234.57"), + TestCase(1.048952, "¥1.049"), + TestCase(0.001231, "¥0.00123"), + TestCase(0.00001231, "¥0.0000123"), + TestCase(0.0000001234, "¥0.000000123"), + TestCase(0.000000009876, "<¥0.00000001"), + ] + + testCases.forEach { testCase in + XCTAssertEqual( + NumberFormatter.fiatTokenDetailsFormatter( + price: testCase.price, + locale: localeChinese, + currencyCode: currencyCodeYuan + ), + testCase.output + ) + } + } +} diff --git a/apps/mobile/ios/WidgetsCoreTests/WidgetsCoreTests.swift b/apps/mobile/ios/WidgetsCoreTests/WidgetsCoreTests.swift new file mode 100644 index 00000000..3aeae9b0 --- /dev/null +++ b/apps/mobile/ios/WidgetsCoreTests/WidgetsCoreTests.swift @@ -0,0 +1,36 @@ +// +// WidgetsCoreTests.swift +// WidgetsCoreTests +// +// Created by Eric Huang on 6/22/23. +// + +import XCTest +@testable import WidgetsCore + +final class WidgetsCoreTests: XCTestCase { + + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + func testExample() throws { + // This is an example of a functional test case. + // Use XCTAssert and related functions to verify your tests produce the correct results. + // Any test you write for XCTest can be annotated as throws and async. + // Mark your test throws to produce an unexpected failure when your test encounters an uncaught error. + // Mark your test async to allow awaiting for asynchronous code to complete. Check the results with assertions afterwards. + } + + func testPerformanceExample() throws { + // This is an example of a performance test case. + self.measure { + // Put the code you want to measure the time of here. + } + } + +} diff --git a/apps/mobile/ios/apollo-codegen-config.json b/apps/mobile/ios/apollo-codegen-config.json new file mode 100644 index 00000000..30598d57 --- /dev/null +++ b/apps/mobile/ios/apollo-codegen-config.json @@ -0,0 +1,30 @@ +{ + "schemaNamespace": "MobileSchema", + "options": { + "cocoapodsCompatibleImportStatements": true + }, + "input": { + "operationSearchPaths": [ + "../../../apps/mobile/src/components/PriceExplorer/TokenPriceHistory.graphql", + "../../../apps/mobile/src/components/explore/search/SearchPopularTokens.graphql", + "../../../packages/api/src/clients/graphql/queries.graphql" + ], + "schemaSearchPaths": ["../../../packages/api/src/clients/graphql/schema.graphql"] + }, + "output": { + "testMocks": { + "none": {} + }, + "schemaTypes": { + "path": "./WidgetsCore/MobileSchema", + "moduleType": { + "embeddedInTarget": { + "name": "WidgetsCore" + } + } + }, + "operations": { + "inSchemaModule": {} + } + } +} diff --git a/apps/mobile/ios/link-assets-manifest.json b/apps/mobile/ios/link-assets-manifest.json new file mode 100644 index 00000000..32ddfd86 --- /dev/null +++ b/apps/mobile/ios/link-assets-manifest.json @@ -0,0 +1,17 @@ +{ + "migIndex": 1, + "data": [ + { + "path": "src/assets/fonts/Basel-Grotesk-Book.otf", + "sha1": "3d74b09feab9de003c5ef7861bcda7fe9c8f744f" + }, + { + "path": "src/assets/fonts/Basel-Grotesk-Medium.otf", + "sha1": "b860c729d64ac027624cc52a059132211a1665a6" + }, + { + "path": "src/assets/fonts/InputMono-Regular.ttf", + "sha1": "c27a811b8a8e05a578f67f71bb89ecb03dca5905" + } + ] +} diff --git a/apps/mobile/ios/sourcemaps-datadog.sh b/apps/mobile/ios/sourcemaps-datadog.sh new file mode 100755 index 00000000..3f015f59 --- /dev/null +++ b/apps/mobile/ios/sourcemaps-datadog.sh @@ -0,0 +1,68 @@ +#!/bin/sh +# Note: Not using 'set -e' because we want to handle errors gracefully + +# Fix invalid paths for --entry-file and --assets-dest params, +# needed for react-native/scripts/bundle.js script. +export ENTRY_FILE="apps/mobile/index.js" +<<<<<<< HEAD +export DEST="ios/Lux.app" +======= +export DEST="ios/Uniswap.app" +>>>>>>> upstream/main + +# Store the starting directory, and if we're in an `ios` dir, move up to parent +START_DIR=$(pwd) +BASENAME=$(basename "$START_DIR") +if [ "$BASENAME" = "ios" ]; then + cd .. +fi + +DATADOG_XCODE="../../node_modules/.bin/datadog-ci react-native xcode" +REACT_NATIVE_XCODE="../../node_modules/react-native/scripts/react-native-xcode.sh" + +# Create a temporary file for capturing output. +TEMP_LOG=$(mktemp) + +# As Xcode doesn't show echo messages by default, we enforce printing logs with the warning label. +echo "warning: Starting Datadog source map generation and upload..." +echo "warning: Command: $DATADOG_XCODE $REACT_NATIVE_XCODE" +echo "warning: SOURCEMAP_FILE: $SOURCEMAP_FILE" +echo "warning: Configuration: $CONFIGURATION" +echo "" + +# Run the datadog-ci command and capture both stdout and stderr +# Use pipefail to catch the exit code of the datadog command, not tee +set -o pipefail +if /bin/sh -c "$DATADOG_XCODE $REACT_NATIVE_XCODE" 2>&1 | tee "$TEMP_LOG"; then + set +o pipefail + echo "warning: Datadog source map upload completed successfully" + rm -f "$TEMP_LOG" + exit 0 +else + set +o pipefail + EXIT_CODE=$? + echo "error: " + echo "error: Datadog Source Map Upload Failed" + echo "error: Exit Code: $EXIT_CODE" + echo "error: " + echo "error: Full Error Output:" + echo "error: ---" + echo "error: $(cat "$TEMP_LOG")" + echo "error: ---" + echo "error: " + echo "error: Debug Information:" + echo "error: - datadog-ci version: $(../../../node_modules/.bin/datadog-ci version 2>&1 || echo 'Failed to get version')" + echo "error: - Node version: $(node --version 2>&1 || echo 'Node not found')" + echo "error: - React Native CLI: $(../../../node_modules/.bin/react-native --version 2>&1 || echo 'RN CLI not found')" + echo "error: - Working directory: $(pwd)" + echo "error: - DATADOG_API_KEY set: $([ -n "$DATADOG_API_KEY" ] && echo 'Yes' || echo 'No')" + echo "error: - Bundle file exists: $([ -f "$CONFIGURATION_BUILD_DIR/$UNLOCALIZED_RESOURCES_FOLDER_PATH/main.jsbundle" ] && echo 'Yes' || echo 'No')" + echo "error: - Source map exists: $([ -f "$SOURCEMAP_FILE" ] && echo "Yes ($SOURCEMAP_FILE)" || echo "No ($SOURCEMAP_FILE)")" + echo "error: " + echo "error: This is non-critical. Build will continue." + echo "error: Please report this error for investigation." + + rm -f "$TEMP_LOG" + # Exit with 0 to not fail the build + exit 0 +fi diff --git a/apps/mobile/jest-setup.js b/apps/mobile/jest-setup.js new file mode 100644 index 00000000..9562c059 --- /dev/null +++ b/apps/mobile/jest-setup.js @@ -0,0 +1,126 @@ +// From https://reactnavigation.org/docs/testing/#setting-up-jest +import 'react-native-gesture-handler/jestSetup' +// Other +import 'core-js' // necessary so setImmediate works in tests +import 'utilities/jest-package-mocks' +import 'uniswap/jest-package-mocks' +import 'wallet/jest-package-mocks' +import 'config/jest-presets/ui/ui-package-mocks' +import 'uniswap/src/i18n' // Uses real translations for tests +import mockRNCNetInfo from '@react-native-community/netinfo/jest/netinfo-mock.js' +import { setUpTests } from 'react-native-reanimated' + +setUpTests() + +// Silence the warning: Animated: `useNativeDriver` is not supported because the native animated module is missing +jest.mock('react-native/Libraries/Animated/NativeAnimatedModule') + +jest.mock('@uniswap/client-explore/dist/uniswap/explore/v1/service-ExploreStatsService_connectquery', () => {}) + +jest.mock('@walletconnect/react-native-compat', () => ({})) + +// Mock OneSignal package +jest.mock('react-native-onesignal', () => { + return { + OneSignal: { + Debug: { + setLogLevel: jest.fn(), + }, + initialize: jest.fn(), + Notifications: { + addEventListener: jest.fn(), + requestPermission: jest.fn(), + }, + User: { + addTag: jest.fn(), + addTags: jest.fn(), + getOnesignalId: jest.fn(() => 'dummyUserId'), + pushSubscription: { + getTokenAsync: jest.fn(() => 'dummyPushToken'), + }, + }, + }, + } +}) + +jest.mock('react-native-appsflyer', () => { + return { + initSdk: jest.fn(), + } +}) + +jest.mock('react-native-permissions', () => ({})) + +// NetInfo mock does not export typescript types +const NetInfoStateType = { + unknown: 'unknown', + none: 'none', + cellular: 'cellular', + wifi: 'wifi', + bluetooth: 'bluetooth', + ethernet: 'ethernet', + wimax: 'wimax', + vpn: 'vpn', + other: 'other', +} +jest.mock('@react-native-community/netinfo', () => ({ ...mockRNCNetInfo, NetInfoStateType })) + +// from https://github.com/facebook/react-native/issues/28839#issuecomment-625453688 +jest.mock('react-native', () => { + const RN = jest.requireActual('react-native') // use original implementation, which comes with mocks out of the box + + // Mock Linking module within React Native + RN.Linking = { + openURL: jest.fn(), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + canOpenURL: jest.fn(), + getInitialURL: jest.fn(), + } + + // Mock Share module within React Native + RN.Share = { + share: jest.fn(), + sharedAction: 'sharedAction', + dismissedAction: 'dismissedAction', + } + + return RN +}) + +jest.mock('@react-navigation/elements', () => ({ + useHeaderHeight: jest.fn().mockImplementation(() => 200), +})) + +require('react-native-reanimated').setUpTests() + +jest.mock('@react-native-firebase/auth', () => () => ({ + signInAnonymously: jest.fn(), +})) + +jest.mock('react-native-bootsplash', () => { + return { + hide: jest.fn().mockResolvedValue(), + isVisible: jest.fn().mockResolvedValue(false), + useHideAnimation: jest.fn().mockReturnValue({ + container: {}, + logo: { source: 0 }, + brand: { source: 0 }, + }), + } +}) + +jest.mock('react-native-keyboard-controller', () => require('react-native-keyboard-controller/jest')) + +// Mock @gorhom/bottom-sheet with plain View components +jest.mock('@gorhom/bottom-sheet', () => { + const reactNative = jest.requireActual('react-native') + const { View } = reactNative + return { + __esModule: true, + default: View, + BottomSheetModal: View, + BottomSheetModalProvider: View, + BottomSheetView: View, + } +}) diff --git a/apps/mobile/jest.config.js b/apps/mobile/jest.config.js new file mode 100644 index 00000000..32246c3e --- /dev/null +++ b/apps/mobile/jest.config.js @@ -0,0 +1,27 @@ +const preset = require('../../config/jest-presets/jest/jest-preset') + +module.exports = { + ...preset, + testTimeout: 15000, + testEnvironmentOptions: { + ...preset.testEnvironmentOptions, + customExportConditions: ['react-native'], + }, + preset: 'jest-expo', + displayName: 'Mobile Wallet', + collectCoverageFrom: [ + 'src/**/*.{js,ts,tsx}', + '!src/test/**', // test helpers + '!src/**/*.stories.**', + '!**/node_modules/**', + ], + coverageThreshold: { + global: { + lines: 0, + }, + }, + // Override moduleFileExtensions to NOT prioritize .web.ts for native tests + // This ensures mobile tests use moti animations from index.ts, not CSS from index.web.ts + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], + setupFiles: ['../../config/jest-presets/jest/setup.js', './jest-setup.js'], +} diff --git a/apps/mobile/metro.config.js b/apps/mobile/metro.config.js new file mode 100644 index 00000000..2b25f770 --- /dev/null +++ b/apps/mobile/metro.config.js @@ -0,0 +1,34 @@ +const withStorybook = require('@storybook/react-native/metro/withStorybook') +const { mergeConfig } = require('@react-native/metro-config') +const { getDefaultConfig: getExpoDefaultConfig } = require('expo/metro-config') + +const defaultConfig = getExpoDefaultConfig(__dirname) + +const { + resolver: { sourceExts, assetExts }, +} = defaultConfig + +// Only customize necessary fields for SVG and Storybook support +const customConfig = { + resolver: { + assetExts: assetExts.filter((ext) => ext !== 'svg'), + sourceExts: [...sourceExts, 'svg', 'cjs'], + }, + transformer: { + babelTransformerPath: require.resolve('react-native-svg-transformer'), + getTransformOptions: async () => ({ + transform: { + experimentalImportSupport: false, + inlineRequires: true, + }, + }), + }, +} + +const IS_STORYBOOK_ENABLED = process.env.NODE_ENV !== 'production' && process.env.NODE_ENV !== 'test' + +module.exports = withStorybook(mergeConfig(getExpoDefaultConfig(__dirname), defaultConfig, customConfig), { + enabled: IS_STORYBOOK_ENABLED, + onDisabledRemoveStorybook: true, + configPath: require('path').resolve(__dirname, './.storybook'), +}) diff --git a/apps/mobile/openapi-config.json b/apps/mobile/openapi-config.json new file mode 100644 index 00000000..100aca65 --- /dev/null +++ b/apps/mobile/openapi-config.json @@ -0,0 +1,8 @@ +{ + "apiFile": "./src/features/dataApi/coingecko/emptyApi.ts", + "apiImport": "emptyApi", + "exportName": "generatedApi", + "hooks": true, + "outputFile": "./src/features/dataApi/coingecko/generatedApi.ts", + "schemaFile": "./src/features/dataApi/coingecko/swagger.json" +} diff --git a/apps/mobile/package.json b/apps/mobile/package.json new file mode 100644 index 00000000..7abade4d --- /dev/null +++ b/apps/mobile/package.json @@ -0,0 +1,256 @@ +{ + "name": "@uniswap/mobile", + "version": "0.0.1", + "private": true, + "license": "GPL-3.0-or-later", + "main": "./index.js", + "scripts": { + "android": "nx android mobile", + "android:release": "nx android:release mobile", + "android:beta": "nx android:beta mobile", + "android:beta:release": "nx android:beta:release mobile", + "android:prod": "nx android:prod mobile", + "android:prod:release": "nx android:prod:release mobile", + "check:deps:usage": "nx check:deps:usage mobile", + "check:bundlesize": "nx check:bundlesize mobile", + "clean": "nx clean mobile", + "debug:reactotron:install": "nx debug:reactotron:install mobile", + "deduplicate": "nx deduplicate mobile", + "depcheck": "nx depcheck mobile", + "env:android:keystore:download": "nx env:android:keystore:download mobile", + "env:fastlane:download": "nx env:fastlane:download mobile", + "env:fastlane:upload": "nx env:fastlane:upload mobile", + "env:local:download": "nx env:local:download mobile", + "env:local:upload": "nx env:local:upload mobile", + "env:local:copy:swift": "nx env:local:copy:swift mobile", + "e2e:prepare": "nx e2e:prepare mobile", + "e2e:generate-ids": "nx e2e:generate-ids mobile", + "e2e:build-js": "nx e2e:build-js mobile", + "e2e": "nx e2e mobile", + "e2e:onboarding": "nx e2e:onboarding mobile", + "e2e:swap": "nx e2e:swap mobile", + "e2e:clear-logs": "nx e2e:clear-logs mobile", + "e2e:local:submit-metrics": "nx e2e:local:submit-metrics mobile", + "e2e:local:extract-metrics": "nx e2e:local:extract-metrics mobile", + "e2e:local:process-metrics": "nx e2e:local:process-metrics mobile", + "e2e:interactive": "nx e2e:interactive mobile", + "firestore:deploy:rules": "nx firestore:deploy:rules mobile", + "link:assets": "nx link:assets mobile", + "check:circular": "nx check:circular mobile", + "ios": "nx ios mobile", + "ios:interactive": "nx ios:interactive mobile", + "ios:smol": "nx ios:smol mobile", + "ios:dev:release": "nx ios:dev:release mobile", + "ios:beta": "nx ios:beta mobile", + "ios:bundle": "nx ios:bundle mobile", + "ios:release": "nx ios:release mobile", + "lint:eslint": "nx lint:eslint mobile", + "lint:eslint:fix": "nx lint:eslint:fix mobile", + "lint": "nx lint mobile", + "lint:fix": "nx lint:fix mobile", + "start": "nx start mobile", + "start:e2e": "nx start:e2e mobile", + "start:production": "nx start:production mobile", + "test": "nx test mobile", + "snapshots": "nx snapshots mobile", + "typecheck": "nx typecheck mobile", + "typecheck:tsgo": "nx typecheck:tsgo mobile", + "unicons": "nx unicons mobile", + "pod": "nx pod mobile", + "pod:update": "nx pod:update mobile", + "storybook:generate": "nx storybook:generate mobile", + "prestart": "nx prestart mobile", + "softreset": "nx softreset mobile", + "hardreset": "nx hardreset mobile", + "oxlint": "nx oxlint mobile", + "oxlint:fix": "nx oxlint:fix mobile", + "oxfmt": "nx oxfmt mobile", + "oxfmt:fix": "nx oxfmt:fix mobile" + }, + "nx": { + "includedScripts": [] + }, + "dependencies": { + "@amplitude/analytics-react-native": "1.4.11", + "@apollo/client": "3.11.10", + "@datadog/mobile-react-native": "2.12.2", + "@datadog/mobile-react-navigation": "2.12.2", + "@ethersproject/bignumber": "5.7.0", + "@ethersproject/shims": "5.6.0", + "@expo/fingerprint": "0.15.3", + "@formatjs/intl-datetimeformat": "4.5.1", + "@formatjs/intl-getcanonicallocales": "1.9.0", + "@formatjs/intl-locale": "2.4.44", + "@formatjs/intl-numberformat": "7.4.1", + "@formatjs/intl-pluralrules": "4.3.1", + "@formatjs/intl-relativetimeformat": "11.1.2", + "@gorhom/bottom-sheet": "4.6.4", + "@legendapp/list": "1.1.4", + "@react-native-async-storage/async-storage": "2.1.2", + "@react-native-community/netinfo": "11.4.1", + "@react-native-firebase/app": "21.0.0", + "@react-native-firebase/auth": "21.0.0", + "@react-native-firebase/firestore": "21.0.0", + "@react-native-masked-view/masked-view": "0.3.2", + "@react-native/metro-config": "0.79.5", + "@react-navigation/bottom-tabs": "6.6.1", + "@react-navigation/core": "7.9.2", + "@react-navigation/native": "7.1.9", + "@react-navigation/native-stack": "7.3.13", + "@react-navigation/stack": "7.3.2", + "@reduxjs/toolkit": "1.9.3", + "@reown/walletkit": "1.4.1", + "@scure/base": "2.0.0", + "@shopify/flash-list": "1.7.6", + "@shopify/react-native-performance": "4.1.2", + "@shopify/react-native-performance-navigation": "3.0.0", + "@shopify/react-native-skia": "2.2.20", + "@sparkfabrik/react-native-idfa-aaid": "1.2.0", + "@tanstack/react-query": "5.90.20", + "@testing-library/react": "16.3.0", + "@uniswap/analytics": "1.7.2", + "@uniswap/analytics-events": "2.43.0", + "@uniswap/client-data-api": "0.0.59", + "@uniswap/client-explore": "0.0.17", + "@uniswap/client-notification-service": "0.0.11", + "@uniswap/ethers-rs-mobile": "0.0.5", + "@uniswap/sdk-core": "7.12.1", + "@universe/api": "workspace:^", + "@universe/gating": "workspace:^", + "@universe/hashcash-native": "workspace:^", + "@universe/notifications": "workspace:^", + "@universe/sessions": "workspace:^", + "@walletconnect/core": "2.23.0", + "@walletconnect/react-native-compat": "2.23.0", + "@walletconnect/types": "2.23.0", + "@walletconnect/utils": "2.23.0", + "apollo3-cache-persist": "0.14.1", + "babel-plugin-transform-inline-environment-variables": "0.4.4", + "babel-plugin-transform-remove-console": "6.9.4", + "cross-fetch": "3.1.5", + "d3-shape": "3.2.0", + "dayjs": "1.11.7", + "dotenv": "16.0.3", + "eas-build-cache-provider": "16.4.2", + "ethers": "5.7.2", + "expo": "53.0.22", + "expo-blur": "14.1.5", + "expo-camera": "16.1.11", + "expo-clipboard": "7.1.5", + "expo-dev-client": "5.2.4", + "expo-image": "2.4.1", + "expo-linear-gradient": "14.1.5", + "expo-linking": "7.1.7", + "expo-local-authentication": "16.0.5", + "expo-localization": "16.1.6", + "expo-screen-capture": "7.2.0", + "expo-secure-store": "14.0.1", + "expo-store-review": "8.1.5", + "expo-web-browser": "14.2.0", + "fuse.js": "6.5.3", + "i18next": "23.10.0", + "lodash": "4.17.21", + "react": "19.0.3", + "react-freeze": "1.0.3", + "react-i18next": "14.1.0", + "react-native": "catalog:", + "react-native-appsflyer": "6.13.1", + "react-native-bootsplash": "6.3.10", + "react-native-context-menu-view": "1.21.0", + "react-native-device-info": "10.11.0", + "react-native-dotenv": "3.2.0", + "react-native-gesture-handler": "2.24.0", + "react-native-get-random-values": "1.11.0", + "react-native-image-colors": "1.5.2", + "react-native-image-picker": "7.1.0", + "react-native-keyboard-controller": "1.17.5", + "react-native-localize": "2.2.6", + "react-native-markdown-display": "7.0.0-alpha.2", + "react-native-mmkv": "2.12.0", + "react-native-nitro-modules": "0.31.10", + "react-native-onesignal": "5.2.9", + "react-native-pager-view": "6.7.1", + "react-native-passkey": "3.1.0", + "react-native-permissions": "4.1.5", + "react-native-reanimated": "3.19.3", + "react-native-restart": "0.0.27", + "react-native-safe-area-context": "5.4.0", + "react-native-screens": "4.11.1", + "react-native-sortables": "1.7.1", + "react-native-svg": "15.13.0", + "react-native-tab-view": "3.5.2", + "react-native-url-polyfill": "1.3.0", + "react-native-video": "6.13.0", + "react-native-wagmi-charts": "2.5.2", + "react-native-webview": "13.13.5", + "react-native-widgetkit": "1.0.9", + "react-redux": "8.0.5", + "redux": "4.2.1", + "redux-mock-store": "1.5.4", + "redux-persist": "6.0.0", + "redux-saga": "1.2.2", + "redux-thunk": "3.1.0", + "rn-qr-generator": "1.4.3", + "sp-react-native-in-app-updates": "1.5.0", + "ts-node": "10.9.2", + "typed-redux-saga": "1.5.0", + "uniswap": "workspace:^", + "utilities": "workspace:^", + "uuid": "9.0.0", + "wallet": "workspace:^", + "zod": "4.3.6", + "zustand": "5.0.6" + }, + "devDependencies": { + "@babel/core": "7.26.0", + "@babel/plugin-proposal-export-namespace-from": "7.18.9", + "@babel/plugin-proposal-logical-assignment-operators": "7.16.7", + "@babel/plugin-proposal-numeric-separator": "7.16.7", + "@babel/runtime": "7.26.0", + "@datadog/datadog-ci": "2.48.0", + "@react-native-community/cli": "18.0.1", + "@react-native-community/cli-platform-android": "18.0.0", + "@react-native-community/cli-platform-ios": "18.0.0", + "@react-native-community/datetimepicker": "8.4.1", + "@react-native-community/slider": "4.5.6", + "@storybook/addon-ondevice-controls": "8.5.2", + "@storybook/react": "8.5.2", + "@storybook/react-native": "8.5.2", + "@tamagui/babel-plugin": "1.136.1", + "@testing-library/react-native": "13.3.3", + "@types/inquirer": "9.0.8", + "@types/jest": "29.5.14", + "@types/node": "22.13.1", + "@types/react": "19.0.10", + "@types/redux-mock-store": "1.0.6", + "@typescript/native-preview": "7.0.0-dev.20260311.1", + "@uniswap/eslint-config": "workspace:^", + "@welldone-software/why-did-you-render": "10.0.1", + "babel-loader": "8.2.3", + "babel-plugin-module-resolver": "5.0.0", + "babel-plugin-react-native-web": "0.17.5", + "babel-preset-expo": "13.0.0", + "core-js": "2.6.12", + "esbuild": "0.25.9", + "eslint": "10.0.2", + "expo-modules-core": "2.5.0", + "inquirer": "8.2.6", + "jest": "29.7.0", + "jest-expo": "53.0.10", + "jest-extended": "4.0.2", + "jest-transformer-svg": "2.0.0", + "madge": "6.1.0", + "mockdate": "3.0.5", + "postinstall-postinstall": "2.1.0", + "react-dom": "19.0.3", + "react-native-asset": "2.1.1", + "react-native-clean-project": "4.0.1", + "react-native-svg-transformer": "1.3.0", + "react-test-renderer": "19.0.3", + "reactotron-react-native": "5.1.15", + "reactotron-react-native-mmkv": "0.2.8", + "reactotron-redux": "3.2.0", + "redux-saga-test-plan": "4.0.4", + "typescript": "5.8.3" + } +} diff --git a/apps/mobile/project.json b/apps/mobile/project.json new file mode 100644 index 00000000..055c5f0c --- /dev/null +++ b/apps/mobile/project.json @@ -0,0 +1,367 @@ +{ + "tags": ["scope:mobile", "type:app"], + "targets": { + "build": { + "executor": "nx:noop" + }, + "android": { + "command": "EXPO_ANDROID_LAUNCH_ACTIVITY='com.uniswap.mobile.dev/com.uniswap.MainActivity' bunx expo run:android --variant=devDebug --app-id=com.uniswap.mobile.dev", + "options": { + "cwd": "{projectRoot}" + } + }, + "android:release": { + "command": "EXPO_ANDROID_LAUNCH_ACTIVITY='com.uniswap.mobile.dev/com.uniswap.MainActivity' bunx expo run:android --variant=devRelease --app-id=com.uniswap.mobile.dev", + "options": { + "cwd": "{projectRoot}" + } + }, + "android:beta": { + "command": "EXPO_ANDROID_LAUNCH_ACTIVITY='com.uniswap.mobile.beta/com.uniswap.MainActivity' bunx expo run:android --variant=betaDebug --app-id=com.uniswap.mobile.beta", + "options": { + "cwd": "{projectRoot}" + } + }, + "android:beta:release": { + "command": "EXPO_ANDROID_LAUNCH_ACTIVITY='com.uniswap.mobile.beta/com.uniswap.MainActivity' bunx expo run:android --variant=betaRelease --app-id=com.uniswap.mobile.beta", + "options": { + "cwd": "{projectRoot}" + } + }, + "android:prod": { + "command": "EXPO_ANDROID_LAUNCH_ACTIVITY='com.uniswap.mobile/com.uniswap.MainActivity' bunx expo run:android --variant=prodDebug", + "options": { + "cwd": "{projectRoot}" + } + }, + "android:prod:release": { + "command": "EXPO_ANDROID_LAUNCH_ACTIVITY='com.uniswap.mobile/com.uniswap.MainActivity' bunx expo run:android --variant=prodRelease", + "options": { + "cwd": "{projectRoot}" + } + }, + "check:deps:usage": { + "command": "./scripts/checkDepsUsage.sh", + "options": { + "cwd": "{projectRoot}" + } + }, + "check:bundlesize": { + "command": "./scripts/checkBundleSize.sh", + "options": { + "cwd": "{projectRoot}" + } + }, + "clean": { + "command": "react-native-clean-project", + "options": { + "cwd": "{projectRoot}" + } + }, + "debug:reactotron:install": { + "command": "./scripts/installDebugger.sh", + "options": { + "cwd": "{projectRoot}" + } + }, + "deduplicate": { + "command": "echo 'Bun automatically deduplicates dependencies'", + "options": { + "cwd": "{projectRoot}" + } + }, + "depcheck": { + "command": "depcheck", + "options": { + "cwd": "{projectRoot}" + } + }, + "env:android:keystore:download": { + "command": "bash ./scripts/downloadAndroidKeystore.sh", + "options": { + "cwd": "{projectRoot}" + } + }, + "env:fastlane:download": { + "command": "bash ./scripts/downloadFastlaneEnv.sh", + "options": { + "cwd": "{projectRoot}" + } + }, + "env:fastlane:upload": { + "command": "bash ./scripts/uploadFastlaneEnv.sh", + "options": { + "cwd": "{projectRoot}" + } + }, + "env:local:download": { + "command": "bash ../../scripts/downloadEnvLocal.sh xmznnx7ozuojy5lnohcmt73aee ../../.env.defaults.local && bun run env:local:copy:swift", + "options": { + "cwd": "{projectRoot}" + } + }, + "env:local:upload": { + "command": "bash ../../scripts/uploadEnvLocal.sh xmznnx7ozuojy5lnohcmt73aee ../../.env.defaults.local", + "options": { + "cwd": "{projectRoot}" + } + }, + "env:local:copy:swift": { + "command": "python3 scripts/copy_env_vars_to_swift.py", + "options": { + "cwd": "{projectRoot}" + } + }, + "e2e:prepare": { + "executor": "nx:noop", + "dependsOn": ["e2e:generate-ids", "e2e:build-js"] + }, + "e2e:generate-ids": { + "command": "mkdir -p .maestro/scripts/dist && bun run .maestro/scripts/tooling/generateTestIds.ts > .maestro/scripts/dist/testIds.js", + "options": { + "cwd": "{projectRoot}" + } + }, + "e2e:build-js": { + "command": "bun run .maestro/scripts/tooling/buildPerformanceScripts.ts", + "options": { + "cwd": "{projectRoot}" + } + }, + "e2e": { + "command": "maestro test .maestro", + "options": { + "cwd": "{projectRoot}" + }, + "dependsOn": ["e2e:prepare"] + }, + "e2e:onboarding": { + "command": "maestro test .maestro/flows/onboarding", + "options": { + "cwd": "{projectRoot}" + }, + "dependsOn": ["e2e:prepare"] + }, + "e2e:swap": { + "command": "maestro test .maestro/flows/swap", + "options": { + "cwd": "{projectRoot}" + }, + "dependsOn": ["e2e:prepare"] + }, + "e2e:clear-logs": { + "command": "rm -rf $HOME/.maestro/tests", + "options": { + "cwd": "{projectRoot}" + } + }, + "e2e:local:submit-metrics": { + "command": ".maestro/scripts/performance/submit-local.sh metrics.jsonl", + "options": { + "cwd": "{projectRoot}" + } + }, + "e2e:local:extract-metrics": { + "command": "bun .maestro/scripts/performance/dist/utils/extract-metrics.js $HOME/.maestro/tests metrics.jsonl", + "options": { + "cwd": "{projectRoot}" + }, + "dependsOn": ["e2e:build-js"] + }, + "e2e:local:process-metrics": { + "command": "bun run e2e:local:extract-metrics && bun run e2e:local:submit-metrics", + "options": { + "cwd": "{projectRoot}" + } + }, + "e2e:interactive": { + "command": "bun run .maestro/scripts/e2e-interactive.ts", + "options": { + "cwd": "{projectRoot}" + }, + "dependsOn": ["e2e:prepare"] + }, + "firestore:deploy:rules": { + "command": "firebase deploy --only firestore:rules", + "options": { + "cwd": "{projectRoot}" + } + }, + "link:assets": { + "command": "react-native-asset", + "options": { + "cwd": "{projectRoot}" + } + }, + "check:circular": { + "command": "bunx madge --circular ./src/app/App.tsx", + "options": { + "cwd": "{projectRoot}" + } + }, + "ios": { + "command": "bunx expo run:ios --scheme Uniswap --configuration DebugOptimized", + "options": { + "cwd": "{projectRoot}" + } + }, + "ios:debug": { + "command": "bunx expo run:ios --scheme Uniswap --configuration Debug", + "options": { + "cwd": "{projectRoot}" + } + }, + "ios:interactive": { + "command": "bun run scripts/ios-build-interactive/main.ts", + "options": { + "cwd": "{projectRoot}" + } + }, + "ios:smol": { + "command": "bunx expo run:ios --device=\"iPhone SE (3rd generation)\"", + "options": { + "cwd": "{projectRoot}" + } + }, + "ios:dev:release": { + "command": "bunx expo run:ios --configuration Dev", + "options": { + "cwd": "{projectRoot}" + } + }, + "ios:beta": { + "command": "bunx expo run:ios --configuration Beta", + "options": { + "cwd": "{projectRoot}" + } + }, + "ios:bundle": { + "command": "bunx react-native bundle --entry-file apps/mobile/index.js --platform ios --dev false --bundle-output ./ios/main.jsbundle --assets-dest ./ios --sourcemap-output ./ios/main.jsbundle.map", + "options": { + "cwd": "{projectRoot}" + } + }, + "ios:release": { + "command": "bunx expo run:ios --configuration Release", + "options": { + "cwd": "{projectRoot}" + } + }, + "lint": {}, + "lint:fix": {}, + "check": {}, + "check:fix": {}, + "lint:eslint": {}, + "lint:eslint:fix": {}, + "start": { + "command": "EXPO_ANDROID_LAUNCH_ACTIVITY='com.uniswap.mobile.dev/com.uniswap.MainActivity' EXPO_BUILD_CONFIGURATION=Debug NODE_ENV=development bunx expo start --scheme uniswap", + "options": { + "cwd": "{projectRoot}" + }, + "dependsOn": ["pod:ensure", "android:ensure"] + }, + "start:e2e": { + "command": "NODE_ENV=development IS_E2E_TEST=true bunx expo start", + "options": { + "cwd": "{projectRoot}" + }, + "dependsOn": ["pod:ensure", "android:ensure"] + }, + "start:production": { + "command": "NODE_ENV=production bunx expo start --reset-cache", + "options": { + "cwd": "{projectRoot}" + }, + "dependsOn": ["pod:ensure", "android:ensure"] + }, + "test": { + "command": "node --max-old-space-size=8912 ../../node_modules/.bin/jest", + "options": { + "cwd": "{projectRoot}" + }, + "inputs": [ + "sourceFiles", + "mobileGlobals", + "^production", + "{projectRoot}/**/*.test.*", + "{projectRoot}/**/*.spec.*" + ] + }, + "snapshots": { + "command": "jest -u", + "options": { + "cwd": "{projectRoot}" + } + }, + "typecheck": {}, + "typecheck:tsgo": {}, + "unicons": { + "command": "cd scripts && python3 populate_svgs.py && cd .. && bun run lint --fix", + "options": { + "cwd": "{projectRoot}" + } + }, + "pod": { + "command": "./scripts/podinstall.sh", + "options": { + "cwd": "{projectRoot}" + } + }, + "pod:ensure": { + "command": "./scripts/check-podfile.sh", + "options": { + "cwd": "{projectRoot}" + }, + "inputs": ["{projectRoot}/ios/Podfile", "{projectRoot}/ios/Podfile.lock"], + "cache": true + }, + "android:ensure": { + "command": "./scripts/check-android-gradle.sh", + "options": { + "cwd": "{projectRoot}" + }, + "inputs": [ + "{projectRoot}/android/build.gradle", + "{projectRoot}/android/app/build.gradle", + "{projectRoot}/android/settings.gradle", + "{projectRoot}/android/gradle.properties" + ], + "cache": true + }, + "pod:update": { + "command": "./scripts/podinstall.sh -u", + "options": { + "cwd": "{projectRoot}" + } + }, + "storybook:generate": { + "command": "sb-rn-get-stories", + "options": { + "cwd": "{projectRoot}" + } + }, + "prestart": { + "command": "bun run storybook:generate", + "options": { + "cwd": "{projectRoot}" + } + }, + "softreset": { + "command": "bash ./scripts/resetDevEnv.sh", + "options": { + "cwd": "{projectRoot}" + } + }, + "hardreset": { + "command": "bash ./scripts/resetDevEnv.sh --hard", + "options": { + "cwd": "{projectRoot}" + } + }, + "oxfmt": {}, + "oxfmt:fix": {}, + "oxlint": {}, + "oxlint:fix": {}, + "oxlint:type-aware": {} + } +} diff --git a/apps/mobile/react-native.config.js b/apps/mobile/react-native.config.js new file mode 100644 index 00000000..6282855a --- /dev/null +++ b/apps/mobile/react-native.config.js @@ -0,0 +1,3 @@ +module.exports = { + assets: ['./src/assets/fonts'], +} diff --git a/apps/mobile/scripts/check-android-gradle.sh b/apps/mobile/scripts/check-android-gradle.sh new file mode 100755 index 00000000..775576a1 --- /dev/null +++ b/apps/mobile/scripts/check-android-gradle.sh @@ -0,0 +1,25 @@ +#!/bin/bash + +# This script warns users if they need to sync Gradle +# It's designed to be used with NX caching - NX will only run this +# when Android Gradle files change + +# Detect if we're in a workspace (monorepo) by checking for workspace root +# Script runs from apps/mobile, so check if ../../nx.json exists (workspace root) +if [ -f "../../nx.json" ]; then + # We're in a workspace, user likely runs commands from root + ANDROID_CMD="bun mobile android" +else + # We're not in a workspace, user runs commands from mobile dir + ANDROID_CMD="bun android" +fi + +echo "⚠️ Warning: Android Gradle files have changed since last build" +echo "" +echo "You may encounter issues when running the Android app." +echo "To fix this, run one of the following:" +echo " • $ANDROID_CMD (build Android app, which will sync Gradle automatically)" +echo "" +echo "Metro bundler will continue starting, but you should build the Android app before" +echo "attempting to run it to ensure Gradle dependencies are synced." + diff --git a/apps/mobile/scripts/check-podfile.sh b/apps/mobile/scripts/check-podfile.sh new file mode 100755 index 00000000..4aec05ff --- /dev/null +++ b/apps/mobile/scripts/check-podfile.sh @@ -0,0 +1,25 @@ +#!/bin/bash + +# This script warns users if they need to run pod install +# It's designed to be used with NX caching - NX will only run this +# when Podfile or Podfile.lock changes + +# Detect if we're in a workspace (monorepo) by checking for workspace root +# Script runs from apps/mobile, so check if ../../nx.json exists (workspace root) +if [ -f "../../nx.json" ]; then + # We're in a workspace, user likely runs commands from root + IOS_CMD="bun mobile ios" +else + # We're not in a workspace, user runs commands from mobile dir + IOS_CMD="bun ios" +fi + +echo "⚠️ Warning: Podfile or Podfile.lock has changed since last pod install" +echo "" +echo "You may encounter issues when running the iOS app." +echo "To fix this, run:" +echo " • $IOS_CMD (build iOS app, which will install pods automatically)" +echo "" +echo "Metro bundler will continue starting, but you should build the iOS app before" +echo "attempting to run it to ensure pods are installed." + diff --git a/apps/mobile/scripts/checkBundleSize.sh b/apps/mobile/scripts/checkBundleSize.sh new file mode 100755 index 00000000..3e1baa16 --- /dev/null +++ b/apps/mobile/scripts/checkBundleSize.sh @@ -0,0 +1,27 @@ +#!/bin/bash +<<<<<<< HEAD +MAX_SIZE=25.50 +======= +# Bumped 25.50 -> 26.00: security dep audit (elliptic 6.6.1, qs 6.14.2, etc.) added ~4KB via transitive deps +MAX_SIZE=26.00 +>>>>>>> upstream/main +MAX_BUFFER=0.5 + +# Check OS type and use appropriate stat command +if [[ $OSTYPE == "darwin"* ]]; then + # MacOS + BUNDLE_SIZE=$(stat -f %z ios/main.jsbundle | awk '{print $1/1024/1024}') +else + # Linux and others + BUNDLE_SIZE=$(stat --format=%s ios/main.jsbundle | awk '{print $1/1024/1024}') +fi + +if (($(echo "$BUNDLE_SIZE > $MAX_SIZE" | bc -l))); then + echo "Bundle size ($BUNDLE_SIZE MB) exceeds limit ($MAX_SIZE MB). If you are adding new dependencies or files and see an increase of the bundle size by > 0.5MB, please check with the team. Otherwise you can bump the max size in the script by 0.5MB." + exit 1 +elif (($(echo "$BUNDLE_SIZE + $MAX_BUFFER < $MAX_SIZE" | bc -l))); then + echo "Bundle size ($BUNDLE_SIZE MB) has too much buffer (Max buffer is $MAX_BUFFER MB)! Please bump down the limit to be within $MAX_BUFFER of the current bundle size to ensure we retain our bundle size gains!" + exit 1 +else + echo "✅ Bundle size ($BUNDLE_SIZE MB) is within limit ($MAX_SIZE MB)" +fi diff --git a/apps/mobile/scripts/checkDepsUsage.sh b/apps/mobile/scripts/checkDepsUsage.sh new file mode 100755 index 00000000..5dabfe75 --- /dev/null +++ b/apps/mobile/scripts/checkDepsUsage.sh @@ -0,0 +1,8 @@ +#!/bin/bash + +mv src/package.json src/ignore.json +bun run depcheck +result_status=$? +mv src/ignore.json src/package.json + +exit $result_status diff --git a/apps/mobile/scripts/copy_env_vars_to_swift.py b/apps/mobile/scripts/copy_env_vars_to_swift.py new file mode 100644 index 00000000..a9fdcf2d --- /dev/null +++ b/apps/mobile/scripts/copy_env_vars_to_swift.py @@ -0,0 +1,62 @@ +import os + +ENV_DEFAULTS_FILE = '../../.env.defaults' +ENV_DEFAULTS_LOCAL_FILE = '../../.env.defaults.local' +SWIFT_FILE_PATHS = ['ios/WidgetsCore/Env.swift', 'ios/OneSignalNotificationServiceExtension/Env.swift'] +<<<<<<< HEAD +SWIFT_ENV_VARIABLES = ['LUX_API_KEY', 'STATSIG_API_KEY'] +======= +SWIFT_ENV_VARIABLES = ['UNISWAP_API_KEY', 'STATSIG_API_KEY'] +>>>>>>> upstream/main + +def to_swift_constant_line(key, value): + return f' static let {key.upper()} = "{value}"' + +def process_lines(lines, search_vars): + env_var_declarations = [] + for line in lines: + line = line.strip() + if line and not line.startswith('#'): + # Split variable name and value + key, value = line.split('=', 1) + if key in search_vars: + env_var_declarations.append(to_swift_constant_line(key.upper(), value)) + search_vars.remove(key) + + return env_var_declarations + +# convert env variables to swift constants and writes to a swift file. +def copy_env_vars_to_swift(env_defaults_file, env_defaults_local_file, swift_files, env_variables): + envs_left_to_find = env_variables.copy() + env_var_declarations = [] + + # Search for env vars in the system first + for key in env_variables: + if key in os.environ: + env_var_declarations.append(to_swift_constant_line(key.upper(), os.environ[key])) + envs_left_to_find.remove(key) + + # read from local env file if it exists + if os.path.isfile(env_defaults_local_file): + with open(env_defaults_local_file, 'r') as f: + env_lines = f.readlines() + env_var_declarations.extend(process_lines(env_lines, envs_left_to_find)) + + # read from checked in env file for non-secret variables + with open(env_defaults_file, 'r') as f: + default_env_lines = f.readlines() + env_var_declarations.extend(process_lines(default_env_lines, envs_left_to_find)) + + # write to swift file + for swift_file in swift_files: + with open(swift_file, 'w') as f: + f.write('struct Env {\n') + f.write('\n'.join(env_var_declarations)) + f.write('\n}') + + # If not all env variables are set + if len(env_variables) != len(env_var_declarations): + print('WARNING: Not all environment variables were converted to Swift.') + exit(1) + +copy_env_vars_to_swift(ENV_DEFAULTS_FILE, ENV_DEFAULTS_LOCAL_FILE, SWIFT_FILE_PATHS, SWIFT_ENV_VARIABLES) diff --git a/apps/mobile/scripts/getFingerprintForRadonIDE.js b/apps/mobile/scripts/getFingerprintForRadonIDE.js new file mode 100644 index 00000000..eff9245e --- /dev/null +++ b/apps/mobile/scripts/getFingerprintForRadonIDE.js @@ -0,0 +1,6 @@ +/* This file is run by Radon IDE to get the fingerprint for the current build +Change the string to update the fingerprint for the current build, forcing Radon to re-run the build process +Usually, this should only be necessary when there are native code changes relative to the most recent RadonIDE build +Avoid committing changes to this file! */ + +console.log('2024-11-13') diff --git a/apps/mobile/scripts/getFingerprintForRadonIDE.ts b/apps/mobile/scripts/getFingerprintForRadonIDE.ts new file mode 100644 index 00000000..61d1e332 --- /dev/null +++ b/apps/mobile/scripts/getFingerprintForRadonIDE.ts @@ -0,0 +1,22 @@ +#!/usr/bin/env bun +import { createProjectHashAsync } from '@expo/fingerprint' + +async function main(): Promise { + try { + const projectRoot = process.cwd() + const hash = await createProjectHashAsync(projectRoot, { + silent: true, + }) + console.log(hash) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + console.error(`Failed to generate fingerprint: ${errorMessage}`) + process.exit(1) + } +} + +main().catch((error) => { + const errorMessage = error instanceof Error ? error.message : String(error) + console.error(`Fatal error: ${errorMessage}`) + process.exit(1) +}) diff --git a/apps/mobile/scripts/installDebugger.sh b/apps/mobile/scripts/installDebugger.sh new file mode 100755 index 00000000..615bf9f1 --- /dev/null +++ b/apps/mobile/scripts/installDebugger.sh @@ -0,0 +1,11 @@ +#!/bin/bash +which -s brew +if [[ $? != 0 ]] ; then + echo 'Homebrew has not been found to install Reactotron! Installing Homebrew now.' + # Install Homebrew + /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" +else + brew update +fi + +brew install --cask reactotron diff --git a/apps/mobile/scripts/ios-build-interactive/README.md b/apps/mobile/scripts/ios-build-interactive/README.md new file mode 100644 index 00000000..81e3b4ac --- /dev/null +++ b/apps/mobile/scripts/ios-build-interactive/README.md @@ -0,0 +1,34 @@ +# iOS Build Interactive Tool + +An interactive CLI tool for building iOS apps with various configurations. + +## Structure + +- `main.ts` - Main script containing the interactive build logic +- `utils.ts` - Shared utilities, constants, types, and helper functions + +## Usage + +From the `apps/mobile` directory: + +```bash +bun ios:interactive +``` + +## Features + +- Choose between simulator and device builds +- Select Debug or Release configurations +- Pick from multiple app schemes (Uniswap, Dev, Beta, Production) +- Auto-detect available simulators and devices +- Metro bundler management +- Build cleaning and cache reset options + +## Requirements + +- Xcode +- Node.js +- Bun +- CocoaPods +- iOS Simulator (for simulator builds) +- Connected iOS device (for device builds) diff --git a/apps/mobile/scripts/ios-build-interactive/main.ts b/apps/mobile/scripts/ios-build-interactive/main.ts new file mode 100755 index 00000000..540e4bc3 --- /dev/null +++ b/apps/mobile/scripts/ios-build-interactive/main.ts @@ -0,0 +1,402 @@ +#!/usr/bin/env ts-node +// oxlint-disable-next-line no-console -- CLI tool needs console for user interaction +import { spawn } from 'child_process' +import { existsSync } from 'fs' +import { homedir } from 'os' +import { join } from 'path' +import inquirer from 'inquirer' +// oxlint-disable-next-line universe-custom/no-relative-import-paths +import { + type BuildConfig, + type BuildType, + CONSTANTS, + type Configuration, + log, + type PhysicalDevice, + PROMPT_CONFIGS, + type PreflightCheck, + parseDeviceFromLine, + printBuildInfo, + printHelp, + printTroubleshootingTips, + runCommand, + type Scheme, + type SimulatorDevice, + sleep, + spawnProcess, +} from './utils' + +// Pre-flight checks +const checkPreflightRequirements = async (): Promise => { + log.info(`${CONSTANTS.MESSAGES.EMOJIS.CHECK} Running pre-flight checks...\n`) + + const checks: PreflightCheck[] = [ + { name: 'Xcode', command: CONSTANTS.COMMANDS.XCODE_VERSION }, + { name: 'Node.js', command: CONSTANTS.COMMANDS.NODE_VERSION }, + { name: 'Bun', command: CONSTANTS.COMMANDS.BUN_VERSION }, + { name: 'CocoaPods', command: CONSTANTS.COMMANDS.POD_VERSION }, + { name: 'iOS Simulator', command: CONSTANTS.COMMANDS.LIST_SIMULATORS }, + ] + + for (const check of checks) { + try { + await runCommand(check.command) + log.success(`${check.name} is available`) + } catch (error) { + log.error(`${check.name} is not available or not working properly`) + throw new Error(`${CONSTANTS.MESSAGES.ERRORS.PREFLIGHT_FAILED} ${check.name}`) + } + } + + log.success('\nAll pre-flight checks passed!\n') +} + +const checkCurrentDirectory = (): void => { + const currentDir = process.cwd() + + if (!currentDir.endsWith(CONSTANTS.PATHS.MOBILE_DIR)) { + log.error(CONSTANTS.MESSAGES.ERRORS.WRONG_DIR) + log.info(`Current directory: ${currentDir}`) + process.exit(1) + } +} + +const checkEnvironmentFile = (): void => { + const envFilePath = join(process.cwd(), '..', '..', CONSTANTS.PATHS.ENV_FILE) + + if (!existsSync(envFilePath)) { + log.error(CONSTANTS.MESSAGES.ERRORS.ENV_MISSING) + log.info('') + log.info(`The required ${CONSTANTS.PATHS.ENV_FILE} file was not found in the project root.`) + log.info('This file contains necessary environment variables for the build.') + log.info('') + log.info('Please run the following command to download it:') + log.info(` ${CONSTANTS.MESSAGES.ERRORS.ENV_DOWNLOAD}`) + log.info('') + process.exit(1) + } +} + +const getAvailableSimulators = async (): Promise => { + try { + const { stdout } = await runCommand(CONSTANTS.COMMANDS.LIST_SIMULATORS_JSON) + const data = JSON.parse(stdout) + + const simulators: SimulatorDevice[] = [] + + Object.keys(data.devices).forEach((runtime) => { + if (runtime.includes('iOS')) { + data.devices[runtime].forEach((device: { name: string; udid: string; state: string; isAvailable: boolean }) => { + simulators.push({ + name: `${device.name} (${runtime.replace('com.apple.CoreSimulator.SimRuntime.', '').replace('-', ' ')})`, + udid: device.udid, + state: device.state, + isAvailable: device.isAvailable, + }) + }) + } + }) + + return simulators.filter((sim) => sim.isAvailable) + } catch (error) { + log.warning('Could not fetch simulator list, using default options') + return [] + } +} + +const getConnectedDevices = async (): Promise => { + try { + // Try new devicectl command first (iOS 17+) + try { + const { stdout } = await runCommand(CONSTANTS.COMMANDS.LIST_DEVICES_NEW) + const lines = stdout.split('\n').filter((line) => line.includes('iPhone') || line.includes('iPad')) + return lines + .map((line) => { + const parts = line.trim().split(/\s+/) + const name = parts.slice(0, -1).join(' ') + const udid = parts[parts.length - 1] + return { + name: name || 'Unknown Device', + udid: udid || 'unknown', + platform: 'iOS', + } + }) + .filter((device) => device.name !== 'Unknown Device') + } catch { + // Fallback to older instruments command + const { stdout } = await runCommand(CONSTANTS.COMMANDS.LIST_DEVICES_OLD) + const lines = stdout.split('\n') + const devices: PhysicalDevice[] = [] + + for (const line of lines) { + const device = parseDeviceFromLine(line) + if (device) { + devices.push(device) + } + } + return devices + } + } catch (error) { + log.warning('Could not fetch connected devices, will use generic device build') + return [] + } +} + +const checkMetroStatus = async (): Promise => { + try { + const { stdout } = await runCommand(`${CONSTANTS.COMMANDS.CHECK_PORT}${CONSTANTS.PORTS.METRO}`) + return stdout.includes('node') + } catch { + return false + } +} + +const startMetro = async (): Promise => { + log.info(`${CONSTANTS.MESSAGES.EMOJIS.ROCKET} Starting Metro bundler...`) + + const metro = spawn(CONSTANTS.COMMANDS.START_METRO[0], CONSTANTS.COMMANDS.START_METRO.slice(1), { + stdio: 'pipe', + detached: true, + }) + + metro.unref() + + await sleep(CONSTANTS.TIMEOUTS.METRO_START) + + const isRunning = await checkMetroStatus() + if (isRunning) { + log.success('Metro bundler started successfully\n') + } else { + log.warning('Metro bundler may not have started properly\n') + } +} + +const cleanBuildFolder = async (): Promise => { + log.info(`${CONSTANTS.MESSAGES.EMOJIS.CLEAN} Cleaning build folder...`) + try { + // Clean local build directory + await runCommand(`${CONSTANTS.COMMANDS.CLEAN_BUILD} ${CONSTANTS.PATHS.BUILD_DIR}`) + + // Clean Xcode DerivedData using proper home directory path + const derivedDataPath = join(homedir(), 'Library', 'Developer', 'Xcode', 'DerivedData') + await runCommand(`${CONSTANTS.COMMANDS.CLEAN_BUILD} ${derivedDataPath}`) + + log.success('Build folder cleaned\n') + } catch (error) { + log.warning('Could not clean build folder completely\n') + } +} + +const resetMetroCache = async (): Promise => { + log.info(`${CONSTANTS.MESSAGES.EMOJIS.TRASH} Resetting Metro cache...`) + try { + // Use spawn with detached option to properly run the reset command + const resetProcess = spawn('bun', ['run', 'start', '--reset-cache'], { + stdio: 'ignore', + detached: true, + }) + + resetProcess.unref() + + // Give it time to start the reset process + await sleep(CONSTANTS.TIMEOUTS.METRO_RESET) + + // Kill the process after it has initiated the cache reset + if (resetProcess.pid) { + try { + process.kill(-resetProcess.pid) + } catch { + // Process may have already exited, which is fine + } + } + + log.success('Metro cache reset\n') + } catch (error) { + log.warning('Could not reset Metro cache\n') + } +} + +const buildForSimulator = async (config: BuildConfig): Promise => { + printBuildInfo(config, 'iOS Simulator') + + const args = ['expo', 'run:ios', '--scheme', 'Uniswap', '--configuration', config.configuration] + + if (config.simulator) { + const simulatorName = config.simulator.split('(')[0]?.trim() + args.push(`--device=${simulatorName}`) + } + + log.info(`Command: bun run ${args.join(' ')}\n`) + + try { + await spawnProcess('bun', ['run', ...args]) + log.success('\nBuild completed successfully!') + } catch (error) { + printTroubleshootingTips(false) + throw error + } +} + +const buildForDevice = async (config: BuildConfig): Promise => { + printBuildInfo(config, 'iOS Device') + + const args = [ + 'expo', + 'run:ios', + '--scheme', + config.scheme, + '--configuration', + config.configuration, + '--device', + 'device', + ] + + if (config.configuration === 'Release') { + args.push('--archive') + } + + log.info(`Command: bun run ${args.join(' ')}\n`) + + try { + await spawnProcess('bun', ['run', ...args]) + log.success('\nBuild completed successfully!') + } catch (error) { + printTroubleshootingTips(true) + throw error + } +} + +const main = async (): Promise => { + // Handle help flag + if (process.argv.includes('--help') || process.argv.includes('-h')) { + printHelp() + return + } + + log.info(`${CONSTANTS.MESSAGES.EMOJIS.PHONE} iOS Build Interactive Tool\n`) + + try { + // Pre-flight checks + checkCurrentDirectory() + checkEnvironmentFile() + await checkPreflightRequirements() + + // Check Metro status + const isMetroRunning = await checkMetroStatus() + if (!isMetroRunning) { + const { startMetroChoice } = await inquirer.prompt([ + { + type: 'confirm', + name: 'startMetroChoice', + message: 'Metro bundler is not running. Start it now?', + default: true, + }, + ]) + + if (startMetroChoice) { + await startMetro() + } + } else { + log.success('Metro bundler is already running\n') + } + + // Get available simulators and devices + const [simulators, devices] = await Promise.all([getAvailableSimulators(), getConnectedDevices()]) + + // Build configuration prompts + const answers = await inquirer.prompt<{ + buildType: BuildType + configuration: Configuration + scheme: Scheme + simulator?: string + device?: string + }>([PROMPT_CONFIGS.buildType, PROMPT_CONFIGS.configuration]) + + // Simulator selection + if (answers.buildType === 'simulator' && simulators.length > 0) { + const { simulator } = await inquirer.prompt([ + { + type: 'list', + name: 'simulator', + message: 'Select target simulator:', + choices: [ + { name: 'Default simulator', value: null }, + ...simulators.map((sim) => ({ name: sim.name, value: sim.name })), + ], + }, + ]) + answers.simulator = simulator + } + + // Device selection + if (answers.buildType === 'device' && devices.length > 0) { + const { device } = await inquirer.prompt([ + { + type: 'list', + name: 'device', + message: 'Select target device:', + choices: [ + { name: 'Any connected device', value: null }, + ...devices.map((dev) => ({ name: `${dev.name} (${dev.platform})`, value: dev.name })), + ], + }, + ]) + answers.device = device + } + + // Utility options + const { utilities } = await inquirer.prompt<{ utilities: string[] }>([PROMPT_CONFIGS.utilities]) + + const config: BuildConfig = { + ...answers, + scheme: 'Uniswap', + cleanBuild: utilities.includes('clean'), + resetMetroCache: utilities.includes('resetCache'), + } + + // Execute pre-build utilities + if (config.cleanBuild) { + await cleanBuildFolder() + } + + if (config.resetMetroCache) { + await resetMetroCache() + } + + // Execute build + log.info(`${CONSTANTS.MESSAGES.EMOJIS.ROCKET} Starting build process...\n`) + + if (config.buildType === 'simulator') { + await buildForSimulator(config) + } else { + await buildForDevice(config) + } + + log.info(`\n${CONSTANTS.MESSAGES.EMOJIS.PARTY} Build process completed successfully!`) + } catch (error) { + console.error( + `\n${CONSTANTS.MESSAGES.EMOJIS.ERROR} Build process failed:`, + error instanceof Error ? error.message : error, + ) + process.exit(1) + } +} + +// Handle process termination gracefully +process.on('SIGINT', () => { + log.info(`\n\n${CONSTANTS.MESSAGES.EMOJIS.WAVE} Build process interrupted by user`) + process.exit(0) +}) + +process.on('SIGTERM', () => { + log.info(`\n\n${CONSTANTS.MESSAGES.EMOJIS.WAVE} Build process terminated`) + process.exit(0) +}) + +// Run the main function +if (require.main === module) { + main().catch((error) => { + console.error('Fatal error:', error) + process.exit(1) + }) +} diff --git a/apps/mobile/scripts/ios-build-interactive/utils.ts b/apps/mobile/scripts/ios-build-interactive/utils.ts new file mode 100644 index 00000000..888dbf55 --- /dev/null +++ b/apps/mobile/scripts/ios-build-interactive/utils.ts @@ -0,0 +1,213 @@ +/* oxlint-disable no-console -- CLI tool needs console for user interaction */ + +import { exec, spawn } from 'child_process' +import { promisify } from 'util' + +const execAsync = promisify(exec) + +// Constants +export const CONSTANTS = { + PORTS: { + METRO: 8081, + }, + PATHS: { + MOBILE_DIR: 'apps/mobile', + ENV_FILE: '.env.defaults.local', + BUILD_DIR: 'ios/build', + }, + COMMANDS: { + XCODE_VERSION: 'xcodebuild -version', + NODE_VERSION: 'node --version', + BUN_VERSION: 'bun --version', + POD_VERSION: 'pod --version', + LIST_SIMULATORS: 'xcrun simctl list devices', + LIST_SIMULATORS_JSON: 'xcrun simctl list devices --json', + LIST_DEVICES_NEW: 'xcrun devicectl list devices', + LIST_DEVICES_OLD: 'xcrun instruments -s devices', + CHECK_PORT: 'lsof -i :', + START_METRO: ['bun', 'run', 'start'], + CLEAN_BUILD: 'rm -rf', + POD_INSTALL: 'bun run pod', + }, + TIMEOUTS: { + METRO_START: 3000, + METRO_RESET: 2000, + }, + MESSAGES: { + EMOJIS: { + CHECK: '🔍', + SUCCESS: '✅', + ERROR: '❌', + WARNING: '⚠️', + ROCKET: '🚀', + CLEAN: '🧹', + TRASH: '🗑️', + BUILD: '🔨', + BULB: '💡', + PHONE: '📱', + DEVICE: '📲', + PARTY: '🎉', + WAVE: '👋', + }, + ERRORS: { + WRONG_DIR: 'Please run this script from the apps/mobile directory', + ENV_MISSING: 'Environment file missing!', + ENV_DOWNLOAD: + 'bun run env:local:download (from apps/mobile) OR bun run mobile env:local:download (from workspace root)', + BUILD_FAILED: 'Build failed with exit code', + PREFLIGHT_FAILED: 'Pre-flight check failed:', + }, + }, +} as const + +// Types +export type BuildType = 'simulator' | 'device' +export type Configuration = 'Debug' | 'Release' +export type Scheme = 'Uniswap' + +export interface BuildConfig { + buildType: BuildType + configuration: Configuration + scheme: Scheme + simulator?: string + device?: string + cleanBuild: boolean + resetMetroCache: boolean +} + +export interface SimulatorDevice { + name: string + udid: string + state: string + isAvailable: boolean +} + +export interface PhysicalDevice { + name: string + udid: string + platform: string +} + +export interface PreflightCheck { + name: string + command: string +} + +// Utility functions +export const log = { + info: (message: string): void => console.log(message), + success: (message: string): void => console.log(`${CONSTANTS.MESSAGES.EMOJIS.SUCCESS} ${message}`), + error: (message: string): void => console.log(`${CONSTANTS.MESSAGES.EMOJIS.ERROR} ${message}`), + warning: (message: string): void => console.log(`${CONSTANTS.MESSAGES.EMOJIS.WARNING} ${message}`), +} + +export const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)) + +export const runCommand = async (command: string): Promise<{ stdout: string; stderr: string }> => { + return execAsync(command) +} + +export const spawnProcess = (command: string, args: string[]): Promise => { + return new Promise((resolve, reject) => { + // oxlint-disable-next-line typescript/no-explicit-any -- Node spawn options type requires any for stdio config + const process = spawn(command, args, { stdio: 'inherit' } as any) + process.on('close', (code) => { + if (code === 0) { + resolve() + } else { + reject(new Error(`Process failed with exit code ${code}`)) + } + }) + }) +} + +export const parseDeviceFromLine = (line: string): PhysicalDevice | null => { + const match = line.match(/^(.+?)\s+\(([0-9.]+)\)\s+\[([A-F0-9-]+)\]/) + if (!match || line.includes('Simulator')) { + return null + } + + const [, name, version, udid] = match + if (name && version && udid) { + return { + name: name.trim(), + udid, + platform: `iOS ${version}`, + } + } + return null +} + +export const printBuildInfo = (config: BuildConfig, targetType: string): void => { + log.info(`${CONSTANTS.MESSAGES.EMOJIS.BUILD} Building for ${targetType}...`) + log.info(`Configuration: ${config.configuration}`) + log.info(`Scheme: ${config.scheme}`) + const targetName = config.buildType === 'simulator' ? config.simulator : config.device + if (targetName) { + log.info(`Target ${targetType}: ${targetName}`) + } +} + +export const printTroubleshootingTips = (isDevice: boolean): void => { + log.info(`\n${CONSTANTS.MESSAGES.EMOJIS.BULB} Troubleshooting suggestions:`) + log.info('1. Try cleaning build folder and resetting Metro cache') + log.info(`2. Ensure all pods are installed: ${CONSTANTS.COMMANDS.POD_INSTALL}`) + log.info('3. Check Xcode for any signing or configuration issues') + + if (isDevice) { + log.info('4. Ensure your device is connected and trusted') + log.info('5. Check your signing certificates in Xcode') + log.info('6. Verify your provisioning profiles are valid') + log.info('7. Make sure your device is registered in your Apple Developer account') + } else { + log.info('4. Verify the selected simulator is available') + } +} + +export const printHelp = (): void => { + log.info(`${CONSTANTS.MESSAGES.EMOJIS.PHONE} iOS Build Interactive Tool`) + log.info('') + log.info('Interactive CLI tool for building iOS apps with various configurations.') + log.info('') + log.info('Features:') + log.info('• Choose between simulator and device builds') + log.info('• Select Debug or Release configurations') + log.info('• Pick from multiple app schemes') + log.info('• Auto-detect available simulators and devices') + log.info('• Metro bundler management') + log.info('• Build cleaning and cache reset options') + log.info('') + log.info('Usage: bun run ios:interactive') +} + +// Prompt configurations +export const PROMPT_CONFIGS = { + buildType: { + type: 'list' as const, + name: 'buildType' as const, + message: 'What type of build do you want?', + choices: [ + { name: `${CONSTANTS.MESSAGES.EMOJIS.PHONE} iOS Simulator`, value: 'simulator' }, + { name: `${CONSTANTS.MESSAGES.EMOJIS.DEVICE} Physical Device`, value: 'device' }, + ], + }, + configuration: { + type: 'list' as const, + name: 'configuration' as const, + message: 'Select build configuration:', + choices: [ + { name: 'Debug (faster build, debugging enabled)', value: 'Debug' }, + { name: 'Release (optimized, production-ready)', value: 'Release' }, + ], + default: 'Debug', + }, + utilities: { + type: 'checkbox' as const, + name: 'utilities' as const, + message: 'Select additional options:', + choices: [ + { name: 'Clean build folder before building', value: 'clean' }, + { name: 'Reset Metro cache', value: 'resetCache' }, + ], + }, +} diff --git a/apps/mobile/scripts/podinstall.sh b/apps/mobile/scripts/podinstall.sh new file mode 100755 index 00000000..42de223c --- /dev/null +++ b/apps/mobile/scripts/podinstall.sh @@ -0,0 +1,42 @@ +#!/bin/bash + +set -e + +REQUIRED_XCODE_VERSION="$(cat "$(dirname "$0")/../../../.xcode-version" | tr -d '\n')" +UPDATE_REPOS=false + +while [[ $# -gt 0 ]]; do + case $1 in + -u|--update) + UPDATE_REPOS=true + shift + ;; + *) + shift + ;; + esac +done + +check_xcode_version() { + local current_version=$(xcodebuild -version | grep "Xcode" | cut -d' ' -f2) + if [ "$current_version" != "$REQUIRED_XCODE_VERSION" ]; then + echo "Error: Xcode version mismatch" + echo "Required: $REQUIRED_XCODE_VERSION" + echo "Current: $current_version" + exit 1 + fi + echo "Xcode version check passed: $current_version" +} + +# Check Xcode version +check_xcode_version + +# Install pods +cd ios/ +bundle install +if [ "$UPDATE_REPOS" = true ]; then + bundle exec pod install --repo-update +else + bundle exec pod install +fi +cd .. diff --git a/apps/mobile/scripts/populate_svgs.py b/apps/mobile/scripts/populate_svgs.py new file mode 100644 index 00000000..85ef9495 --- /dev/null +++ b/apps/mobile/scripts/populate_svgs.py @@ -0,0 +1,131 @@ +import os +from os.path import isfile, join + + +def find_closing_quote_index(s, starting_index=0): + for i in range(starting_index, len(s)): + if s[i] == '"': + return i + return None + + +def grab_svg_attribute_prop(prop_name, line): + # if prop_name = fill-rule, then this function + # will return the string between the quotes: + # returns + i = line.index(f"{prop_name}=") + idx_after_quote = i + len(prop_name) + 2 + j = find_closing_quote_index(line, idx_after_quote) + if not j: + return None + return line[idx_after_quote:j] + + +folder_location = "../src/assets/unicons/" +folders = ["Container", "Emblem"] +unicons_location = '../src/components/unicons' + + +def generate_arrays_from_svgs(): + print("Generating ShapeSvg Arrays") + for folder in folders: + folder_path = join(folder_location, folder) + print("Looking in", folder_path) + print("Found", len(os.listdir(folder_path)), "files") + result = "import { PathProps } from 'src/components/unicons/types'\nexport const svgPaths: PathProps[][] = [" + count = 0 + for filename in os.listdir(folder_path): + if not isfile(join(folder_path, filename)) or filename[-4:] != ".svg": + continue + f = open(join(folder_path, filename), "r") + cur_svgs = [] + for line in f: + if "svg" in line: + continue + if ">>>>>> upstream/main + echo "No changes detected in project.pbxproj." + else + echo "🚨🚨🚨🚨🚨🚨🚨 WARNING! 🚨🚨🚨🚨🚨🚨🚨" + echo "Changes detected in project.pbxproj. During a hard reset, these changes will be lost." + read -p "Do you want to continue with the hard reset? (y/N): " confirm_diff + if [[ $confirm_diff != [yY] && $confirm_diff != [yY][eE][sS] ]]; then + echo "🚫 Hard reset cancelled due to changes." + exit 1 + fi + fi + + echo "📦 Removing Pods directory..." + cd ios + rm -rf Pods + echo "📦 Removing build directory..." + rm -rf ios/build + echo "🗑️ Removing Pods..." + pod deintegrate + echo "🗑️ Cleaning pod cache..." + pod cache clean --all + cd .. + + echo "🗑️ Removing Xcode DerivedData..." + rm -rf ~/Library/Developer/Xcode/DerivedData + + echo "✨ Hard reset complete!" +fi + +echo "🔄 Running soft reset..." +bun install +bun run g:prepare +bun run pod:update + +if [ "$1" = "--hard" ]; then + echo "🗑️ Restoring project.pbxproj..." +<<<<<<< HEAD + git checkout -- ios/Lux.xcodeproj/project.pbxproj +======= + git checkout -- ios/Uniswap.xcodeproj/project.pbxproj +>>>>>>> upstream/main +fi + + +echo "🚇 Starting metro bundler" +bun run start --reset-cache + + +echo "🔧 You may want to run 'bun run ios'/'bun run mobile ios' to start the iOS app" +echo "✨ Soft reset complete!" diff --git a/apps/mobile/scripts/testDeepLinks.sh b/apps/mobile/scripts/testDeepLinks.sh new file mode 100755 index 00000000..2449f0e0 --- /dev/null +++ b/apps/mobile/scripts/testDeepLinks.sh @@ -0,0 +1,65 @@ +#!/bin/bash + +<<<<<<< HEAD +# This script tests deep links for the Lux mobile app locally. +======= +# This script tests deep links for the Uniswap mobile app locally. +>>>>>>> upstream/main +# Usage: ./testDeepLinks.sh + +# It opens a series of URLs in the iOS simulator and terminates the app after each URL is opened. +# Arguments: +# user_id: The user ID to be included in some of the URLs. + +<<<<<<< HEAD +bundle_id="com.lux.exchange.mobile.dev" +======= +bundle_id="com.uniswap.mobile.dev" +>>>>>>> upstream/main + +if [ -z "$1" ]; then + echo "Usage: $0 " + exit 1 +fi + +user_id="$1" + +urls=( +<<<<<<< HEAD + "lux://wc?uri=wc:af098@2?relay-protocol=irn&symKey=51e" + "lux://wc:af098@2?relay-protocol=irn&symKey=51e" + "lux://scantastic?param=value" + "lux://uwulink?param=value" + "lux://redirect?screen=transaction&fiatOffRamp=true&userAddress=$user_id&externalTransactionId=123" + "https://lux.exchange/app?screen=swap&userAddress=$user_id&inputCurrencyId=1-0x6B175474E89094C44Da98b954EedeAC495271d0F&outputCurrencyId=1-0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48¤cyField=input&amount=100" + "https://lux.exchange/app?screen=transaction&fiatOnRamp=true&userAddress=$user_id" + "https://lux.exchange/app?screen=transaction&userAddress=$user_id" + "https://lux.exchange/app/wc?uri=wc:af098@2?relay-protocol=irn&symKey=51e" + "lux://app/fiatonramp?userAddress=$user_id&source=push" + "lux://app/tokendetails?currencyId=10-0x6fd9d7ad17242c41f7131d257212c54a0e816691&source=push" + "lux://app/tokendetails?currencyId=0-fwefe&source=push" # invalid currencyId +======= + "uniswap://wc?uri=wc:af098@2?relay-protocol=irn&symKey=51e" + "uniswap://wc:af098@2?relay-protocol=irn&symKey=51e" + "uniswap://scantastic?param=value" + "uniswap://uwulink?param=value" + "uniswap://redirect?screen=transaction&fiatOffRamp=true&userAddress=$user_id&externalTransactionId=123" + "https://uniswap.org/app?screen=swap&userAddress=$user_id&inputCurrencyId=1-0x6B175474E89094C44Da98b954EedeAC495271d0F&outputCurrencyId=1-0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48¤cyField=input&amount=100" + "https://uniswap.org/app?screen=transaction&fiatOnRamp=true&userAddress=$user_id" + "https://uniswap.org/app?screen=transaction&userAddress=$user_id" + "https://uniswap.org/app/wc?uri=wc:af098@2?relay-protocol=irn&symKey=51e" + "uniswap://app/fiatonramp?userAddress=$user_id&source=push" + "uniswap://app/tokendetails?currencyId=10-0x6fd9d7ad17242c41f7131d257212c54a0e816691&source=push" + "uniswap://app/tokendetails?currencyId=0-fwefe&source=push" # invalid currencyId +>>>>>>> upstream/main +) + +xcrun simctl terminate booted "$bundle_id" + +for url in "${urls[@]}"; do + echo "Opening URL: $url" + xcrun simctl openurl booted "$url" + sleep 10 + echo "Terminating app with bundle ID: $bundle_id" + xcrun simctl terminate booted "$bundle_id" +done diff --git a/apps/mobile/scripts/update_apollo_files_in_xcode.rb b/apps/mobile/scripts/update_apollo_files_in_xcode.rb new file mode 100755 index 00000000..ca4eed56 --- /dev/null +++ b/apps/mobile/scripts/update_apollo_files_in_xcode.rb @@ -0,0 +1,364 @@ +#!/usr/bin/env ruby + +require 'xcodeproj' +require 'fileutils' +require 'digest' + +# Debug mode +DEBUG = ENV['DEBUG'] == '1' + +# Helper functions +def debug(message) + puts "[DEBUG] #{message}" if DEBUG +end + +def log(message, symbol = "") + puts "#{symbol} #{message}" +end + +def log_success(message) + log(message, "✅") +end + +def log_warning(message) + log(message, "⚠️") +end + +def log_error(message) + log(message, "❌") +end + +def log_info(message) + log(message, "📝") +end + +# Project-specific functions +def find_group_by_name(parent_group, name) + return parent_group if parent_group.name == name + + parent_group.groups.each do |group| + result = find_group_by_name(group, name) + return result if result + end + + nil +end + +def print_groups(group, indent = 0) + puts "#{' ' * indent}+ #{group.name || 'Main Group'}" + group.groups.each do |subgroup| + print_groups(subgroup, indent + 2) + end +end + +def find_and_remove_group(project, name) + # Try to find the group using deep search + group_to_remove = find_group_by_name(project.main_group, name) + + if group_to_remove + log_info("Found existing #{name} group - removing it") + debug "Group path: #{group_to_remove.hierarchy_path}" + + # Remove all file references from build phases + if group_to_remove.recursive_children.any? + remove_files_from_build_phases(project, group_to_remove) + end + + # Remove the group itself + group_to_remove.remove_from_project + return true + end + + return false +end + +def remove_files_from_build_phases(project, group) + project.targets.each do |target| + target.build_phases.each do |phase| + # Check phase type dynamically instead of using the class name directly + next unless phase.is_a?(Xcodeproj::Project::AbstractBuildPhase) || + phase.class.name.end_with?('BuildPhase') + + files_to_remove = [] + phase.files.each do |build_file| + next unless build_file.file_ref + + if group.recursive_children.include?(build_file.file_ref) + files_to_remove << build_file + debug "Removing file from build phase: #{build_file.file_ref.path}" + end + end + + # Remove files from the phase + files_to_remove.each do |build_file| + phase.remove_build_file(build_file) + end + end + end +end + +def find_widgets_core_group(project) + # Look for common parent group names + ['WidgetsCore', 'Widgets', 'Sources'].each do |group_name| + group = find_group_by_name(project.main_group, group_name) + if group + log_success("Found #{group_name} group") + debug "Group path: #{group.hierarchy_path}" + return group + end + end + + # If we still can't find it, look in common places + project.main_group.groups.each do |group| + # Look inside the main project group or similarly named groups +<<<<<<< HEAD + if ['Lux', 'App', 'Sources'].include?(group.name) +======= + if ['Uniswap', 'App', 'Sources'].include?(group.name) +>>>>>>> upstream/main + group.groups.each do |subgroup| + if subgroup.name && ['WidgetsCore', 'Widgets', 'Sources'].include?(subgroup.name) + log_success("Found #{subgroup.name} group under #{group.name}") + debug "Group path: #{subgroup.hierarchy_path}" + return subgroup + end + end + end + end + + # If still not found, create it + log_error("Could not find WidgetsCore group in project") + log_info("Creating WidgetsCore group under project root...") + + group = project.main_group.new_group('WidgetsCore') + debug "Created new group: #{group.hierarchy_path}" + return group +end + +def find_target(project, target_name) + target = project.targets.find { |t| t.name == target_name } + + if target.nil? + log_error("Could not find #{target_name} target in project") + + # If we can't find the target by name, let's find a suitable alternative + # Look for a framework target that might be similar + framework_targets = project.targets.select { |t| t.product_type.include?('framework') } + + if framework_targets.empty? + log_error("No framework targets found. Please specify the correct target in the script.") + exit 1 + else + # Choose the first framework target as a fallback + target = framework_targets.first + log_info("Using '#{target.name}' as the target instead") + end + end + + return target +end + +def ensure_directory_exists(directory_path) + unless Dir.exist?(directory_path) + log_warning("Directory doesn't exist at #{directory_path}") + log_info("Creating directory...") + begin + FileUtils.mkdir_p(directory_path) + log_success("Created directory #{directory_path}") + rescue => e + log_error("Failed to create directory: #{e.message}") + end + end +end + +# Generate a fingerprint of all Swift files in the directory +def generate_files_fingerprint(directory) + swift_files = Dir.glob("#{directory}/**/*.swift").sort + return nil if swift_files.empty? + + # Create a digest of file paths and their contents to detect any change + digest = Digest::SHA256.new + swift_files.each do |file_path| + digest.update(file_path) + digest.update(File.read(file_path)) if File.exist?(file_path) + end + digest.hexdigest +end + +# Check if the MobileSchema group in the project matches the files on disk +def files_changed?(project, mobile_schema_dir) + # Generate a fingerprint of the current files + current_fingerprint = generate_files_fingerprint(mobile_schema_dir) + + # If no files exist, we need to make sure there's no group in the project + if current_fingerprint.nil? + existing_group = find_group_by_name(project.main_group, 'MobileSchema') + return existing_group != nil + end + + # Get the previous fingerprint from a temporary file + fingerprint_file = File.join(File.dirname(mobile_schema_dir), '.mobileschema_fingerprint') + previous_fingerprint = File.exist?(fingerprint_file) ? File.read(fingerprint_file).strip : nil + + # If fingerprints are different, update the fingerprint file and return true + if current_fingerprint != previous_fingerprint + FileUtils.mkdir_p(File.dirname(fingerprint_file)) unless Dir.exist?(File.dirname(fingerprint_file)) + File.write(fingerprint_file, current_fingerprint) + return true + end + + return false +end + +def add_files_to_project(project, mobile_schema_group, mobile_schema_dir, target) + # Find all Swift files in MobileSchema directory + swift_files = Dir.glob("#{mobile_schema_dir}/**/*.swift").sort + + if swift_files.empty? + log_warning("No Swift files found in #{mobile_schema_dir}!") + log_info("Make sure GraphQL code generation completed successfully.") + + # Save project structure anyway + project.save + log_success("Project structure was updated (but no files were added)") + return + end + + log_info("Found #{swift_files.length} Swift files in MobileSchema directory") + debug "Swift files:" + swift_files.each { |f| debug " - #{f}" } if DEBUG + + # Create subgroups based on directory structure + swift_dirs = group_files_by_directory(swift_files, mobile_schema_dir) + + # Create the group structure and add files + added_files = add_files_to_groups(swift_dirs, mobile_schema_group, target) + + if added_files > 0 + # Save project + project.save + log_success("Successfully added #{added_files} GraphQL files to the Xcode project") + else + log_warning("No GraphQL files were added") + project.save + log_success("Project structure was updated successfully") + end +end + +def group_files_by_directory(files, base_dir) + dirs = {} + files.each do |file_path| + directory = File.dirname(file_path) + relative_dir = directory.sub(base_dir, '') + relative_dir = relative_dir[1..-1] if relative_dir.start_with?('/') # Remove leading slash + + dirs[relative_dir] ||= [] + dirs[relative_dir] << file_path + end + dirs +end + +def add_files_to_groups(dirs_hash, root_group, target) + added_files = 0 + + # Sort directories to process them in a consistent order + dirs_hash.keys.sort.each do |relative_dir| + # Find or create group for this directory + current_group = root_group + unless relative_dir.empty? + relative_dir.split('/').each do |dir_name| + subgroup = current_group.children.find { |child| + child.is_a?(Xcodeproj::Project::Object::PBXGroup) && child.name == dir_name + } + if subgroup.nil? + subgroup = current_group.new_group(dir_name) + debug "Created new group: #{dir_name} under #{current_group.hierarchy_path}" + end + current_group = subgroup + end + end + + # Add all files in this directory to the group + dirs_hash[relative_dir].sort.each do |file_path| + file_name = File.basename(file_path) + + # Add file reference + file_ref = current_group.new_reference(file_path) + file_ref.source_tree = '' + debug "Added file reference: #{file_path} to group #{current_group.name}" + + # Add file to build phase + target.source_build_phase.add_file_reference(file_ref) + debug "Added file to build phase: #{file_name}" + + log_success("Added #{file_name} to #{current_group.name} group") + added_files += 1 + end + end + + added_files +end + +def main + # Paths +<<<<<<< HEAD + project_path = File.expand_path('../ios/Lux.xcodeproj', __dir__) +======= + project_path = File.expand_path('../ios/Uniswap.xcodeproj', __dir__) +>>>>>>> upstream/main + mobile_schema_dir = File.expand_path('../ios/WidgetsCore/MobileSchema', __dir__) + + log_info("Project path: #{project_path}") + log_info("Mobile schema directory: #{mobile_schema_dir}") + debug "Debug mode enabled" + + # Open the Xcode project + project = Xcodeproj::Project.open(project_path) + + # Check if files have changed before modifying the project + unless files_changed?(project, mobile_schema_dir) + log_success("No changes detected in GraphQL files - skipping project update") + return + end + + # List all targets for debugging + log_info("Project targets:") + project.targets.each_with_index do |target, index| + log("#{index}. #{target.name} (#{target.product_type})") + end + + # Find the WidgetsCore target + widgets_core_target = find_target(project, 'WidgetsCore') + + log_info("Processing GraphQL files in #{mobile_schema_dir}") + + # List all top-level groups + log_info("Project structure:") if DEBUG + print_groups(project.main_group) if DEBUG + + # First, try to find the root widgets group by path + widgets_root_path = File.expand_path('../ios/WidgetsCore', __dir__) + debug "Looking for group containing: #{widgets_root_path}" + + # First, remove any existing MobileSchema group + removed = find_and_remove_group(project, 'MobileSchema') + log_info("No existing MobileSchema group found") unless removed + + # Find or create the main widget group + widgets_core_group = find_widgets_core_group(project) + + # Create a new MobileSchema group + log_info("Creating new MobileSchema group under #{widgets_core_group.name}...") + mobile_schema_group = widgets_core_group.new_group('MobileSchema') + log_success("Created MobileSchema group under #{widgets_core_group.name}") + debug "Group path: #{mobile_schema_group.hierarchy_path}" + + # Check if directory exists + ensure_directory_exists(mobile_schema_dir) + + # Add files to project + add_files_to_project(project, mobile_schema_group, mobile_schema_dir, widgets_core_target) +end + +# Run the main function +main diff --git a/apps/mobile/src/app/(tabs)/_layout.tsx b/apps/mobile/src/app/(tabs)/_layout.tsx new file mode 100644 index 00000000..1f820417 --- /dev/null +++ b/apps/mobile/src/app/(tabs)/_layout.tsx @@ -0,0 +1,49 @@ +import { Tabs } from 'expo-router' + +export default function TabLayout() { + return ( + + + + + + + ) +} diff --git a/apps/mobile/src/app/(tabs)/activity.tsx b/apps/mobile/src/app/(tabs)/activity.tsx new file mode 100644 index 00000000..e02b754b --- /dev/null +++ b/apps/mobile/src/app/(tabs)/activity.tsx @@ -0,0 +1,43 @@ +import { View, Text, StyleSheet, ScrollView } from 'react-native' + +export default function ActivityScreen() { + return ( + + + 📋 + No Activity Yet + + Your transactions will appear here after you make your first swap + + + + ) +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#0f0f1a', + padding: 16, + justifyContent: 'center', + }, + emptyState: { + alignItems: 'center', + padding: 24, + }, + emptyIcon: { + fontSize: 48, + marginBottom: 16, + }, + emptyTitle: { + color: '#fff', + fontSize: 20, + fontWeight: '600', + marginBottom: 8, + }, + emptySubtitle: { + color: '#9ca3af', + fontSize: 14, + textAlign: 'center', + }, +}) diff --git a/apps/mobile/src/app/(tabs)/index.tsx b/apps/mobile/src/app/(tabs)/index.tsx new file mode 100644 index 00000000..6bf54e79 --- /dev/null +++ b/apps/mobile/src/app/(tabs)/index.tsx @@ -0,0 +1,130 @@ +import { View, Text, StyleSheet, TouchableOpacity, TextInput } from 'react-native' +import { useState } from 'react' +import { useRouter } from 'expo-router' + +export default function SwapScreen() { + const router = useRouter() + const [inputAmount, setInputAmount] = useState('') + const [outputAmount, setOutputAmount] = useState('') + + return ( + + + You pay + + + router.push('/token-select?type=input')} + > + LUX + + + + + + + + + + You receive + + + router.push('/token-select?type=output')} + > + LETH + + + + + + Connect Wallet + + + ) +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#0f0f1a', + padding: 16, + }, + card: { + backgroundColor: '#1a1a2e', + borderRadius: 16, + padding: 16, + marginBottom: 8, + }, + label: { + color: '#9ca3af', + fontSize: 14, + marginBottom: 8, + }, + inputRow: { + flexDirection: 'row', + alignItems: 'center', + }, + input: { + flex: 1, + color: '#fff', + fontSize: 32, + fontWeight: '500', + }, + tokenButton: { + backgroundColor: '#374151', + paddingHorizontal: 16, + paddingVertical: 8, + borderRadius: 20, + flexDirection: 'row', + alignItems: 'center', + }, + tokenText: { + color: '#fff', + fontSize: 18, + fontWeight: '600', + }, + swapButton: { + alignSelf: 'center', + backgroundColor: '#374151', + width: 40, + height: 40, + borderRadius: 20, + justifyContent: 'center', + alignItems: 'center', + marginVertical: 8, + }, + swapIcon: { + color: '#fff', + fontSize: 20, + }, + actionButton: { + backgroundColor: '#6366f1', + borderRadius: 16, + padding: 16, + alignItems: 'center', + marginTop: 16, + }, + actionButtonText: { + color: '#fff', + fontSize: 18, + fontWeight: '600', + }, +}) diff --git a/apps/mobile/src/app/(tabs)/pool.tsx b/apps/mobile/src/app/(tabs)/pool.tsx new file mode 100644 index 00000000..0ec029a8 --- /dev/null +++ b/apps/mobile/src/app/(tabs)/pool.tsx @@ -0,0 +1,88 @@ +import { View, Text, StyleSheet, ScrollView, TouchableOpacity } from 'react-native' + +const POOLS = [ + { id: 1, name: 'LUX/LETH', tvl: '$2.4M', apr: '12.5%' }, + { id: 2, name: 'LUX/LUSD', tvl: '$1.8M', apr: '8.2%' }, + { id: 3, name: 'LETH/LUSD', tvl: '$890K', apr: '6.8%' }, + { id: 4, name: 'LUX/LBTC', tvl: '$560K', apr: '15.3%' }, +] + +export default function PoolScreen() { + return ( + + + + Add Liquidity + + + + {POOLS.map((pool) => ( + + + {pool.name} + TVL: {pool.tvl} + + + {pool.apr} APR + + + ))} + + + ) +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#0f0f1a', + padding: 16, + }, + addButton: { + backgroundColor: '#6366f1', + borderRadius: 12, + padding: 16, + alignItems: 'center', + marginBottom: 16, + }, + addButtonText: { + color: '#fff', + fontSize: 16, + fontWeight: '600', + }, + poolList: { + flex: 1, + }, + poolCard: { + backgroundColor: '#1a1a2e', + borderRadius: 12, + padding: 16, + marginBottom: 12, + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + poolInfo: { + flex: 1, + }, + poolName: { + color: '#fff', + fontSize: 18, + fontWeight: '600', + marginBottom: 4, + }, + poolTvl: { + color: '#9ca3af', + fontSize: 14, + }, + aprBadge: { + backgroundColor: '#22c55e20', + paddingHorizontal: 12, + paddingVertical: 6, + borderRadius: 8, + }, + aprText: { + color: '#22c55e', + fontSize: 14, + fontWeight: '600', + }, +}) diff --git a/apps/mobile/src/app/(tabs)/portfolio.tsx b/apps/mobile/src/app/(tabs)/portfolio.tsx new file mode 100644 index 00000000..dd5b106f --- /dev/null +++ b/apps/mobile/src/app/(tabs)/portfolio.tsx @@ -0,0 +1,77 @@ +import { View, Text, StyleSheet, ScrollView } from 'react-native' + +export default function PortfolioScreen() { + return ( + + + Total Balance + $0.00 + Connect wallet to view + + + Your Tokens + + No tokens found + Connect your wallet to see your tokens + + + Your Positions + + No positions + Add liquidity to earn fees + + + ) +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#0f0f1a', + padding: 16, + }, + balanceCard: { + backgroundColor: '#1a1a2e', + borderRadius: 16, + padding: 24, + alignItems: 'center', + marginBottom: 24, + }, + balanceLabel: { + color: '#9ca3af', + fontSize: 14, + marginBottom: 8, + }, + balanceAmount: { + color: '#fff', + fontSize: 36, + fontWeight: '700', + marginBottom: 4, + }, + balanceChange: { + color: '#9ca3af', + fontSize: 14, + }, + sectionTitle: { + color: '#fff', + fontSize: 18, + fontWeight: '600', + marginBottom: 12, + }, + emptyState: { + backgroundColor: '#1a1a2e', + borderRadius: 12, + padding: 24, + alignItems: 'center', + marginBottom: 24, + }, + emptyStateText: { + color: '#9ca3af', + fontSize: 16, + marginBottom: 4, + }, + emptyStateSubtext: { + color: '#6b7280', + fontSize: 14, + }, +}) diff --git a/apps/mobile/src/app/App.tsx b/apps/mobile/src/app/App.tsx new file mode 100644 index 00000000..d88d4830 --- /dev/null +++ b/apps/mobile/src/app/App.tsx @@ -0,0 +1,398 @@ +import { ApolloProvider } from '@apollo/client' +import { loadDevMessages, loadErrorMessages } from '@apollo/client/dev' +import { DdRum, RumActionType } from '@datadog/mobile-react-native' +import { BottomSheetModalProvider } from '@gorhom/bottom-sheet' +import { PerformanceProfiler, type RenderPassReport } from '@shopify/react-native-performance' +import { ApiInit, getEntryGatewayUrl, provideSessionService } from '@universe/api' +import { + DatadogSessionSampleRateKey, + DynamicConfigs, + Experiments, + FeatureFlags, + getDynamicConfigValue, + getIsHashcashSolverEnabled, + getIsSessionServiceEnabled, + getIsSessionsPerformanceTrackingEnabled, + getIsSessionUpgradeAutoEnabled, + getIsTurnstileSolverEnabled, + getStatsigClient, + StatsigCustomAppValue, + type StatsigUser, + Storage, + useFeatureFlag, + useIsSessionServiceEnabled, + WALLET_FEATURE_FLAG_NAMES, +} from '@universe/gating' +import { + type ChallengeSolver, + ChallengeType, + createChallengeSolverService, + createHashcashMockSolver, + createHashcashSolver, + createPerformanceTracker, + createSessionInitializationService, + createTurnstileMockSolver, + type SessionInitializationService, +} from '@universe/sessions' +import { MMKVWrapper } from 'apollo3-cache-persist' +import { default as React, StrictMode, useCallback, useEffect, useMemo, useRef } from 'react' +import { I18nextProvider } from 'react-i18next' +import { NativeModules, StatusBar } from 'react-native' +import appsFlyer from 'react-native-appsflyer' +import DeviceInfo, { getUniqueIdSync } from 'react-native-device-info' +import { GestureHandlerRootView } from 'react-native-gesture-handler' +import { KeyboardProvider } from 'react-native-keyboard-controller' +import { MMKV } from 'react-native-mmkv' +import { OneSignal } from 'react-native-onesignal' +import { configureReanimatedLogger } from 'react-native-reanimated' +import { SafeAreaProvider } from 'react-native-safe-area-context' +import { enableFreeze } from 'react-native-screens' +import { useDispatch, useSelector } from 'react-redux' +import { PersistGate } from 'redux-persist/integration/react' +import { MobileWalletNavigationProvider } from 'src/app/MobileWalletNavigationProvider' +import { AppModals } from 'src/app/modals/AppModals' +import { useIsPartOfNavigationTree } from 'src/app/navigation/hooks' +import { AppStackNavigator } from 'src/app/navigation/navigation' +import { NavigationContainer } from 'src/app/navigation/NavigationContainer' +import { store } from 'src/app/store' +import { TraceUserProperties } from 'src/components/Trace/TraceUserProperties' +import { initAppsFlyer } from 'src/features/analytics/appsflyer' +import { useLogMissingMnemonic } from 'src/features/analytics/useLogMissingMnemonic' +import { useLogUnexpectedOnboardingReset } from 'src/features/analytics/useLogUnexpectedOnboardingReset' +import { useAppStateResetter } from 'src/features/appState/appStateResetter' +import { + DatadogProviderWrapper, + MOBILE_DEFAULT_DATADOG_SESSION_SAMPLE_RATE, +} from 'src/features/datadog/DatadogProviderWrapper' +import { setDatadogUserWithUniqueId } from 'src/features/datadog/user' +import { OneSignalUserTagField } from 'src/features/notifications/constants' +import { NotificationToastWrapper } from 'src/features/notifications/NotificationToastWrapper' +import { initOneSignal } from 'src/features/notifications/Onesignal' +import { createHashcashWorkerChannel } from 'src/features/sessions/createHashcashWorkerChannel' +import { statsigMMKVStorageProvider } from 'src/features/statsig/statsigMMKVStorageProvider' +import { shouldLogScreen } from 'src/features/telemetry/directLogScreens' +import { selectCustomEndpoint } from 'src/features/tweaks/selectors' +import { + processWidgetEvents, + setAccountAddressesUserDefaults, + setFavoritesUserDefaults, + setI18NUserDefaults, +} from 'src/features/widgets/widgets' +import { SystemBannerPortalProvider } from 'src/notification-service/notification-renderer/SystemBannerPortal' +import { initDynamicIntlPolyfills } from 'src/polyfills/intl-delayed' +import { useDatadogUserAttributesTracking } from 'src/screens/HomeScreen/useDatadogUserAttributesTracking' +import { useAppStateTrigger } from 'src/utils/useAppStateTrigger' +import { flexStyles, ImageSettingsProvider, useIsDarkMode } from 'ui/src' +import { TestnetModeBanner } from 'uniswap/src/components/banners/TestnetModeBanner' +import { BlankUrlProvider } from 'uniswap/src/contexts/UrlContext' +import { initializePortfolioQueryOverrides } from 'uniswap/src/data/rest/portfolioBalanceOverrides' +import { useCurrentAppearanceSetting } from 'uniswap/src/features/appearance/hooks' +import { selectFavoriteTokens } from 'uniswap/src/features/favorites/selectors' +import { useAppFiatCurrencyInfo } from 'uniswap/src/features/fiatCurrency/hooks' +import { StatsigProviderWrapper } from 'uniswap/src/features/gating/StatsigProviderWrapper' +import { mapLanguageToLocale } from 'uniswap/src/features/language/constants' +import { useCurrentLanguageInfo } from 'uniswap/src/features/language/hooks' +import { LocalizationContextProvider } from 'uniswap/src/features/language/LocalizationContext' +import { clearNotificationQueue } from 'uniswap/src/features/notifications/slice/slice' +import { TokenPriceProvider } from 'uniswap/src/features/prices/TokenPriceContext' +import { selectCurrentLanguage } from 'uniswap/src/features/settings/selectors' +import { MobileEventName } from 'uniswap/src/features/telemetry/constants' +import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import Trace from 'uniswap/src/features/telemetry/Trace' +import i18n, { changeLanguage } from 'uniswap/src/i18n' +import { type CurrencyId } from 'uniswap/src/types/currency' +import { datadogEnabledBuild } from 'utilities/src/environment/constants' +import { isTestEnv } from 'utilities/src/environment/env' +import { registerConsoleOverrides } from 'utilities/src/logger/console' +import { attachUnhandledRejectionHandler, setAttributesToDatadog } from 'utilities/src/logger/datadog/Datadog' +import { DDRumAction, DDRumTiming } from 'utilities/src/logger/datadog/datadogEvents' +import { getLogger, logger } from 'utilities/src/logger/logger' +import { isIOS } from 'utilities/src/platform' +import { AnalyticsNavigationContextProvider } from 'utilities/src/telemetry/trace/AnalyticsNavigationContext' +import { ErrorBoundary } from 'wallet/src/components/ErrorBoundary/ErrorBoundary' +// oxlint-disable-next-line no-restricted-imports -- Required for Apollo client initialization at app root +import { usePersistedApolloClient } from 'wallet/src/data/apollo/usePersistedApolloClient' +import { AccountsStoreContextProvider } from 'wallet/src/features/accounts/store/provider' +import { StatsigUserIdentifiersUpdater } from 'wallet/src/features/gating/StatsigUserIdentifiersUpdater' +import { useHeartbeatReporter } from 'wallet/src/features/telemetry/hooks/useHeartbeatReporter' +import { useLastBalancesReporter } from 'wallet/src/features/telemetry/hooks/useLastBalancesReporter' +import { selectAllowAnalytics } from 'wallet/src/features/telemetry/selectors' +import { useTestnetModeForLoggingAndAnalytics } from 'wallet/src/features/testnetMode/hooks/useTestnetModeForLoggingAndAnalytics' +import { WalletUniswapProvider } from 'wallet/src/features/transactions/contexts/WalletUniswapContext' +import { TransactionHistoryUpdater } from 'wallet/src/features/transactions/TransactionHistoryUpdater' +import { type Account } from 'wallet/src/features/wallet/accounts/types' +import { WalletContextProvider } from 'wallet/src/features/wallet/context' +import { useAccounts } from 'wallet/src/features/wallet/hooks' +import { NativeWalletProvider } from 'wallet/src/features/wallet/providers/NativeWalletProvider' +import { selectFinishedOnboarding } from 'wallet/src/features/wallet/selectors' +import { SharedWalletProvider as SharedWalletReduxProvider } from 'wallet/src/providers/SharedWalletProvider' +import { getReduxPersistor } from 'wallet/src/state/persistor' + +enableFreeze(true) + +if (__DEV__ && !isTestEnv()) { + registerConsoleOverrides() + // TODO(WALL-5780): Fix "Reading from `value` during component render." warnings while + // mainly switching between screens. + configureReanimatedLogger({ + strict: false, + }) + loadDevMessages() + loadErrorMessages() +} + +initDynamicIntlPolyfills() + +initOneSignal() +initAppsFlyer() + +initializePortfolioQueryOverrides({ store }) + +/** + * Wrapper component that provides the app state resetter to ErrorBoundary. + * Necessary to access the redux and query providers + */ +function ErrorBoundaryWrapper({ children }: { children: React.ReactNode }): JSX.Element { + const appStateResetter = useAppStateResetter() + return {children} +} + +const provideSessionInitializationService = (): SessionInitializationService => { + // Create performance tracker with feature flag control + // Platform-specific: uses React Native's performance.now() API + const performanceTracker = createPerformanceTracker({ + getIsPerformanceTrackingEnabled: getIsSessionsPerformanceTrackingEnabled, + getNow: () => performance.now(), + }) + + // Build solvers map based on feature flags + const solvers = new Map() + + if (getIsTurnstileSolverEnabled()) { + // Turnstile not supported on mobile - use mock + solvers.set(ChallengeType.TURNSTILE, createTurnstileMockSolver()) + } else { + solvers.set(ChallengeType.TURNSTILE, createTurnstileMockSolver()) + } + if (getIsHashcashSolverEnabled()) { + // Use real hashcash solver with native Nitro module + // The native implementation runs on background threads via platform-native APIs + solvers.set( + ChallengeType.HASHCASH, + createHashcashSolver({ + performanceTracker, + getWorkerChannel: () => createHashcashWorkerChannel(), + getLogger, + }), + ) + } else { + solvers.set(ChallengeType.HASHCASH, createHashcashMockSolver()) + } + + return createSessionInitializationService({ + getSessionService: () => + provideSessionService({ + getBaseUrl: getEntryGatewayUrl, + getIsSessionServiceEnabled, + getLogger, + }), + challengeSolverService: createChallengeSolverService({ + solvers, + getLogger, + }), + performanceTracker, + getIsSessionUpgradeAutoEnabled, + getLogger, + }) +} + +function App(): JSX.Element | null { + useEffect(() => { + if (!__DEV__) { + attachUnhandledRejectionHandler() + setAttributesToDatadog({ buildNumber: DeviceInfo.getBuildNumber() }).catch(() => undefined) + } + + setDatadogUserWithUniqueId(undefined) + }, []) + + const [datadogSessionSampleRate, setDatadogSessionSampleRate] = React.useState(undefined) + + Storage._setProvider(statsigMMKVStorageProvider) + + const statsigUser: StatsigUser = useMemo( + () => ({ + userID: getUniqueIdSync(), + custom: { + app: StatsigCustomAppValue.Mobile, + }, + }), + [], + ) + + const onStatsigInit = (): void => { + setDatadogSessionSampleRate( + getDynamicConfigValue({ + config: DynamicConfigs.DatadogSessionSampleRate, + key: DatadogSessionSampleRateKey.Rate, + defaultValue: MOBILE_DEFAULT_DATADOG_SESSION_SAMPLE_RATE, + }), + ) + } + + return ( + + + + + + + + + + + + + + + + + + + + ) +} + +const MAX_CACHE_SIZE_IN_BYTES = 1024 * 1024 * 25 // 25 MB + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} + +function AppInner(): JSX.Element { + const dispatch = useDispatch() + const isDarkMode = useIsDarkMode() + const themeSetting = useCurrentAppearanceSetting() + const allowAnalytics = useSelector(selectAllowAnalytics) + + // handles AppsFlyer enable/disable based on the allow analytics toggle + useEffect(() => { + if (allowAnalytics) { + appsFlyer.startSdk() + logger.debug('AppsFlyer', 'status', 'started') + } else { + appsFlyer.stop(!allowAnalytics, (res: unknown) => { + if (typeof res === 'string' && res === 'Success') { + logger.debug('AppsFlyer', 'status', 'stopped') + } else { + logger.warn('AppsFlyer', 'stop', `Got an error when trying to stop the AppsFlyer SDK: ${res}`) + } + }) + } + }, [allowAnalytics]) + + useEffect(() => { + dispatch(clearNotificationQueue()) // clear all in-app toasts on app start + }, [dispatch]) + + useEffect(() => { + // TODO: This is a temporary solution (it should be replaced with Appearance.setColorScheme + // after updating RN to 0.72.0 or higher) + NativeModules['ThemeModule'].setColorScheme(themeSetting) + }, [themeSetting]) + + useLogMissingMnemonic() + useLogUnexpectedOnboardingReset() + + return ( + + + + + + ) +} + +/** + * Background side effects that run in the background and are not part of the main app. + * A separate component is used to avoid unnecessary re-rendering of the main app when + * these services are running. + */ +function DataUpdaters(): JSX.Element { + const favoriteTokens: CurrencyId[] = useSelector(selectFavoriteTokens) + const accountsMap: Record = useAccounts() + const { locale } = useCurrentLanguageInfo() + const { code } = useAppFiatCurrencyInfo() + const finishedOnboarding = useSelector(selectFinishedOnboarding) + const isSessionServiceEnabled = useIsSessionServiceEnabled() + + useDatadogUserAttributesTracking({ isOnboarded: !!finishedOnboarding }) + useHeartbeatReporter({ isOnboarded: !!finishedOnboarding }) + useLastBalancesReporter({ isOnboarded: !!finishedOnboarding }) + useTestnetModeForLoggingAndAnalytics() + + // Refreshes widgets when bringing app to foreground + useAppStateTrigger({ from: 'background', to: 'active', callback: processWidgetEvents }) + + useEffect(() => { + setFavoritesUserDefaults(favoriteTokens) + }, [favoriteTokens]) + + useEffect(() => { + setAccountAddressesUserDefaults(Object.values(accountsMap)) + }, [accountsMap]) + + useEffect(() => { + setI18NUserDefaults({ locale, currency: code }) + }, [code, locale]) + + return ( + <> + + + + + + ) +} + +export default App diff --git a/apps/mobile/src/app/MobileWalletNavigationProvider.tsx b/apps/mobile/src/app/MobileWalletNavigationProvider.tsx new file mode 100644 index 00000000..5fc03f4d --- /dev/null +++ b/apps/mobile/src/app/MobileWalletNavigationProvider.tsx @@ -0,0 +1,368 @@ +import { StackActions } from '@react-navigation/native' +import { FeatureFlags, useFeatureFlag } from '@universe/gating' +import { PropsWithChildren, useCallback } from 'react' +import { Share } from 'react-native' +import { useDispatch } from 'react-redux' +import { exploreNavigationRef, navigationRef } from 'src/app/navigation/navigationRef' +import { useAppStackNavigation } from 'src/app/navigation/types' +import { useReactNavigationModal } from 'src/components/modals/useReactNavigationModal' +import { closeAllModals, closeModal, openModal } from 'src/features/modals/modalSlice' +import { useAdvancedSettingsMenuState } from 'src/features/settings/hooks/useAdvancedSettingsMenuState' +import { HomeScreenTabIndex } from 'src/screens/HomeScreen/HomeScreenTabIndex' +import { ScannerModalState } from 'uniswap/src/components/ReceiveQRCode/constants' +import { useEnabledChains } from 'uniswap/src/features/chains/hooks/useEnabledChains' +import { + useFiatOnRampAggregatorCountryListQuery, + useFiatOnRampAggregatorGetCountryQuery, +} from 'uniswap/src/features/fiatOnRamp/hooks/useFiatOnRampQueries' +import { RampDirection } from 'uniswap/src/features/fiatOnRamp/types' +import { useNavigateToNftExplorerLink } from 'uniswap/src/features/nfts/hooks/useNavigateToNftExplorerLink' +import { ModalName, WalletEventName } from 'uniswap/src/features/telemetry/constants' +import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { TransactionState } from 'uniswap/src/features/transactions/types/transactionState' +import { MobileScreens } from 'uniswap/src/types/screens/mobile' +import { ShareableEntity } from 'uniswap/src/types/sharing' +import { buildCurrencyId } from 'uniswap/src/utils/currencyId' +import { getTokenUrl } from 'uniswap/src/utils/linking' +import { closeKeyboardBeforeCallback } from 'utilities/src/device/keyboard/dismissNativeKeyboard' +import { logger } from 'utilities/src/logger/logger' +import { noop } from 'utilities/src/react/noop' +import { + getNavigateToSendFlowArgsInitialState, + getNavigateToSwapFlowArgsInitialState, + isNavigateToSwapFlowArgsPartialState, + NavigateToExternalProfileArgs, + NavigateToFiatOnRampArgs, + NavigateToSendFlowArgs, + NavigateToSwapFlowArgs, + ShareTokenArgs, + WalletNavigationProvider, +} from 'wallet/src/contexts/WalletNavigationContext' + +export function MobileWalletNavigationProvider({ children }: PropsWithChildren): JSX.Element { + const handleShareToken = useHandleShareToken() + const navigateToAccountActivityList = useNavigateToActivity() + const navigateToAccountTokenList = useNavigateToHomepageTab(HomeScreenTabIndex.Tokens) + const navigateToBuyOrReceiveWithEmptyWallet = useNavigateToBuyOrReceiveWithEmptyWallet() + const navigateToNftDetails = useNavigateToNftExplorerLink() + const navigateToReceive = useNavigateToReceive() + const navigateToSend = useNavigateToSend() + const navigateToSwapFlow = useNavigateToSwapFlow() + const navigateToTokenDetails = useNavigateToTokenDetails() + const navigateToFiatOnRamp = useNavigateToFiatOnRamp() + const navigateToExternalProfile = useNavigateToExternalProfile() + const navigateToAdvancedSettings = useNavigateToAdvancedSettings() + + return ( + + {children} + + ) +} + +function useHandleShareToken(): (args: ShareTokenArgs) => Promise { + return useCallback(async ({ currencyId }: ShareTokenArgs): Promise => { + const url = getTokenUrl(currencyId, true) + + if (!url) { + logger.error(new Error('Failed to get token URL'), { + tags: { file: 'MobileWalletNavigationProvider.tsx', function: 'useHandleShareToken' }, + extra: { currencyId }, + }) + return + } + + try { + await Share.share({ message: url }) + + sendAnalyticsEvent(WalletEventName.ShareButtonClicked, { + entity: ShareableEntity.Token, + url, + }) + } catch (error) { + logger.error(error, { + tags: { file: 'MobileWalletNavigationProvider.tsx', function: 'useHandleShareToken' }, + }) + } + }, []) +} + +function useNavigateToActivity(): () => void { + const { navigate } = useAppStackNavigation() + const isBottomTabsEnabled = useFeatureFlag(FeatureFlags.BottomTabs) + + const navigateToActivityTab = useNavigateToHomepageTab(HomeScreenTabIndex.Activity) + + const navigateToActivityScreen = useCallback((): void => { + navigate(MobileScreens.Activity) + }, [navigate]) + + return useCallback((): void => { + if (isBottomTabsEnabled) { + navigateToActivityScreen() + } else { + navigateToActivityTab() + } + }, [navigateToActivityTab, isBottomTabsEnabled, navigateToActivityScreen]) +} + +function useNavigateToHomepageTab(tab: HomeScreenTabIndex): () => void { + const { navigate } = useAppStackNavigation() + + return useCallback((): void => { + closeKeyboardBeforeCallback(() => { + navigate(MobileScreens.Home, { tab }) + }) + }, [navigate, tab]) +} + +function useNavigateToReceive(): () => void { + const dispatch = useDispatch() + + return useCallback((): void => { + closeKeyboardBeforeCallback(() => { + dispatch(openModal({ name: ModalName.WalletConnectScan, initialState: ScannerModalState.WalletQr })) + }) + }, [dispatch]) +} + +function useNavigateToSend(): (args: NavigateToSendFlowArgs) => void { + const dispatch = useDispatch() + + return useCallback( + (args: NavigateToSendFlowArgs) => { + closeKeyboardBeforeCallback(() => { + const initialSendState = getNavigateToSendFlowArgsInitialState(args) + dispatch(openModal({ name: ModalName.Send, initialState: initialSendState })) + }) + }, + [dispatch], + ) +} + +// Helper function for when coming from BridgedAsset modal (skip BridgedAsset step) +function navigateToSwapWithTokenWarning({ + navigation, + currencyId, + swapInitialState, +}: { + navigation: ReturnType + currencyId: string + swapInitialState?: TransactionState +}): void { + navigation.dispatch( + StackActions.replace(ModalName.TokenWarning, { + initialState: { + currencyId, + onAcknowledge: () => { + navigation.dispatch(StackActions.replace(ModalName.Swap, swapInitialState)) + }, + }, + }), + ) +} + +// Helper function for full flow: TokenWarning -> BridgedAsset -> Swap +function navigateToSwapWithFullFlow({ + navigation, + currencyId, + swapInitialState, +}: { + navigation: ReturnType + currencyId: string + swapInitialState?: TransactionState +}): void { + navigation.navigate(ModalName.TokenWarning, { + initialState: { + currencyId, + onAcknowledge: () => { + navigation.dispatch( + // Then replace TokenWarning with BridgedAsset + StackActions.replace(ModalName.BridgedAssetNav, { + initialState: { + currencyId, + onAcknowledge: () => { + // Then replace BridgedAsset with Swap + navigation.dispatch(StackActions.replace(ModalName.Swap, swapInitialState)) + }, + }, + }), + ) + }, + }, + }) +} + +function useNavigateToSwapFlow(): (args: NavigateToSwapFlowArgs) => void { + const { defaultChainId } = useEnabledChains() + const navigation = useAppStackNavigation() + const { onClose } = useReactNavigationModal() + + return useCallback( + (args: NavigateToSwapFlowArgs): void => { + closeKeyboardBeforeCallback(() => { + const initialState = getNavigateToSwapFlowArgsInitialState(args, defaultChainId) + + // If no prefilled token, go directly to swap + if (!isNavigateToSwapFlowArgsPartialState(args)) { + onClose() + navigation.navigate(ModalName.Swap, initialState) + return + } + + const currencyId = buildCurrencyId(args.currencyChainId, args.currencyAddress) + + // Show warning modal for prefilled tokens, which will handle token safety and bridged asset checks + // The happy path is we first show the token warning modal, then the bridged asset modal, then the swap modal + // However, if we are coming from BridgedAssetModal then we do not need to show it later + if (args.origin === ModalName.BridgedAsset) { + navigateToSwapWithTokenWarning({ navigation, currencyId, swapInitialState: initialState }) + } else { + navigateToSwapWithFullFlow({ navigation, currencyId, swapInitialState: initialState }) + } + }) + }, + [defaultChainId, navigation, onClose], + ) +} + +function useNavigateToTokenDetails(): (currencyId: string) => void { + const appNavigation = useAppStackNavigation() + const { onClose } = useReactNavigationModal() + const dispatch = useDispatch() + const isBottomTabsEnabled = useFeatureFlag(FeatureFlags.BottomTabs) + + return useCallback( + (currencyId: string): void => { + const currentNavRouteName = navigationRef.getCurrentRoute()?.name + const isExploreNavigationActuallyFocused = Boolean( + currentNavRouteName === ModalName.Explore && exploreNavigationRef.current && exploreNavigationRef.isFocused(), + ) + + closeKeyboardBeforeCallback(() => { + const route = navigationRef.getCurrentRoute() + const isSwap = route?.name === ModalName.Swap + const isExploreScreen = route?.name === MobileScreens.Explore + + dispatch(closeAllModals()) + + if (!isBottomTabsEnabled) { + if (isExploreNavigationActuallyFocused) { + exploreNavigationRef.navigate(MobileScreens.TokenDetails, { currencyId }) + return + } + + onClose() + appNavigation.reset({ + index: 1, + routes: [{ name: MobileScreens.Home }, { name: MobileScreens.TokenDetails, params: { currencyId } }], + }) + return + } + + if (isExploreScreen) { + // There's nothing to close on Explore with bottom tabs enabled + appNavigation.navigate(MobileScreens.TokenDetails, { currencyId }) + return + } + + onClose() + + if (isSwap) { + appNavigation.reset({ + index: 1, + routes: [{ name: MobileScreens.Home }, { name: MobileScreens.TokenDetails, params: { currencyId } }], + }) + return + } + + appNavigation.navigate(MobileScreens.TokenDetails, { currencyId }) + }) + }, + [appNavigation, dispatch, onClose, isBottomTabsEnabled], + ) +} + +function useNavigateToBuyOrReceiveWithEmptyWallet(): () => void { + const dispatch = useDispatch() + + const { data: countryResult } = useFiatOnRampAggregatorGetCountryQuery() + const { data: countryOptionsResult } = useFiatOnRampAggregatorCountryListQuery({ + rampDirection: RampDirection.ON_RAMP, + }) + const forAggregatorEnabled = countryOptionsResult?.supportedCountries.some( + (c) => c.countryCode === countryResult?.countryCode, + ) + + return useCallback((): void => { + closeKeyboardBeforeCallback(() => { + dispatch(closeModal({ name: ModalName.Send })) + + if (forAggregatorEnabled) { + dispatch(openModal({ name: ModalName.FiatOnRampAggregator })) + } else { + dispatch( + openModal({ + name: ModalName.WalletConnectScan, + initialState: ScannerModalState.WalletQr, + }), + ) + } + }) + }, [dispatch, forAggregatorEnabled]) +} + +function useNavigateToFiatOnRamp(): (args: NavigateToFiatOnRampArgs) => void { + const dispatch = useDispatch() + + return useCallback( + ({ prefilledCurrency, isOfframp }: NavigateToFiatOnRampArgs): void => { + closeKeyboardBeforeCallback(() => { + dispatch(openModal({ name: ModalName.FiatOnRampAggregator, initialState: { prefilledCurrency, isOfframp } })) + }) + }, + [dispatch], + ) +} + +function useNavigateToExternalProfile(): (args: NavigateToExternalProfileArgs) => void { + const appNavigation = useAppStackNavigation() + + return useCallback( + ({ address }: NavigateToExternalProfileArgs): void => { + closeKeyboardBeforeCallback(() => { + if (exploreNavigationRef.isFocused()) { + exploreNavigationRef.navigate(MobileScreens.ExternalProfile, { address }) + } else { + appNavigation.navigate(MobileScreens.ExternalProfile, { address }) + } + }) + }, + [appNavigation], + ) +} + +function useNavigateToAdvancedSettings(): () => void { + const navigation = useAppStackNavigation() + const advancedSettingsState = useAdvancedSettingsMenuState() + + return useCallback((): void => { + closeKeyboardBeforeCallback(() => { + navigation.navigate(ModalName.SmartWalletAdvancedSettingsModal, advancedSettingsState) + }) + }, [navigation, advancedSettingsState]) +} diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx new file mode 100644 index 00000000..dcb74870 --- /dev/null +++ b/apps/mobile/src/app/_layout.tsx @@ -0,0 +1,29 @@ +import { Stack } from 'expo-router' +import { GuiProvider } from '@hanzo/gui' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { StatusBar } from 'expo-status-bar' + +const queryClient = new QueryClient() + +export default function RootLayout() { + return ( + + + + + + + + + ) +} diff --git a/apps/mobile/src/app/globalActions.ts b/apps/mobile/src/app/globalActions.ts new file mode 100644 index 00000000..7cb6be57 --- /dev/null +++ b/apps/mobile/src/app/globalActions.ts @@ -0,0 +1,8 @@ +// Copied from https://github.com/Uniswap/interface/blob/main/src/state/global/actions.ts + +import { createAction } from '@reduxjs/toolkit' + +// fired once when the app reloads but before the app renders +// allows any updates to be applied to store data loaded from localStorage +// oxlint-disable-next-line import/no-unused-modules +export const updateVersion = createAction('global/updateVersion') diff --git a/apps/mobile/src/app/hooks.ts b/apps/mobile/src/app/hooks.ts new file mode 100644 index 00000000..7a60cf8f --- /dev/null +++ b/apps/mobile/src/app/hooks.ts @@ -0,0 +1,32 @@ +import { useFocusEffect } from '@react-navigation/core' +import { useCallback, useRef, useState } from 'react' + +const getNativeComponentKey = (): string => `native-component-${Math.random().toString()}` + +export function useNativeComponentKey(autoUpdate = true): { + key: string + triggerUpdate: () => void +} { + const isInitialRenderRef = useRef(true) + + const [key, setKey] = useState(getNativeComponentKey) + + useFocusEffect( + useCallback(() => { + if (isInitialRenderRef.current || !autoUpdate) { + isInitialRenderRef.current = false + return + } + setKey(getNativeComponentKey()) + }, [autoUpdate]), + ) + + const triggerUpdate = useCallback(() => { + setKey(getNativeComponentKey()) + }, []) + + return { + key, + triggerUpdate, + } +} diff --git a/apps/mobile/src/app/migrations.test.ts b/apps/mobile/src/app/migrations.test.ts new file mode 100644 index 00000000..6a450f91 --- /dev/null +++ b/apps/mobile/src/app/migrations.test.ts @@ -0,0 +1,686 @@ + testSetWalletDeviceLanguage, + testTransformNotificationCountToStatus, + testUpdateLanguageSettings, +} from 'src/app/mobileMigrationTests' +import { + getSchema, + initialSchema, + v1Schema, + v2Schema, + v3Schema, + v4Schema, + v5Schema, + v6Schema, + v7Schema, + v8Schema, + v9Schema, + v10Schema, + v11Schema, + v12Schema, + v13Schema, + v14Schema, + v15Schema, + v16Schema, + v17Schema, + v18Schema, + v19Schema, + v20Schema, + v21Schema, + v22Schema, + v23Schema, + v24Schema, + v25Schema, + v26Schema, + v27Schema, + v28Schema, + v29Schema, + v31Schema, + v32Schema, + v33Schema, + v34Schema, + v35Schema, + v36Schema, + v37Schema, + v38Schema, + v39Schema, + v40Schema, + v41Schema, + v42Schema, + v43Schema, + v44Schema, + v45Schema, + v46Schema, + v47Schema, + v48Schema, + v49Schema, + v50Schema, + v51Schema, + v52Schema, + v53Schema, + v54Schema, + v55Schema, + v56Schema, + v57Schema, + v58Schema, + v59Schema, + v60Schema, + v61Schema, + v62Schema, + v63Schema, + v64Schema, + v65Schema, + v66Schema, + v67Schema, + v68Schema, + v69Schema, + v70Schema, + v71Schema, + v72Schema, + v73Schema, + v74Schema, + v75Schema, + v76Schema, + v77Schema, + v78Schema, + v79Schema, + v80Schema, + v81Schema, + v82Schema, + v83Schema, + v84Schema, + v85Schema, + v86Schema, + v87Schema, + v88Schema, + v89Schema, + v90Schema, + v91Schema, + v92Schema, + v93Schema, + v95Schema, +import { ScannerModalState } from 'uniswap/src/components/ReceiveQRCode/constants' +import { USDC } from 'uniswap/src/constants/tokens' +import { AccountType } from 'uniswap/src/features/accounts/types' +import { initialAppearanceSettingsState } from 'uniswap/src/features/appearance/slice' +import { initialUniswapBehaviorHistoryState } from 'uniswap/src/features/behaviorHistory/slice' +import { UniverseChainId } from 'uniswap/src/features/chains/types' +import { initialFavoritesState } from 'uniswap/src/features/favorites/slice' +import { FiatCurrency } from 'uniswap/src/features/fiatCurrency/constants' +import { initialNotificationsState } from 'uniswap/src/features/notifications/slice/slice' +import { initialSearchHistoryState } from 'uniswap/src/features/search/searchHistorySlice' +import { initialUserSettingsState } from 'uniswap/src/features/settings/slice' +import { ModalName } from 'uniswap/src/features/telemetry/constants' +import { initialTokensState } from 'uniswap/src/features/tokens/warnings/slice/slice' +import { initialTransactionsState } from 'uniswap/src/features/transactions/slice' +import { TransactionStatus, TransactionType } from 'uniswap/src/features/transactions/types/transactionDetails' +import { initialVisibilityState } from 'uniswap/src/features/visibility/slice' +import { getWalletDeviceLanguage } from 'uniswap/src/i18n/utils' +import { + testAddActivityVisibility, + testMigrateDismissedTokenWarnings, + testMigrateSearchHistory, + testRemoveTHBFromCurrency, +} from 'uniswap/src/state/uniswapMigrationTests' +import { transactionDetails } from 'uniswap/src/test/fixtures' +import { DappRequestType } from 'uniswap/src/types/walletConnect' +import { getAllKeysOfNestedObject } from 'utilities/src/primitives/objects' +import { initialBatchedTransactionsState } from 'wallet/src/features/batchedTransactions/slice' +import { initialBehaviorHistoryState } from 'wallet/src/features/behaviorHistory/slice' +import { initialTelemetryState } from 'wallet/src/features/telemetry/slice' +import { Account, SignerMnemonicAccount } from 'wallet/src/features/wallet/accounts/types' +import { initialWalletState, SwapProtectionSetting } from 'wallet/src/features/wallet/slice' +import { createMigrate } from 'wallet/src/state/createMigrate' +import { HAYDEN_ETH_ADDRESS } from 'wallet/src/state/walletMigrations' +import { + testActivatePendingAccounts, + testAddBatchedTransactions, + testAddCreatedOnboardingRedesignAccount, + testAddExploreAndWelcomeBehaviorHistory, + testAddedHapticSetting, + testAddRoutingFieldToTransactions, + testDeleteBetaOnboardingState, + testDeleteDefaultFavoritesFromFavoritesState, + testDeleteExtensionOnboardingState, + testDeleteWelcomeWalletCard, + testMigrateLiquidityTransactionInfoRename, + testMovedCurrencySetting, + testMovedLanguageSetting, + testMovedTokenWarnings, + testMovedUserSettings, + testMoveHapticsToUserSettings, + testMoveTokenAndNFTVisibility, + testRemoveCreatedOnboardingRedesignAccount, + testRemoveHoldToSwap, + testRemovePriceAlertsEnabledFromPushNotifications, + testRemoveUniconV2BehaviorState, + testRemoveWalletIsUnlockedState, + testUnchecksumDismissedTokenWarningKeys, + testUpdateExploreOrderByType, +} from 'wallet/src/state/walletMigrationsTests' +import { signerMnemonicAccount } from 'wallet/src/test/fixtures' + +jest.mock('uniswap/src/i18n/utils', () => { + const actual = jest.requireActual('uniswap/src/i18n/utils') + const { Language } = + require('uniswap/src/features/language/constants') as typeof import('uniswap/src/features/language/constants') + return { + ...actual, + getWalletDeviceLanguage: jest.fn(() => Language.English), + } +}) + +expect.extend({ toIncludeSameMembers }) + +const account = signerMnemonicAccount() + +const txDetailsConfirmed = transactionDetails({ + status: TransactionStatus.Success, +}) +const fiatOnRampTxDetailsFailed = { + ...transactionDetails({ + status: TransactionStatus.Failed, + }), + typeInfo: { + type: TransactionType.FiatPurchaseDeprecated, + explorerUrl: + 'https://buy-sandbox.moonpay.com/transaction_receipt?transactionId=d6c32bb5-7cd9-4c22-8f46-6bbe786c599f', + id: 'd6c32bb5-7cd9-4c22-8f46-6bbe786c599f', + }, +} + +describe('Redux state migrations', () => { + it('is able to perform all migrations starting from the initial schema', async () => { + const initialSchemaStub = { + ...initialSchema, + _persist: { version: -1, rehydrated: false }, + } + + const migrate = createMigrate(migrations) + const migratedSchema = await migrate(initialSchemaStub, persistConfig.version) + expect(typeof migratedSchema).toBe('object') + }) + + // If this test fails then it's likely a required property was added to the Redux state but a migration was not defined + it('migrates all the properties correctly', async () => { + const initialSchemaStub = { + ...initialSchema, + _persist: { version: -1, rehydrated: false }, + } + + const migrate = createMigrate(migrations) + const migratedSchema = await migrate(initialSchemaStub, persistConfig.version) + + // Add new slices here! + const initialState = { + appearanceSettings: initialAppearanceSettingsState, + batchedTransactions: initialBatchedTransactionsState, + biometricSettings: initialBiometricsSettingsState, + blocks: { byChainId: {} }, + chains: { + byChainId: { + '1': { isActive: true }, + '10': { isActive: true }, + '137': { isActive: true }, + '42161': { isActive: true }, + }, + }, + ens: { ensForAddress: {} }, + favorites: initialFavoritesState, + fiatCurrencySettings: { currentCurrency: FiatCurrency.UnitedStatesDollar }, + modals: initialModalsState, + notifications: initialNotificationsState, + passwordLockout: initialPasswordLockoutState, + behaviorHistory: initialBehaviorHistoryState, + providers: { isInitialized: false }, + pushNotifications: initialPushNotificationsState, + saga: {}, + searchHistory: initialSearchHistoryState, + telemetry: initialTelemetryState, + tokenLists: {}, + tokens: initialTokensState, + transactions: initialTransactionsState, + tweaks: initialTweaksState, + uniswapBehaviorHistory: initialUniswapBehaviorHistoryState, + userSettings: initialUserSettingsState, + visibility: initialVisibilityState, + wallet: initialWalletState, + walletConnect: initialWalletConnectState, + _persist: { + version: persistConfig.version, + rehydrated: true, + }, + } + + if (!migratedSchema) { + throw new Error('Migrated schema is undefined') + } + + const migratedSchemaKeys = new Set(getAllKeysOfNestedObject(migratedSchema)) + const latestSchemaKeys = new Set(getAllKeysOfNestedObject(getSchema())) + const initialStateKeys = new Set(getAllKeysOfNestedObject(initialState)) + + for (const key of initialStateKeys) { + if (latestSchemaKeys.has(key)) { + latestSchemaKeys.delete(key) + } + if (migratedSchemaKeys.has(key)) { + migratedSchemaKeys.delete(key) + } + initialStateKeys.delete(key) + } + + expect(Array.from(migratedSchemaKeys)).toEqual([]) + expect(Array.from(latestSchemaKeys)).toEqual([]) + expect(Array.from(initialStateKeys)).toEqual([]) + }) + + // This is a precaution to ensure we do not attempt to access undefined properties during migrations + // If this test fails, make sure all property references to state are using optional chaining + it('uses optional chaining when accessing old state variables', async () => { + const emptyStub = { _persist: { version: -1, rehydrated: false } } + + const migrate = createMigrate(migrations) + const migratedSchema = await migrate(emptyStub, persistConfig.version) + expect(typeof migratedSchema).toBe('object') + }) + + it('migrates from initialSchema to v0Schema', () => { + testRestructureTransactionsAndNotifications(migrations[0], initialSchema) + }) + + it('migrates from v0 to v1', () => { + testRemoveWalletConnectModalState(migrations[1], migrations[0](initialSchema)) + }) + + it('migrates from v1 to v2', () => { + testRenameFollowedAddressesToWatchedAddresses(migrations[2], v1Schema) + }) + + it('migrates from v2 to v3', () => { + testAddSearchHistory(migrations[3], v2Schema) + }) + + it('migrates from v3 to v4', () => { + testAddTimeImportedAndDerivationIndex(migrations[4], v3Schema) + }) + + it('migrates from v4 to v5', () => { + testAddModalsState(migrations[5], v4Schema) + }) + + it('migrates from v5 to v6', () => { + testAddWalletConnectPendingSessionAndSettings(migrations[6], v5Schema) + }) + + it('migrates from v6 to v7', () => { + testRemoveNonZeroDerivationIndexAccounts(migrations[7], v6Schema) + }) + + it('migrates from v7 to v8', () => { + testAddCloudBackup(migrations[8], v7Schema) + }) + + it('migrates from v8 to v9', () => { + testRemoveLocalTypeAccounts(migrations[9], v8Schema) + }) + + it('migrates from v9 to v10', () => { + testRemoveDemoAccount(migrations[10], v9Schema) + }) + + it('migrates from v10 to v11', () => { + testAddBiometricSettings(migrations[11], v10Schema) + }) + + it('migrates from v11 to v12', () => { + testAddPushNotificationsEnabledToAccounts(migrations[12], v11Schema) + }) + + it('migrates from v12 to v13', () => { + testAddEnsState(migrations[13], v12Schema) + }) + + it('migrates from v13 to v14', () => { + testMigrateBiometricSettings(migrations[14], v13Schema) + }) + + it('migrates from v14 to v15', () => { + testChangeNativeTypeToSignerMnemonic(migrations[15], v14Schema) + }) + + it('migrates from v15 to v16', () => { + testRemoveDataApi(migrations[16], v15Schema) + }) + + it('migrates from v16 to v17', () => { + testResetPushNotificationsEnabled(migrations[17], v16Schema) + }) + + it('migrates from v17 to v18', () => { + testRemoveEnsState(migrations[18], v17Schema) + }) + + it('migrates from v18 to v19', () => { + testFilterToSupportedChains(migrations[19], v18Schema) + }) + + it('migrates from v19 to v20', () => { + testResetLastTxNotificationUpdate(migrations[20], v19Schema) + }) + + it('migrates from v20 to v21', () => { + testAddExperimentsSlice(migrations[21], v20Schema) + }) + + it('migrates from v21 to v22', () => { + testRemoveCoingeckoApiAndTokenLists(migrations[22], v21Schema) + }) + + it('migrates from v22 to v23', () => { + testResetTokensOrderByAndMetadataDisplayType(migrations[23], v22Schema) + }) + + it('migrates from v23 to v24', () => { + testTransformNotificationCountToStatus(migrations[24], v23Schema) + }) + + it('migrates from v24 to v25', () => { + testAddPasswordLockout(migrations[25], v24Schema) + }) + + it('migrates from v25 to v26', () => { + testRemoveShowSmallBalances(migrations[26], v25Schema) + }) + + it('migrates from v26 to v27', () => { + testResetTokensOrderBy(migrations[27], v26Schema) + }) + + it('migrates from v27 to v28', () => { + testRemoveTokensMetadataDisplayType(migrations[28], v27Schema) + }) + + it('migrates from v28 to v29', () => { + testRemoveTokenListsAndCustomTokens(migrations[29], v28Schema) + }) + + it('migrates from v29 to v30', () => { + testMigrateFiatPurchaseTransactionInfo(migrations[30], v29Schema, account, txDetailsConfirmed) + }) + + it('migrates from v31 to 32', () => { + testResetEnsApi(migrations[32], v31Schema) + }) + + it('migrates from v32 to 33', () => { + testAddReplaceAccountOptions(migrations[33], v32Schema) + }) + + it('migrates from v33 to 34', () => { + testAddLastBalancesReport(migrations[34], v33Schema) + }) + + it('migrates from v34 to 35', () => { + testAddAppearanceSetting(migrations[35], v34Schema) + }) + + it('migrates from v35 to 36', () => { + testAddHiddenNfts(migrations[36], v35Schema) + }) + + it('migrates from v36 to 37', () => { + testCorrectFailedFiatOnRampTxIds(migrations[37], v36Schema, account, fiatOnRampTxDetailsFailed, txDetailsConfirmed) + }) + + it('migrates from v37 to 38', () => { + testRemoveReplaceAccountOptions(migrations[38], v37Schema) + }) + + it('migrates from v38 to 39', () => { + testRemoveExperimentsSlice(migrations[39], v38Schema) + }) + + it('migrates from v39 to 40', () => { + testRemovePersistedWalletConnectSlice(migrations[40], v39Schema) + }) + + it('migrates from v40 to 41', () => { + testAddLastBalancesReportValue(migrations[41], v40Schema) + }) + + it('migrates from v41 to 42', () => { + testRemoveFlashbotsEnabledFromWalletSlice(migrations[42], v41Schema) + }) + + it('migrates from v42 to 43', () => { + testConvertHiddenNftsToNftsData(migrations[43], v42Schema) + }) + + it('migrates from v43 to v44', () => { + testRemoveProviders(migrations[44], v43Schema) + }) + + it('migrates from v44 to 45', () => { + testAddTokensVisibility(migrations[45], v44Schema) + }) + + it('migrates from v45 to 46', () => { + testDeleteRTKQuerySlices(migrations[46], v45Schema) + }) + + it('migrates from v46 to 47', () => { + testResetActiveChains(migrations[47], v46Schema) + }) + + it('migrates from v47 to 48', () => { + testAddTweaksStartingState(migrations[48], v47Schema) + }) + + it('migrates from v48 to 49', () => { + testAddSwapProtectionSetting(migrations[49], v48Schema) + }) + + it('migrates from v49 to 50', () => { + testDeleteChainsSlice(migrations[50], v49Schema) + }) + + it('migrates from v50 to 51', () => { + testAddLanguageSettings(migrations[51], v50Schema) + }) + + it('migrates from v51 to 52', () => { + testAddFiatCurrencySettings(migrations[52], v51Schema) + }) + + it('migrates from v52 to 53', () => { + testUpdateLanguageSettings(migrations[53], v52Schema) + }) + + it('migrates from v53 to 54', () => { + testAddWalletIsFunded(migrations[54], v53Schema) + }) + + it('migrates from v54 to 55', () => { + testAddBehaviorHistory(migrations[55], v54Schema) + }) + + it('migrates from v55 to 56', () => { + testAddAllowAnalyticsSwitch(migrations[56], v55Schema) + }) + + it('migrates from v56 to 57', () => { + testMoveSettingStateToGlobal(migrations[57], v56Schema) + }) + + it('migrates from v57 to 58', () => { + testAddSkippedUnitagBoolean(migrations[58], v57Schema) + }) + + it('migrates from v58 to 59', () => { + testAddCompletedUnitagsIntroBoolean(migrations[59], v58Schema) + }) + + it('migrates from v59 to 60', () => { + testAddUniconV2IntroModalBoolean(migrations[60], v59Schema) + }) + + it('migrates from v60 to 61', () => { + testFlattenTokenVisibility(migrations[61], v60Schema) + }) + + it('migrates from v61 to 62', () => { + testAddExtensionOnboardingState(migrations[62], v61Schema) + }) + + it('migrates from v62 to 63', () => { + testRemoveWalletIsUnlockedState(migrations[63], v62Schema) + }) + + it('migrates from v63 to 64', () => { + testRemoveUniconV2BehaviorState(migrations[64], v63Schema) + }) + + it('migrates from v64 to 65', () => { + testAddRoutingFieldToTransactions(migrations[65], v64Schema) + }) + it('migrates from v65 to v66', () => { + const v66 = migrations[66] + testActivatePendingAccounts(v66, v65Schema) + }) + + it('migrates from v66 to v67', () => { + testResetOnboardingStateForGA(migrations[67], v66Schema) + }) + + it('migrates from v67 to v68', () => { + testDeleteBetaOnboardingState(migrations[68], v67Schema) + }) + + it('migrates from v68 to v69', async () => { + testDeleteExtensionOnboardingState(migrations[69], v68Schema) + }) + + it('migrates from v69 to v70', async () => { + testDeleteDefaultFavoritesFromFavoritesState(migrations[70], v69Schema, HAYDEN_ETH_ADDRESS) + }) + + it('migrates from v70 to v71', async () => { + testAddedHapticSetting(migrations[71], v70Schema) + }) + + it('migrates from v71 to v72', () => { + testAddExploreAndWelcomeBehaviorHistory(migrations[72], v71Schema) + }) + + it('migrates from v72 to v73', async () => { + testMovedUserSettings(migrations[73], v72Schema) + }) + + it('migrates from v73 to v74', () => { + testDeleteOldOnRampTxData(migrations[74], v73Schema, account, txDetailsConfirmed) + }) + + it('migrates from v74 to v75', () => { + testRemoveHoldToSwap(migrations[75], v74Schema) + }) + + it('migrates from v75 to v76', () => { + testAddCreatedOnboardingRedesignAccount(migrations[76], v75Schema) + }) + + it('migrates from v76 to v77', async () => { + testMovedTokenWarnings(migrations[77], v76Schema) + }) + + it('migrates from v77 to v78', async () => { + testMovedLanguageSetting(migrations[78], v77Schema) + }) + + it('migrates from v78 to v79', async () => { + testMovedCurrencySetting(migrations[79], v78Schema) + }) + + it('migrates from v79 to v80', async () => { + testUpdateExploreOrderByType(migrations[80], v79Schema) + }) + + it('migrates from v80 to v81', async () => { + testRemoveCreatedOnboardingRedesignAccount(migrations[81], v80Schema) + }) + + it('migrates from v81 to v82', () => { + testUnchecksumDismissedTokenWarningKeys(migrations[82], v81Schema) + }) + + it('migrates from v82 to v83', () => { + testAddPushNotifications(migrations[83], v82Schema) + }) + + it('migrates from v83 to v84', () => { + testDeleteWelcomeWalletCard(migrations[84], v83Schema) + }) + + it('migrates from v84 to v85', () => { + testMoveTokenAndNFTVisibility(migrations[85], v84Schema) + }) + + it('migrates from v85 to v86', () => { + testAddBatchedTransactions(migrations[86], v85Schema) + }) + + it('migrates from v86 to v87', () => { + testMigrateDappRequestInfoTypes(migrations[87], v86Schema) + }) + + it('migrates from v87 to v88', () => { + testMoveHapticsToUserSettings(migrations[88], v87Schema) + }) + + it('migrates from v88 to v89', () => { + const v88Stub = { ...v88Schema, userSettings: { ...v88Schema.userSettings, currentCurrency: 'THB' } } + testRemoveTHBFromCurrency(migrations[89], v88Stub) + + const v88Stub2 = { ...v88Schema, userSettings: { ...v88Schema.userSettings, currentCurrency: 'JPY' } } + testRemoveTHBFromCurrency(migrations[89], v88Stub2) + }) + + it('migrates from v89 to v90', () => { + testMigrateLiquidityTransactionInfoRename(migrations[90], v89Schema) + }) + + it('migrates from v90 to v91', () => { + testRemovePriceAlertsEnabledFromPushNotifications(migrations[91], v90Schema) + }) + + it('migrates from v91 to v92', () => { + testMigrateAndRemoveCloudBackupSlice(migrations[92], v91Schema) + }) + + it('migrates from v92 to v93', () => { + testMigrateSearchHistory(migrations[93], v92Schema) + }) + + it('migrates from v93 to v95', () => { + testAddActivityVisibility(migrations[95], v93Schema) + }) + + it('migrates from v95 to v96', () => { + testMigrateDismissedTokenWarnings(migrations[96], { + ...v95Schema, + tokens: { + dismissedTokenWarnings: { + [UniverseChainId.Mainnet]: { + [USDC.address]: { + chainId: UniverseChainId.Mainnet, + address: USDC.address, + }, + }, + }, + }, + }) + }) + + it('migrates from v96 to v97', () => { + testSetWalletDeviceLanguage(migrations[97], v96Schema, jest.mocked(getWalletDeviceLanguage)) + }) +}) diff --git a/apps/mobile/src/app/migrations.ts b/apps/mobile/src/app/migrations.ts new file mode 100644 index 00000000..ebbae46d --- /dev/null +++ b/apps/mobile/src/app/migrations.ts @@ -0,0 +1,196 @@ +import { + addAllowAnalyticsSwitch, + addAppearanceSetting, + addBehaviorHistory, + addBiometricSettings, + addCloudBackup, + addCompletedUnitagsIntroBoolean, + addEnsState, + addExperimentsSlice, + addExtensionOnboardingState, + addFiatCurrencySettings, + addHiddenNfts, + addLanguageSettings, + addLastBalancesReport, + addLastBalancesReportValue, + addModalsState, + addPasswordLockout, + addPushNotifications, + addPushNotificationsEnabledToAccounts, + addReplaceAccountOptions, + addSearchHistory, + addSkippedUnitagBoolean, + addSwapProtectionSetting, + addTimeImportedAndDerivationIndex, + addTokensVisibility, + addTweaksStartingState, + addUniconV2IntroModalBoolean, + addWalletConnectPendingSessionAndSettings, + addWalletIsFunded, + changeNativeTypeToSignerMnemonic, + convertHiddenNftsToNftsData, + correctFailedFiatOnRampTxIds, + deleteChainsSlice, + deleteOldOnRampTxData, + deleteRTKQuerySlices, + emptyMigration, + filterToSupportedChains, + flattenTokenVisibility, + migrateAndRemoveCloudBackupSlice, + migrateBiometricSettings, + migrateDappRequestInfoTypes, + migrateFiatPurchaseTransactionInfo, + moveSettingStateToGlobal, + removeCoingeckoApiAndTokenLists, + removeDataApi, + removeDemoAccount, + removeEnsState, + removeExperimentsSlice, + removeFlashbotsEnabledFromWalletSlice, + removeLocalTypeAccounts, + removeNonZeroDerivationIndexAccounts, + removePersistedWalletConnectSlice, + removeProviders, + removeReplaceAccountOptions, + removeShowSmallBalances, + removeTokenListsAndCustomTokens, + removeTokensMetadataDisplayType, + removeWalletConnectModalState, + renameFollowedAddressesToWatchedAddresses, + resetActiveChains, + resetEnsApi, + resetLastTxNotificationUpdate, + resetOnboardingStateForGA, + resetPushNotificationsEnabled, + resetTokensOrderBy, + resetTokensOrderByAndMetadataDisplayType, + restructureTransactionsAndNotifications, +} from 'uniswap/src/state/uniswapMigrations' +import { + activatePendingAccounts, + addBatchedTransactions, + addCreatedOnboardingRedesignAccountBehaviorHistory, + addExploreAndWelcomeBehaviorHistory, + addHapticSetting, + addRoutingFieldToTransactions, + deleteBetaOnboardingState, + deleteDefaultFavoritesFromFavoritesState, + deleteExtensionOnboardingState, + deleteHoldToSwapBehaviorHistory, + deleteWelcomeWalletCardBehaviorHistory, + migrateLiquidityTransactionInfo, + moveCurrencySetting, + moveDismissedTokenWarnings, + moveHapticsToUserSettings, + moveLanguageSetting, + moveTokenAndNFTVisibility, + moveUserSettings, + removeCreatedOnboardingRedesignAccountBehaviorHistory, + removePriceAlertsEnabledFromPushNotifications, + removeUniconV2BehaviorState, + removeWalletIsUnlockedState, + updateExploreOrderByType, +} from 'wallet/src/state/walletMigrations' + +export const migrations = { + 0: restructureTransactionsAndNotifications, + 1: removeWalletConnectModalState, + 2: renameFollowedAddressesToWatchedAddresses, + 3: addSearchHistory, + 4: addTimeImportedAndDerivationIndex, + 5: addModalsState, + 6: addWalletConnectPendingSessionAndSettings, + 7: removeNonZeroDerivationIndexAccounts, + 8: addCloudBackup, + 9: removeLocalTypeAccounts, + 10: removeDemoAccount, + 11: addBiometricSettings, + 12: addPushNotificationsEnabledToAccounts, + 13: addEnsState, + 14: migrateBiometricSettings, + 15: changeNativeTypeToSignerMnemonic, + 16: removeDataApi, + 17: resetPushNotificationsEnabled, + 18: removeEnsState, + 19: filterToSupportedChains, + 20: resetLastTxNotificationUpdate, + 21: addExperimentsSlice, + 22: removeCoingeckoApiAndTokenLists, + 23: resetTokensOrderByAndMetadataDisplayType, + 24: transformNotificationCountToStatus, + 25: addPasswordLockout, + 26: removeShowSmallBalances, + 27: resetTokensOrderBy, + 28: removeTokensMetadataDisplayType, + 29: removeTokenListsAndCustomTokens, + 30: migrateFiatPurchaseTransactionInfo, + 31: emptyMigration, + 32: resetEnsApi, + 33: addReplaceAccountOptions, + 34: addLastBalancesReport, + 35: addAppearanceSetting, + 36: addHiddenNfts, + 37: correctFailedFiatOnRampTxIds, + 38: removeReplaceAccountOptions, + 39: removeExperimentsSlice, + 40: removePersistedWalletConnectSlice, + 41: addLastBalancesReportValue, + 42: removeFlashbotsEnabledFromWalletSlice, + 43: convertHiddenNftsToNftsData, + 44: removeProviders, + 45: addTokensVisibility, + 46: deleteRTKQuerySlices, + 47: resetActiveChains, + 48: addTweaksStartingState, + 49: addSwapProtectionSetting, + 50: deleteChainsSlice, + 51: addLanguageSettings, + 52: addFiatCurrencySettings, + 53: updateLanguageSettings, + 54: addWalletIsFunded, + 55: addBehaviorHistory, + 56: addAllowAnalyticsSwitch, + 57: moveSettingStateToGlobal, + 58: addSkippedUnitagBoolean, + 59: addCompletedUnitagsIntroBoolean, + 60: addUniconV2IntroModalBoolean, + 61: flattenTokenVisibility, + 62: addExtensionOnboardingState, + 63: removeWalletIsUnlockedState, + 64: removeUniconV2BehaviorState, + 65: addRoutingFieldToTransactions, + 66: activatePendingAccounts, + 67: resetOnboardingStateForGA, + 68: deleteBetaOnboardingState, + 69: deleteExtensionOnboardingState, + 70: deleteDefaultFavoritesFromFavoritesState, + 71: addHapticSetting, + 72: addExploreAndWelcomeBehaviorHistory, + 73: moveUserSettings, + 74: deleteOldOnRampTxData, + 75: deleteHoldToSwapBehaviorHistory, + 76: addCreatedOnboardingRedesignAccountBehaviorHistory, + 77: moveDismissedTokenWarnings, + 78: moveLanguageSetting, + 79: moveCurrencySetting, + 80: updateExploreOrderByType, + 81: removeCreatedOnboardingRedesignAccountBehaviorHistory, + 82: unchecksumDismissedTokenWarningKeys, + 83: addPushNotifications, + 84: deleteWelcomeWalletCardBehaviorHistory, + 85: moveTokenAndNFTVisibility, + 86: addBatchedTransactions, + 87: migrateDappRequestInfoTypes, + 88: moveHapticsToUserSettings, + 89: removeThaiBahtFromFiatCurrency, + 90: migrateLiquidityTransactionInfo, + 91: removePriceAlertsEnabledFromPushNotifications, + 92: migrateAndRemoveCloudBackupSlice, + 93: migrateSearchHistory, + 94: addDismissedBridgedAndCompatibleWarnings, + 95: addActivityVisibility, + 96: migrateDismissedTokenWarnings, + 97: setWalletDeviceLanguage, +} + +export const MOBILE_STATE_VERSION = 97 diff --git a/apps/mobile/src/app/mobileMigrationTests.ts b/apps/mobile/src/app/mobileMigrationTests.ts new file mode 100644 index 00000000..6fe5a9cc --- /dev/null +++ b/apps/mobile/src/app/mobileMigrationTests.ts @@ -0,0 +1,1422 @@ +/** + * Test helpers for testing migrations run in sequence. + * + * Called by migrations.test.ts to verify migrations work correctly with realistic + * data that has passed through all prior migrations in the chain. + * + * For unit tests of individual migrations, see mobileMigrations.test.ts. + */ +/* oxlint-disable typescript/no-explicit-any -- Migration test functions need flexible any types */ +/* oxlint-disable max-lines */ +/* oxlint-disable max-params */ +import { BigNumber } from '@ethersproject/bignumber' +import mockdate from 'mockdate' +import { OLD_DEMO_ACCOUNT_ADDRESS } from 'src/app/mobileMigrations' +import { ScannerModalState } from 'uniswap/src/components/ReceiveQRCode/constants' +import { AccountType } from 'uniswap/src/features/accounts/types' +import { UniverseChainId } from 'uniswap/src/features/chains/types' +import { Language } from 'uniswap/src/features/language/constants' +import { ModalName } from 'uniswap/src/features/telemetry/constants' +import { TransactionStatus, TransactionType } from 'uniswap/src/features/transactions/types/transactionDetails' +import { DappRequestType } from 'uniswap/src/types/walletConnect' +import { type Account, type SignerMnemonicAccount } from 'wallet/src/features/wallet/accounts/types' +import { SwapProtectionSetting } from 'wallet/src/features/wallet/slice' + +export function testRestructureTransactionsAndNotifications(migration: (state: any) => any, prevSchema: any): void { + const txDetails0 = { + chainId: UniverseChainId.Mainnet, + id: '0', + from: '0xShadowySuperCoder', + options: { + request: { + from: '0x123', + to: '0x456', + value: '0x0', + data: '0x789', + nonce: 10, + gasPrice: BigNumber.from('10000'), + }, + }, + typeInfo: { + type: TransactionType.Approve, + tokenAddress: '0xtokenAddress', + spender: '0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45', + }, + status: TransactionStatus.Pending, + addedTime: 1487076708000, + hash: '0x123', + } + + const txDetails1 = { + chainId: UniverseChainId.Optimism, + id: '1', + from: '0xKingHodler', + options: { + request: { + from: '0x123', + to: '0x456', + value: '0x0', + data: '0x789', + nonce: 10, + gasPrice: BigNumber.from('10000'), + }, + }, + typeInfo: { + type: TransactionType.Approve, + tokenAddress: '0xtokenAddress', + spender: '0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45', + }, + status: TransactionStatus.Success, + addedTime: 1487076708000, + hash: '0x123', + } + + const initialSchemaStub = { + ...prevSchema, + transactions: { + byChainId: { + [UniverseChainId.Mainnet]: { + '0': txDetails0, + }, + [UniverseChainId.Optimism]: { + '1': txDetails1, + }, + }, + lastTxHistoryUpdate: { + '0xShadowySuperCoder': 12345678912345, + '0xKingHodler': 9876543210987, + }, + }, + } + + const newSchema = migration(initialSchemaStub) + expect(newSchema.transactions[UniverseChainId.Mainnet]).toBeUndefined() + expect(newSchema.transactions.lastTxHistoryUpdate).toBeUndefined() + + expect(newSchema.transactions['0xShadowySuperCoder'][UniverseChainId.Mainnet]['0'].status).toEqual( + TransactionStatus.Pending, + ) + expect(newSchema.transactions['0xKingHodler'][UniverseChainId.Mainnet]).toBeUndefined() + expect(newSchema.transactions['0xKingHodler'][UniverseChainId.Optimism]['0']).toBeUndefined() + expect(newSchema.transactions['0xKingHodler'][UniverseChainId.Optimism]['1'].from).toEqual('0xKingHodler') + + expect(newSchema.notifications.lastTxNotificationUpdate).toBeDefined() + expect(newSchema.notifications.lastTxNotificationUpdate['0xShadowySuperCoder'][UniverseChainId.Mainnet]).toEqual( + 12345678912345, + ) +} + +export function testRemoveWalletConnectModalState(migration: (state: any) => any, prevSchema: any): void { + const v0Stub = { + ...prevSchema, + walletConnect: { + ...prevSchema.wallet, + modalState: ScannerModalState.ScanQr, + }, + } + + const v1 = migration(v0Stub) + expect(v1.walletConnect.modalState).toEqual(undefined) +} + +export function testRenameFollowedAddressesToWatchedAddresses(migration: (state: any) => any, prevSchema: any): void { + const TEST_ADDRESSES = ['0xTest'] + + const v1SchemaStub = { + ...prevSchema, + favorites: { + ...prevSchema.favorites, + followedAddresses: TEST_ADDRESSES, + }, + } + + const v2 = migration(v1SchemaStub) + + expect(v2.favorites.watchedAddresses).toEqual(TEST_ADDRESSES) + expect(v2.favorites.followedAddresses).toBeUndefined() +} + +export function testAddSearchHistory(migration: (state: any) => any, prevSchema: any): void { + const v3 = migration(prevSchema) + expect(v3.searchHistory.results).toEqual([]) +} + +export function testAddTimeImportedAndDerivationIndex(migration: (state: any) => any, prevSchema: any): void { + const TEST_ADDRESSES = ['0xTest', '0xTest2', '0xTest3', '0xTest4'] + const TEST_IMPORT_TIME_MS = 12345678912345 + + const v3SchemaStub = { + ...prevSchema, + wallet: { + ...prevSchema.wallet, + accounts: [ + { + type: AccountType.Readonly, + address: TEST_ADDRESSES[0], + name: 'Test Account 1', + pending: false, + }, + { + type: AccountType.Readonly, + address: TEST_ADDRESSES[1], + name: 'Test Account 2', + pending: false, + }, + { + type: 'native', + address: TEST_ADDRESSES[2], + name: 'Test Account 3', + pending: false, + }, + { + type: 'native', + address: TEST_ADDRESSES[3], + name: 'Test Account 4', + pending: false, + }, + ], + }, + } + + mockdate.set(TEST_IMPORT_TIME_MS) + + const v4 = migration(v3SchemaStub) + expect(v4.wallet.accounts[0].timeImportedMs).toEqual(TEST_IMPORT_TIME_MS) + expect(v4.wallet.accounts[2].derivationIndex).toBeDefined() +} + +export function testAddModalsState(migration: (state: any) => any, prevSchema: any): void { + const v5 = migration(prevSchema) + + expect(prevSchema.balances).toBeDefined() + expect(v5.balances).toBeUndefined() + + expect(v5.modals[ModalName.Swap].isOpen).toEqual(false) + expect(v5.modals[ModalName.Send].isOpen).toEqual(false) +} + +export function testAddWalletConnectPendingSessionAndSettings(migration: (state: any) => any, prevSchema: any): void { + const v6 = migration(prevSchema) + + expect(v6.walletConnect.pendingSession).toBe(null) + + expect(typeof v6.wallet.settings).toBe('object') + + expect(prevSchema.wallet.bluetooth).toBeDefined() + expect(v6.wallet.bluetooth).toBeUndefined() +} + +export function testRemoveNonZeroDerivationIndexAccounts(migration: (state: any) => any, prevSchema: any): void { + const TEST_ADDRESSES: [string, string, string, string] = ['0xTest', '0xTest2', '0xTest3', '0xTest4'] + const TEST_IMPORT_TIME_MS = 12345678912345 + + const v6SchemaStub = { + ...prevSchema, + wallet: { + ...prevSchema.wallet, + accounts: { + [TEST_ADDRESSES[0]]: { + type: 'native', + address: TEST_ADDRESSES[0], + name: 'Test Account 1', + pending: false, + derivationIndex: 0, + timeImportedMs: TEST_IMPORT_TIME_MS, + }, + [TEST_ADDRESSES[1]]: { + type: 'native', + address: TEST_ADDRESSES[1], + name: 'Test Account 2', + pending: false, + derivationIndex: 1, + timeImportedMs: TEST_IMPORT_TIME_MS, + }, + [TEST_ADDRESSES[2]]: { + type: 'native', + address: TEST_ADDRESSES[2], + name: 'Test Account 3', + pending: false, + derivationIndex: 2, + timeImportedMs: TEST_IMPORT_TIME_MS, + }, + [TEST_ADDRESSES[3]]: { + type: 'native', + address: TEST_ADDRESSES[3], + name: 'Test Account 4', + pending: false, + derivationIndex: 3, + timeImportedMs: TEST_IMPORT_TIME_MS, + }, + }, + }, + } + + expect(Object.values(v6SchemaStub.wallet.accounts)).toHaveLength(4) + const v7 = migration(v6SchemaStub) + + const accounts = Object.values(v7.wallet.accounts) as SignerMnemonicAccount[] + expect(accounts).toHaveLength(1) + expect(accounts[0]?.mnemonicId).toEqual(TEST_ADDRESSES[0]) +} + +export function testAddCloudBackup(migration: (state: any) => any, prevSchema: any): void { + const v8 = migration(prevSchema) + expect(v8.cloudBackup.backupsFound).toEqual([]) +} + +export function testRemoveLocalTypeAccounts(migration: (state: any) => any, prevSchema: any): void { + const TEST_ADDRESSES: [string, string] = ['0xTest', '0xTest2'] + const TEST_IMPORT_TIME_MS = 12345678912345 + + const v8SchemaStub = { + ...prevSchema, + wallet: { + ...prevSchema.wallet, + accounts: { + [TEST_ADDRESSES[0]]: { + type: 'native', + address: TEST_ADDRESSES[0], + name: 'Test Account 1', + pending: false, + derivationIndex: 0, + timeImportedMs: TEST_IMPORT_TIME_MS, + }, + [TEST_ADDRESSES[1]]: { + type: 'local', + address: TEST_ADDRESSES[1], + name: 'Test Account 2', + pending: false, + timeImportedMs: TEST_IMPORT_TIME_MS, + }, + }, + }, + } + + expect(Object.values(v8SchemaStub.wallet.accounts)).toHaveLength(2) + const v9 = migration(v8SchemaStub) + expect(Object.values(v9.wallet.accounts)).toHaveLength(1) +} + +export function testRemoveDemoAccount(migration: (state: any) => any, prevSchema: any): void { + const TEST_ADDRESSES = ['0xTest', OLD_DEMO_ACCOUNT_ADDRESS, '0xTest2', '0xTest3'] + const TEST_IMPORT_TIME_MS = 12345678912345 + + const accounts = TEST_ADDRESSES.reduce( + (acc, address) => { + acc[address] = { + address, + timeImportedMs: TEST_IMPORT_TIME_MS, + type: 'native', + } as unknown as Account + + return acc + }, + {} as { [address: string]: Account }, + ) + + const v9SchemaStub = { + ...prevSchema, + wallet: { + ...prevSchema.wallet, + accounts, + }, + } + + expect(Object.values(v9SchemaStub.wallet.accounts)).toHaveLength(4) + expect(Object.keys(v9SchemaStub.wallet.accounts)).toContain(OLD_DEMO_ACCOUNT_ADDRESS) + + const migratedSchema = migration(v9SchemaStub) + expect(Object.values(migratedSchema.wallet.accounts)).toHaveLength(3) + expect(Object.keys(migratedSchema.wallet.accounts)).not.toContain(OLD_DEMO_ACCOUNT_ADDRESS) +} + +export function testAddBiometricSettings(migration: (state: any) => any, prevSchema: any): void { + const v11 = migration(prevSchema) + + expect(v11.biometricSettings).toBeDefined() + expect(v11.biometricSettings.requiredForAppAccess).toBeDefined() + expect(v11.biometricSettings.requiredForTransactions).toBeDefined() +} + +export function testAddPushNotificationsEnabledToAccounts(migration: (state: any) => any, prevSchema: any): void { + const TEST_ADDRESS = '0xTestAddress' + const ACCOUNT_NAME = 'Test Account' + const v11Stub = { + ...prevSchema, + wallet: { + ...prevSchema.wallet, + accounts: { + [TEST_ADDRESS]: { + type: 'native', + address: TEST_ADDRESS, + name: ACCOUNT_NAME, + pending: false, + derivationIndex: 0, + timeImportedMs: 123, + }, + }, + }, + } + + const v12 = migration(v11Stub) + + expect(v12.wallet.accounts[TEST_ADDRESS].pushNotificationsEnabled).toEqual(false) + expect(v12.wallet.accounts[TEST_ADDRESS].type).toEqual('native') + expect(v12.wallet.accounts[TEST_ADDRESS].address).toEqual(TEST_ADDRESS) + expect(v12.wallet.accounts[TEST_ADDRESS].name).toEqual(ACCOUNT_NAME) +} + +export function testAddEnsState(migration: (state: any) => any, prevSchema: any): void { + const v13 = migration(prevSchema) + expect(v13.ens.ensForAddress).toEqual({}) +} + +export function testMigrateBiometricSettings(migration: (state: any) => any, prevSchema: any): void { + const v13Stub = { + ...prevSchema, + wallet: { + ...prevSchema.wallet, + isBiometricAuthEnabled: true, + }, + biometricSettings: { + requiredForAppAccess: false, + requiredForTransactions: false, + }, + } + + const v14 = migration(v13Stub) + expect(v14.biometricSettings.requiredForAppAccess).toEqual(true) + expect(v14.biometricSettings.requiredForTransactions).toEqual(true) +} + +export function testChangeNativeTypeToSignerMnemonic(migration: (state: any) => any, prevSchema: any): void { + const TEST_ADDRESS = '0xTestAddress' + const ACCOUNT_NAME = 'Test Account' + const v14Stub = { + ...prevSchema, + wallet: { + ...prevSchema.wallet, + accounts: { + [TEST_ADDRESS]: { + type: 'native', + address: TEST_ADDRESS, + name: ACCOUNT_NAME, + pending: false, + derivationIndex: 0, + timeImportedMs: 123, + }, + }, + }, + } + + const v15 = migration(v14Stub) + const accounts = Object.values(v15.wallet.accounts) + // oxlint-disable-next-line typescript/no-unnecessary-condition + expect((accounts[0] as Account)?.type).toEqual(AccountType.SignerMnemonic) +} + +export function testRemoveDataApi(migration: (state: any) => any, prevSchema: any): void { + const v15Stub = { + ...prevSchema, + dataApi: {}, + } + + const v16 = migration(v15Stub) + + expect(v16.dataApi).toBeUndefined() +} + +export function testResetPushNotificationsEnabled(migration: (state: any) => any, prevSchema: any): void { + const TEST_ADDRESS = '0xTestAddress' + const ACCOUNT_NAME = 'Test Account' + const v16Stub = { + ...prevSchema, + wallet: { + ...prevSchema.wallet, + accounts: { + [TEST_ADDRESS]: { + type: 'native', + address: TEST_ADDRESS, + name: ACCOUNT_NAME, + pending: false, + derivationIndex: 0, + timeImportedMs: 123, + pushNotificationsEnabled: true, + }, + }, + }, + } + + const v17 = migration(v16Stub) + + expect(v17.wallet.accounts[TEST_ADDRESS].pushNotificationsEnabled).toEqual(false) + expect(v17.wallet.accounts[TEST_ADDRESS].type).toEqual('native') + expect(v17.wallet.accounts[TEST_ADDRESS].address).toEqual(TEST_ADDRESS) + expect(v17.wallet.accounts[TEST_ADDRESS].name).toEqual(ACCOUNT_NAME) +} + +export function testRemoveEnsState(migration: (state: any) => any, prevSchema: any): void { + const v17Stub = { + ...prevSchema, + ens: {}, + } + const v18 = migration(v17Stub) + expect(v18.ens).toBeUndefined() +} + +export function testFilterToSupportedChains(migration: (state: any) => any, prevSchema: any): void { + const ROPSTEN = 3 as UniverseChainId + const RINKEBY = 4 as UniverseChainId + const GOERLI = 5 as UniverseChainId + const KOVAN = 42 as UniverseChainId + + const TEST_ADDRESS = '0xShadowySuperCoder' + const txDetails0 = { + chainId: UniverseChainId.Mainnet, + id: '0', + from: TEST_ADDRESS, + options: { + request: { + from: '0x123', + to: '0x456', + value: '0x0', + data: '0x789', + nonce: 10, + gasPrice: BigNumber.from('10000'), + }, + }, + typeInfo: { + type: TransactionType.Approve, + tokenAddress: '0xtokenAddress', + spender: '0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45', + }, + status: TransactionStatus.Pending, + addedTime: 1487076708000, + hash: '0x123', + } + + const TEST_ADDRESS_2 = '0xKingHodler' + const txDetails1 = { + chainId: GOERLI, + id: '1', + from: TEST_ADDRESS_2, + options: { + request: { + from: '0x123', + to: '0x456', + value: '0x0', + data: '0x789', + nonce: 10, + gasPrice: BigNumber.from('10000'), + }, + }, + typeInfo: { + type: TransactionType.Approve, + tokenAddress: '0xtokenAddress', + spender: '0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45', + }, + status: TransactionStatus.Success, + addedTime: 1487076708000, + hash: '0x123', + } + + const transactions = { + [TEST_ADDRESS]: { + [UniverseChainId.Mainnet]: { + '0': txDetails0, + }, + [UniverseChainId.Base]: { + '0': txDetails0, + '1': txDetails1, + }, + [GOERLI]: { + '0': txDetails0, + '1': txDetails1, + }, + [ROPSTEN]: { + '0': txDetails0, + '1': txDetails1, + }, + [RINKEBY]: { + '0': txDetails1, + }, + [KOVAN]: { + '1': txDetails1, + }, + }, + [TEST_ADDRESS_2]: { + [UniverseChainId.ArbitrumOne]: { + '0': txDetails0, + }, + [UniverseChainId.Optimism]: { + '0': txDetails0, + '1': txDetails1, + }, + [ROPSTEN]: { + '0': txDetails0, + '1': txDetails1, + }, + [RINKEBY]: { + '0': txDetails1, + }, + [KOVAN]: { + '1': txDetails1, + }, + }, + } + + const blocks = { + byChainId: { + [UniverseChainId.Mainnet]: { latestBlockNumber: 123456789 }, + [UniverseChainId.Optimism]: { latestBlockNumber: 123456789 }, + [UniverseChainId.ArbitrumOne]: { latestBlockNumber: 123456789 }, + [UniverseChainId.Base]: { latestBlockNumber: 123456789 }, + [GOERLI]: { latestBlockNumber: 123456789 }, + [ROPSTEN]: { latestBlockNumber: 123456789 }, + [RINKEBY]: { latestBlockNumber: 123456789 }, + [KOVAN]: { latestBlockNumber: 123456789 }, + }, + } + + const chains = { + byChainId: { + [UniverseChainId.Mainnet]: { isActive: true }, + [UniverseChainId.Optimism]: { isActive: true }, + [UniverseChainId.ArbitrumOne]: { isActive: true }, + [UniverseChainId.Base]: { isActive: true }, + [GOERLI]: { isActive: true }, + [ROPSTEN]: { isActive: true }, + [RINKEBY]: { isActive: true }, + [KOVAN]: { isActive: true }, + }, + } + + const v18Stub = { + ...prevSchema, + transactions, + blocks, + chains, + } + + const v19 = migration(v18Stub) + + expect(v19.transactions[TEST_ADDRESS][UniverseChainId.Mainnet]).toBeDefined() + expect(v19.transactions[TEST_ADDRESS][UniverseChainId.Base]).toBeDefined() + expect(v19.transactions[TEST_ADDRESS][GOERLI]).toBeUndefined() + expect(v19.transactions[TEST_ADDRESS][ROPSTEN]).toBeUndefined() + expect(v19.transactions[TEST_ADDRESS][RINKEBY]).toBeUndefined() + expect(v19.transactions[TEST_ADDRESS][KOVAN]).toBeUndefined() + + expect(v19.transactions[TEST_ADDRESS_2][UniverseChainId.ArbitrumOne]).toBeDefined() + expect(v19.transactions[TEST_ADDRESS_2][UniverseChainId.Optimism]).toBeDefined() + expect(v19.transactions[TEST_ADDRESS_2][ROPSTEN]).toBeUndefined() + expect(v19.transactions[TEST_ADDRESS_2][RINKEBY]).toBeUndefined() + expect(v19.transactions[TEST_ADDRESS_2][KOVAN]).toBeUndefined() + + expect(v19.blocks.byChainId[UniverseChainId.Mainnet]).toBeDefined() + expect(v19.blocks.byChainId[UniverseChainId.Optimism]).toBeDefined() + expect(v19.blocks.byChainId[UniverseChainId.ArbitrumOne]).toBeDefined() + expect(v19.blocks.byChainId[UniverseChainId.Base]).toBeDefined() + expect(v19.blocks.byChainId[GOERLI]).toBeUndefined() + expect(v19.blocks.byChainId[ROPSTEN]).toBeUndefined() + expect(v19.blocks.byChainId[RINKEBY]).toBeUndefined() + expect(v19.blocks.byChainId[KOVAN]).toBeUndefined() + + expect(v19.chains.byChainId[UniverseChainId.Mainnet]).toBeDefined() + expect(v19.chains.byChainId[UniverseChainId.Optimism]).toBeDefined() + expect(v19.chains.byChainId[UniverseChainId.ArbitrumOne]).toBeDefined() + expect(v19.chains.byChainId[UniverseChainId.Base]).toBeDefined() + expect(v19.chains.byChainId[GOERLI]).toBeUndefined() + expect(v19.chains.byChainId[ROPSTEN]).toBeUndefined() + expect(v19.chains.byChainId[RINKEBY]).toBeUndefined() + expect(v19.chains.byChainId[KOVAN]).toBeUndefined() +} + +export function testResetLastTxNotificationUpdate(migration: (state: any) => any, prevSchema: any): void { + const v19Stub = { + ...prevSchema, + notifications: { + ...prevSchema.notifications, + lastTxNotificationUpdate: { 1: 122342134 }, + }, + } + + const v20 = migration(v19Stub) + expect(v20.notifications.lastTxNotificationUpdate).toEqual({}) +} + +export function testAddExperimentsSlice(migration: (state: any) => any, prevSchema: any): void { + const v20Stub = { + ...prevSchema, + } + + const v21 = migration(v20Stub) + expect(v21.experiments).toBeDefined() +} + +export function testRemoveCoingeckoApiAndTokenLists(migration: (state: any) => any, prevSchema: any): void { + const v21Stub = { + ...prevSchema, + coingeckoApi: {}, + } + const v22 = migration(v21Stub) + expect(v22.coingeckoApi).toBeUndefined() + expect(v22.tokens.watchedTokens).toBeUndefined() + expect(v22.tokens.tokenPairs).toBeUndefined() +} + +export function testResetTokensOrderByAndMetadataDisplayType(migration: (state: any) => any, prevSchema: any): void { + const v22Stub = { + ...prevSchema, + } + const v23 = migration(v22Stub) + expect(v23.wallet.settings.tokensOrderBy).toBeUndefined() + expect(v23.wallet.settings.tokensMetadataDisplayType).toBeUndefined() +} + +export function testTransformNotificationCountToStatus(migration: (state: any) => any, prevSchema: any): void { + const dummyAddress1 = '0xDumDum1' + const dummyAddress2 = '0xDumDum2' + const dummyAddress3 = '0xDumDum3' + const v23Stub = { + ...prevSchema, + notifications: { + ...prevSchema.notifications, + notificationCount: { [dummyAddress1]: 5, [dummyAddress2]: 0, [dummyAddress3]: undefined }, + }, + } + const v24 = migration(v23Stub) + expect(v24.notifications.notificationCount).toBeUndefined() + expect(v24.notifications.notificationStatus[dummyAddress1]).toBe(true) + expect(v24.notifications.notificationStatus[dummyAddress2]).toBe(false) + expect(v24.notifications.notificationStatus[dummyAddress2]).toBe(false) +} + +export function testAddPasswordLockout(migration: (state: any) => any, prevSchema: any): void { + const v24Stub = { + ...prevSchema, + } + const v25 = migration(v24Stub) + expect(v25.passwordLockout.passwordAttempts).toBe(0) +} + +export function testRemoveShowSmallBalances(migration: (state: any) => any, prevSchema: any): void { + const v25Stub = { + ...prevSchema, + } + const v26 = migration(v25Stub) + expect(v26.wallet.settings.showSmallBalances).toBeUndefined() +} + +export function testResetTokensOrderBy(migration: (state: any) => any, prevSchema: any): void { + const v26Stub = { + ...prevSchema, + } + const v27 = migration(v26Stub) + expect(v27.wallet.settings.tokensOrderBy).toBeUndefined() +} + +export function testRemoveTokensMetadataDisplayType(migration: (state: any) => any, prevSchema: any): void { + const v27Stub = { + ...prevSchema, + } + const v28 = migration(v27Stub) + expect(v28.wallet.settings.tokensMetadataDisplayType).toBeUndefined() +} + +export function testRemoveTokenListsAndCustomTokens(migration: (state: any) => any, prevSchema: any): void { + const v28Stub = { + ...prevSchema, + } + const v29 = migration(v28Stub) + expect(v29.tokenLists).toBeUndefined() + expect(v29.tokens.customTokens).toBeUndefined() +} + +export function testMigrateFiatPurchaseTransactionInfo( + migration: (state: any) => any, + prevSchema: any, + account: { address: string }, + txDetailsConfirmed: any, +): void { + const oldFiatOnRampTxDetails = { + chainId: UniverseChainId.Mainnet, + id: '0', + from: account.address, + options: { + request: {}, + }, + // expect this payload to change + typeInfo: { + type: TransactionType.FiatPurchaseDeprecated, + explorerUrl: 'explorer', + outputTokenAddress: '0xtokenAddress', + outputCurrencyAmountFormatted: 50, + outputCurrencyAmountPrice: 2, + syncedWithBackend: true, + }, + status: TransactionStatus.Pending, + addedTime: 1487076708000, + hash: '0x123', + } + const expectedTypeInfo = { + type: TransactionType.FiatPurchaseDeprecated, + explorerUrl: 'explorer', + inputCurrency: undefined, + inputCurrencyAmount: 25, + outputCurrency: { + type: 'crypto', + metadata: { + chainId: undefined, + contractAddress: '0xtokenAddress', + }, + }, + outputCurrencyAmount: undefined, + syncedWithBackend: true, + } + const transactions = { + [account.address]: { + [UniverseChainId.Mainnet]: { + '0': oldFiatOnRampTxDetails, + '1': txDetailsConfirmed, + }, + [UniverseChainId.Base]: { + '0': { ...oldFiatOnRampTxDetails, status: TransactionStatus.Failed }, + '1': txDetailsConfirmed, + }, + [UniverseChainId.ArbitrumOne]: { + '0': { ...oldFiatOnRampTxDetails, status: TransactionStatus.Failed }, + }, + }, + '0xshadowySuperCoder': { + [UniverseChainId.ArbitrumOne]: { + '0': oldFiatOnRampTxDetails, + '1': txDetailsConfirmed, + }, + [UniverseChainId.Optimism]: { + '0': oldFiatOnRampTxDetails, + '1': oldFiatOnRampTxDetails, + '2': txDetailsConfirmed, + }, + }, + '0xdeleteMe': { + [UniverseChainId.Mainnet]: { + '0': { ...oldFiatOnRampTxDetails, status: TransactionStatus.Failed }, + }, + }, + } + const v29Stub = { ...prevSchema, transactions } + + const v30 = migration(v29Stub) + + // expect fiat onramp txdetails to change + expect(v30.transactions[account.address][UniverseChainId.Mainnet]['0'].typeInfo).toEqual(expectedTypeInfo) + expect(v30.transactions[account.address][UniverseChainId.Base]['0']).toBeUndefined() + expect(v30.transactions[account.address][UniverseChainId.ArbitrumOne]).toBeUndefined() // does not create an object for chain + expect(v30.transactions['0xshadowySuperCoder'][UniverseChainId.ArbitrumOne]['0'].typeInfo).toEqual(expectedTypeInfo) + expect(v30.transactions['0xshadowySuperCoder'][UniverseChainId.Optimism]['0'].typeInfo).toEqual(expectedTypeInfo) + expect(v30.transactions['0xshadowySuperCoder'][UniverseChainId.Optimism]['1'].typeInfo).toEqual(expectedTypeInfo) + expect(v30.transactions['0xdeleteMe']).toBe(undefined) + // expect non-for txDetails to not change + expect(v30.transactions[account.address][UniverseChainId.Mainnet]['1']).toEqual(txDetailsConfirmed) + expect(v30.transactions[account.address][UniverseChainId.Base]['1']).toEqual(txDetailsConfirmed) + expect(v30.transactions['0xshadowySuperCoder'][UniverseChainId.ArbitrumOne]['1']).toEqual(txDetailsConfirmed) + expect(v30.transactions['0xshadowySuperCoder'][UniverseChainId.Optimism]['2']).toEqual(txDetailsConfirmed) +} + +export function testResetEnsApi(migration: (state: any) => any, prevSchema: any): void { + const v31Stub = { ...prevSchema, ENS: 'defined' } + + const v32 = migration(v31Stub) + + expect(v32.ENS).toBe(undefined) +} + +export function testAddReplaceAccountOptions(migration: (state: any) => any, prevSchema: any): void { + const v32Stub = { ...prevSchema } + + const v33 = migration(v32Stub) + + expect(v33.wallet.replaceAccountOptions.isReplacingAccount).toBe(false) + expect(v33.wallet.replaceAccountOptions.skipToSeedPhrase).toBe(false) +} + +export function testAddLastBalancesReport(migration: (state: any) => any, prevSchema: any): void { + const v33Stub = { ...prevSchema } + + const v34 = migration(v33Stub) + + expect(v34.telemetry.lastBalancesReport).toBe(0) +} + +export function testAddAppearanceSetting(migration: (state: any) => any, prevSchema: any): void { + const v34Stub = { ...prevSchema } + + const v35 = migration(v34Stub) + + expect(v35.appearanceSettings.selectedAppearanceSettings).toBe('system') +} + +export function testAddHiddenNfts(migration: (state: any) => any, prevSchema: any): void { + const v35Stub = { ...prevSchema } + + const v36 = migration(v35Stub) + + expect(v36.favorites.hiddenNfts).toEqual({}) +} + +export function testCorrectFailedFiatOnRampTxIds( + migration: (state: any) => any, + prevSchema: any, + account: { address: string }, + fiatOnRampTxDetailsFailed: any, + txDetailsConfirmed: any, +): void { + const id1 = '123' + const id2 = '456' + const id3 = '789' + const transactions = { + [account.address]: { + [UniverseChainId.Mainnet]: { + [id1]: { + ...fiatOnRampTxDetailsFailed, + typeInfo: { + ...fiatOnRampTxDetailsFailed.typeInfo, + id: undefined, + }, + }, + [id2]: { + ...fiatOnRampTxDetailsFailed, + typeInfo: { + ...fiatOnRampTxDetailsFailed.typeInfo, + id: undefined, + explorerUrl: undefined, + }, + }, + [id3]: txDetailsConfirmed, + }, + }, + } + + const v36Stub = { ...prevSchema, transactions } + + expect(v36Stub.transactions[account.address]?.[UniverseChainId.Mainnet][id1].typeInfo.id).toBeUndefined() + expect(v36Stub.transactions[account.address]?.[UniverseChainId.Mainnet][id2].typeInfo.id).toBeUndefined() + + const v37 = migration(v36Stub) + + expect(v37.transactions[account.address]?.[UniverseChainId.Mainnet][id1].typeInfo.id).toEqual( + fiatOnRampTxDetailsFailed.typeInfo.id, + ) + expect(v36Stub.transactions[account.address]?.[UniverseChainId.Mainnet][id2].typeInfo.id).toBeUndefined() + expect(v36Stub.transactions[account.address]?.[UniverseChainId.Mainnet][id3]).toEqual(txDetailsConfirmed) +} + +export function testRemoveReplaceAccountOptions(migration: (state: any) => any, prevSchema: any): void { + const v37Stub = { ...prevSchema } + const v38 = migration(v37Stub) + expect(v38.wallet.replaceAccountOptions).toBeUndefined() +} + +export function testRemoveExperimentsSlice(migration: (state: any) => any, prevSchema: any): void { + const v38Stub = { ...prevSchema } + expect(v38Stub.experiments).toBeDefined() + const v39 = migration(v38Stub) + expect(v39.experiments).toBeUndefined() +} + +export function testRemovePersistedWalletConnectSlice(migration: (state: any) => any, prevSchema: any): void { + const v39Stub = { ...prevSchema } + + const v40 = migration(v39Stub) + + expect(v40.walletConnect).toBeUndefined() +} + +export function testAddLastBalancesReportValue(migration: (state: any) => any, prevSchema: any): void { + const v40Stub = { ...prevSchema } + + const v41 = migration(v40Stub) + + expect(v41.telemetry.lastBalancesReportValue).toBe(0) +} + +export function testRemoveFlashbotsEnabledFromWalletSlice(migration: (state: any) => any, prevSchema: any): void { + const v41Stub = { ...prevSchema } + + const v42 = migration(v41Stub) + + expect(v42.wallet.flashbotsenabled).toBeUndefined() +} + +export function testConvertHiddenNftsToNftsData(migration: (state: any) => any, prevSchema: any): void { + const v42Stub = { ...prevSchema } + + v42Stub.favorites.hiddenNfts = { + '0xAFa9bAb987E3D7bcD40EB510838aEC663C8b7264': { + 'nftItem.0xb96e881BD4Cd7BCCc8CB47d3aa0e254a72d2F074.3971': true, // checksummed 1 + 'nftItem.0xb96e881bd4cd7bccc8cb47d3aa0e254a72d2f074.3971': true, // not checksummed 1 + 'nftItem.0x25E503331e69EFCBbc50d2a4D661900B23D47662.2': true, // checksummed 2 + 'nftItem.0xe94abea3932576ff957a0b92190d0191aeb1a782.2': true, // not checksummed 3 + }, + } + + const v43 = migration(v42Stub) + + expect(v43.favorites.nftsData).toEqual({ + '0xAFa9bAb987E3D7bcD40EB510838aEC663C8b7264': { + 'nftItem.0xb96e881bd4cd7bccc8cb47d3aa0e254a72d2f074.3971': { isHidden: true }, // not checksummed 1 + 'nftItem.0x25e503331e69efcbbc50d2a4d661900b23d47662.2': { isHidden: true }, // not checksummed 2 + 'nftItem.0xe94abea3932576ff957a0b92190d0191aeb1a782.2': { isHidden: true }, // not checksummed 3 + }, + }) +} + +export function testRemoveProviders(migration: (state: any) => any, prevSchema: any): void { + const v43Stub = { ...prevSchema } + + v43Stub.providers = { isInitialized: true } + + const v44 = migration(v43Stub) + + expect(v44.providers).toBeUndefined() +} + +export function testAddTokensVisibility(migration: (state: any) => any, prevSchema: any): void { + const v44Stub = { ...prevSchema } + + const v45 = migration(v44Stub) + + expect(v45.favorites.tokensVisibility).toEqual({}) +} + +export function testDeleteRTKQuerySlices(migration: (state: any) => any, prevSchema: any): void { + const v45Stub = { ...prevSchema } + const v46 = migration(v45Stub) + + expect(v46.ENS).toBeUndefined() + expect(v46.ens).toBeUndefined() + expect(v46.gasApi).toBeUndefined() + expect(v46.onChainBalanceApi).toBeUndefined() + expect(v46.routingApi).toBeUndefined() + expect(v46.trmApi).toBeUndefined() +} + +export function testResetActiveChains(migration: (state: any) => any, prevSchema: any): void { + const v46Stub = { ...prevSchema } + const v47 = migration(v46Stub) + + expect(v47.chains.byChainId).toStrictEqual({ + '1': { isActive: true }, + '10': { isActive: true }, + '56': { isActive: true }, + '137': { isActive: true }, + '8453': { isActive: true }, + '42161': { isActive: true }, + }) +} + +export function testAddTweaksStartingState(migration: (state: any) => any, prevSchema: any): void { + const v47Stub = { ...prevSchema } + const v48 = migration(v47Stub) + + expect(v48.tweaks).toEqual({}) +} + +export function testAddSwapProtectionSetting(migration: (state: any) => any, prevSchema: any): void { + const v48Stub = { ...prevSchema } + const v49 = migration(v48Stub) + + expect(v49.wallet.settings.swapProtection).toEqual(SwapProtectionSetting.On) +} + +export function testDeleteChainsSlice(migration: (state: any) => any, prevSchema: any): void { + const v49Stub = { ...prevSchema } + const v50 = migration(v49Stub) + + expect(v50.chains).toBeUndefined() +} + +export function testAddLanguageSettings(migration: (state: any) => any, prevSchema: any): void { + const v50Stub = { ...prevSchema } + const v51 = migration(v50Stub) + + expect(v51.languageSettings).not.toBeUndefined() +} + +export function testAddFiatCurrencySettings(migration: (state: any) => any, prevSchema: any): void { + const v51Stub = { ...prevSchema } + const v52 = migration(v51Stub) + + expect(v52.fiatCurrencySettings).not.toBeUndefined() +} + +export function testUpdateLanguageSettings(migration: (state: any) => any, prevSchema: any): void { + const v52Stub = { ...prevSchema } + const v53 = migration(v52Stub) + + expect(v53.languageSettings).not.toBeUndefined() +} + +export function testAddWalletIsFunded(migration: (state: any) => any, prevSchema: any): void { + const v53Stub = { ...prevSchema } + const v54 = migration(v53Stub) + + expect(v54.telemetry.walletIsFunded).toBe(false) +} + +export function testAddBehaviorHistory(migration: (state: any) => any, prevSchema: any): void { + const v54Stub = { ...prevSchema } + const v55 = migration(v54Stub) + + expect(v55.behaviorHistory.hasViewedReviewScreen).toBe(false) +} + +export function testAddAllowAnalyticsSwitch(migration: (state: any) => any, prevSchema: any): void { + const v55Stub = { ...prevSchema } + const v56 = migration(v55Stub) + + expect(v56.telemetry.allowAnalytics).toBe(true) + expect(v56.telemetry.lastHeartbeat).toBe(0) +} + +export function testMoveSettingStateToGlobal(migration: (state: any) => any, prevSchema: any): void { + const v56Stub = { + ...prevSchema, + wallet: { + ...prevSchema.wallet, + accounts: [ + { + type: AccountType.Readonly, + address: '0x', + name: 'Test Account 1', + pending: false, + hideSpamTokens: true, + }, + ], + }, + } + const v57 = migration(v56Stub) + expect(v57.wallet.settings.hideSmallBalances).toBe(true) + expect(v57.wallet.settings.hideSpamTokens).toBe(true) + expect(v57.wallet.accounts[0].showSpamTokens).toBeUndefined() + expect(v57.wallet.accounts[0].showSmallBalances).toBeUndefined() +} + +export function testAddSkippedUnitagBoolean(migration: (state: any) => any, prevSchema: any): void { + const v57Stub = { ...prevSchema } + const v58 = migration(v57Stub) + + expect(v58.behaviorHistory.hasSkippedUnitagPrompt).toBe(false) +} + +export function testAddCompletedUnitagsIntroBoolean(migration: (state: any) => any, prevSchema: any): void { + const v58Stub = { ...prevSchema } + const v59 = migration(v58Stub) + + expect(v59.behaviorHistory.hasCompletedUnitagsIntroModal).toBe(false) +} + +export function testAddUniconV2IntroModalBoolean(migration: (state: any) => any, prevSchema: any): void { + const v59Stub = { ...prevSchema } + const v60 = migration(v59Stub) + + expect(v60.behaviorHistory.hasViewedUniconV2IntroModal).toBe(false) +} + +export function testFlattenTokenVisibility(migration: (state: any) => any, prevSchema: any): void { + const v60Stub = { ...prevSchema } + const address1 = '0x123' + const address2 = '0x456' + const nftKey1 = '0xNFTKey1' + const nftKey2 = '0xNFTKey2' + const nftKey3 = '0xNFTKey3' + const nftKey4 = '0xNFTKey4' + + const currency1ToVisibility = { '0xCurrency1': { isVisible: true } } + const currency2ToVisibility = { '0xCurrency2': { isVisible: false } } + const currency3ToVisibility = { '0xCurrency3': { isVisible: false } } + const nft1ToVisibility = { [nftKey1]: { isSpamIgnored: true } } + const nft2ToVisibility = { [nftKey2]: { isHidden: true } } + const nft3ToVisibility = { [nftKey3]: { isSpamIgnored: false, isHidden: false } } + const nft4ToVisibility = { [nftKey4]: { isSpamIgnored: false, isHidden: true } } + + v60Stub.favorites = { + ...v60Stub.favorites, + tokensVisibility: { + [address1]: { ...currency1ToVisibility, ...currency2ToVisibility }, + [address2]: { ...currency2ToVisibility, ...currency3ToVisibility }, + }, + nftsData: { + [address1]: { ...nft1ToVisibility, ...nft2ToVisibility, ...nft3ToVisibility }, + [address2]: { ...nft3ToVisibility, ...nft4ToVisibility }, + }, + } + + const v61 = migration(v60Stub) + + expect(v61.favorites.nftsData).toBeUndefined() + expect(v61.favorites.tokensVisibility).toMatchObject({ + ...currency1ToVisibility, + ...currency2ToVisibility, + ...currency3ToVisibility, + }) + expect(v61.favorites.nftsVisibility).toMatchObject({ + [nftKey1]: { isVisible: true }, + [nftKey2]: { isVisible: false }, + [nftKey3]: { isVisible: true }, + [nftKey4]: { isVisible: false }, + }) +} + +export function testAddExtensionOnboardingState(migration: (state: any) => any, prevSchema: any): void { + const v61Stub = { ...prevSchema } + const v62 = migration(v61Stub) + + // Removed in schema 69 + expect(v62.behaviorHistory.extensionOnboardingState).toBe('Undefined') +} + +export function testResetOnboardingStateForGA(migration: (state: any) => any, prevSchema: any): void { + const v66Stub = { ...prevSchema } + const v67 = migration(v66Stub) + + // Removed in migration 69 + expect(v67.behaviorHistory.extensionOnboardingState).toBe('Undefined') +} + +export function testDeleteOldOnRampTxData( + migration: (state: any) => any, + prevSchema: any, + account: { address: string }, + txDetailsConfirmed: any, +): void { + const oldFiatOnRampTxDetails = { + chainId: UniverseChainId.Mainnet, + id: '0', + from: account.address, + options: { + request: {}, + }, + typeInfo: { + type: TransactionType.FiatPurchaseDeprecated, + explorerUrl: 'explorer', + inputCurrencyAmount: 25, + outputSymbol: 'USDC', + }, + status: TransactionStatus.Pending, + addedTime: 1487076708000, + hash: '0x123', + } + const transactions = { + [account.address]: { + [UniverseChainId.Mainnet]: { + '0': oldFiatOnRampTxDetails, + '1': txDetailsConfirmed, + }, + [UniverseChainId.Optimism]: { + '0': oldFiatOnRampTxDetails, + '1': { + ...oldFiatOnRampTxDetails, + typeInfo: { + ...oldFiatOnRampTxDetails.typeInfo, + type: TransactionType.Send, + }, + }, + '2': { + ...oldFiatOnRampTxDetails, + typeInfo: { + ...oldFiatOnRampTxDetails.typeInfo, + type: TransactionType.Receive, + }, + }, + '3': txDetailsConfirmed, + }, + }, + } + const v73Stub = { ...prevSchema, transactions } + + const v74 = migration(v73Stub) + + expect(v74.transactions[account.address][UniverseChainId.Mainnet]['0']).toBe(undefined) + expect(v74.transactions[account.address][UniverseChainId.Mainnet]['1']).toEqual(txDetailsConfirmed) + + expect(v74.transactions[account.address][UniverseChainId.Optimism]['0']).toBe(undefined) + expect(v74.transactions[account.address][UniverseChainId.Optimism]['1'].typeInfo).toEqual({ + ...oldFiatOnRampTxDetails.typeInfo, + type: TransactionType.Send, + }) + expect(v74.transactions[account.address][UniverseChainId.Optimism]['2'].typeInfo).toEqual({ + ...oldFiatOnRampTxDetails.typeInfo, + type: TransactionType.Receive, + }) + expect(v74.transactions[account.address][UniverseChainId.Optimism]['3']).toEqual(txDetailsConfirmed) +} + +export function testAddPushNotifications(migration: (state: any) => any, prevSchema: any): void { + // v82 didn't have a new schema + const v82Stub = { ...prevSchema } + const v83 = migration(v82Stub) + + expect(v83.pushNotifications.generalUpdatesEnabled).toBe(false) + expect(v83.pushNotifications.priceAlertsEnabled).toBe(false) +} + +export function testMigrateDappRequestInfoTypes(migration: (state: any) => any, prevSchema: any): void { + /** test migration on uwulink transaction */ + const stateWithUwulinkTransaction = { + transactions: { + testAddress: { + testChainId: { + testTxnId: { + typeInfo: { + dappRequestInfo: { + name: 'testDapp', + }, + externalDappInfo: { + source: 'uwulink', + }, + }, + }, + }, + }, + }, + } + + const v86Stub = { ...prevSchema, ...stateWithUwulinkTransaction } + const v87 = migration(v86Stub) + + expect(v87.transactions).toBeDefined() + expect(v87.transactions.testAddress.testChainId.testTxnId.typeInfo).toEqual({ + dappRequestInfo: { + name: 'testDapp', + }, + externalDappInfo: { + requestType: DappRequestType.UwULink, + }, + }) + + /** test migration on walletconnect transaction */ + const stateWithWalletConnectTransaction = { + transactions: { + testAddress: { + testChainId: { + testTxnId: { + typeInfo: { + type: TransactionType.WCConfirm, + dapp: { + name: 'testDapp', + }, + externalDappInfo: { + source: 'walletconnect', + }, + }, + }, + }, + }, + }, + } + + const v86StubWalletConnect = { ...prevSchema, ...stateWithWalletConnectTransaction } + const v87WalletConnect = migration(v86StubWalletConnect) + + expect(v87WalletConnect.transactions).toBeDefined() + expect(v87WalletConnect.transactions.testAddress.testChainId.testTxnId.typeInfo).toEqual({ + type: TransactionType.WCConfirm, + dappRequestInfo: { + name: 'testDapp', + }, + externalDappInfo: { + requestType: DappRequestType.WalletConnectSessionRequest, + }, + }) +} + +export function testMigrateAndRemoveCloudBackupSlice(migration: (state: any) => any, prevSchema: any): void { + const androidCloudBackupEmail = 'test@test.com' + + const { cloudBackup: _oldCloudBackup, ...v91WithoutCloudBackup } = prevSchema + + const v91WithoutCloudBackupSlice = { + ...v91WithoutCloudBackup, + wallet: { + ...prevSchema.wallet, + activeAccountAddress: '0xabc', + }, + } + + const v91WithCloudBackup = { + ...v91WithoutCloudBackupSlice, + cloudBackup: { + backupsFound: [{ mnemonicId: '0xabc', email: androidCloudBackupEmail }], + }, + wallet: { + ...prevSchema.wallet, + activeAccountAddress: '0xabc', + }, + } + + const v91WithDifferentActiveAccountAddress = { + ...v91WithoutCloudBackupSlice, + cloudBackup: { + backupsFound: [{ mnemonicId: '0xdef', email: androidCloudBackupEmail }], + }, + wallet: { + ...prevSchema.wallet, + activeAccountAddress: '0xabc', + }, + } + + const v92 = migration(v91WithCloudBackup) + expect(v92.cloudBackup).toBeUndefined() + expect(v92.wallet.androidCloudBackupEmail).toBe(androidCloudBackupEmail) + + const v92WithoutCloudBackup = migration(v91WithoutCloudBackupSlice) + expect(v92WithoutCloudBackup.cloudBackup).toBeUndefined() + expect(v92WithoutCloudBackup.wallet.androidCloudBackupEmail).toBe(undefined) + + const v92WithDifferentActiveAccountAddress = migration(v91WithDifferentActiveAccountAddress) + expect(v92WithDifferentActiveAccountAddress.cloudBackup).toBeUndefined() + expect(v92WithDifferentActiveAccountAddress.wallet.androidCloudBackupEmail).toBe(androidCloudBackupEmail) +} + +export function testSetWalletDeviceLanguage( + migration: (state: any) => any, + prevSchema: any, + getWalletDeviceLanguageMock: jest.MockedFunction<() => Language>, +): void { + const deviceLanguage = Language.Japanese + getWalletDeviceLanguageMock.mockReturnValue(deviceLanguage) + + const stateReduxLanguageDiffersFromDevice = { + ...prevSchema, + userSettings: { + ...prevSchema.userSettings, + currentLanguage: Language.English, + }, + } + const resultWhenDifferent = migration(stateReduxLanguageDiffersFromDevice) + expect(resultWhenDifferent.userSettings?.currentLanguage).toBe(deviceLanguage) + expect(getWalletDeviceLanguageMock).toHaveBeenCalled() + getWalletDeviceLanguageMock.mockClear() + + const stateReduxLanguageMatchesDevice = { + ...prevSchema, + userSettings: { + ...prevSchema.userSettings, + currentLanguage: deviceLanguage, + }, + } + const resultWhenSame = migration(stateReduxLanguageMatchesDevice) + expect(resultWhenSame.userSettings?.currentLanguage).toBe(deviceLanguage) + expect(getWalletDeviceLanguageMock).toHaveBeenCalled() + + if (prevSchema.userSettings) { + for (const key of Object.keys(prevSchema.userSettings)) { + if (key !== 'currentLanguage') { + expect(resultWhenDifferent.userSettings[key]).toEqual(prevSchema.userSettings[key]) + expect(resultWhenSame.userSettings[key]).toEqual(prevSchema.userSettings[key]) + } + } + } +} diff --git a/apps/mobile/src/app/mobileMigrations.test.ts b/apps/mobile/src/app/mobileMigrations.test.ts new file mode 100644 index 00000000..fba9b801 --- /dev/null +++ b/apps/mobile/src/app/mobileMigrations.test.ts @@ -0,0 +1,1224 @@ +/** + * Isolated tests for individual migration functions. + * + * Tests each migration independently with various input states, edge cases, + * and error handling, without relying on output from previous migrations. + * + * For tests of the full migration chain, see mobileMigrationTests.ts. + */ +import { + addAllowAnalyticsSwitch, + addAppearanceSetting, + addBehaviorHistory, + addBiometricSettings, + addCloudBackup, + addCompletedUnitagsIntroBoolean, + addEnsState, + addExperimentsSlice, + addExtensionOnboardingState, + addFiatCurrencySettings, + addHiddenNfts, + addLanguageSettings, + addLastBalancesReport, + addLastBalancesReportValue, + addModalsState, + addPasswordLockout, + addPushNotifications, + addPushNotificationsEnabledToAccounts, + addReplaceAccountOptions, + addSearchHistory, + addSkippedUnitagBoolean, + addSwapProtectionSetting, + addTimeImportedAndDerivationIndex, + addTokensVisibility, + addTweaksStartingState, + addUniconV2IntroModalBoolean, + addWalletConnectPendingSessionAndSettings, + addWalletIsFunded, + changeNativeTypeToSignerMnemonic, + convertHiddenNftsToNftsData, + correctFailedFiatOnRampTxIds, + deleteChainsSlice, + deleteOldOnRampTxData, + deleteRTKQuerySlices, + emptyMigration, + filterToSupportedChains, + flattenTokenVisibility, + migrateAndRemoveCloudBackupSlice, + migrateBiometricSettings, + migrateDappRequestInfoTypes, + migrateFiatPurchaseTransactionInfo, + moveSettingStateToGlobal, + OLD_DEMO_ACCOUNT_ADDRESS, + removeCoingeckoApiAndTokenLists, + removeDataApi, + removeDemoAccount, + removeEnsState, + removeExperimentsSlice, + removeFlashbotsEnabledFromWalletSlice, + removeLocalTypeAccounts, + removeNonZeroDerivationIndexAccounts, + removePersistedWalletConnectSlice, + removeProviders, + removeReplaceAccountOptions, + removeShowSmallBalances, + removeTokenListsAndCustomTokens, + removeTokensMetadataDisplayType, + removeWalletConnectModalState, + renameFollowedAddressesToWatchedAddresses, + resetActiveChains, + resetEnsApi, + resetLastTxNotificationUpdate, + resetOnboardingStateForGA, + resetPushNotificationsEnabled, + resetTokensOrderBy, + resetTokensOrderByAndMetadataDisplayType, + restructureTransactionsAndNotifications, + setWalletDeviceLanguage, + transformNotificationCountToStatus, + updateLanguageSettings, +} from 'src/app/mobileMigrations' +import { AccountType } from 'uniswap/src/features/accounts/types' +import { UniverseChainId } from 'uniswap/src/features/chains/types' +import { FiatCurrency } from 'uniswap/src/features/fiatCurrency/constants' +import { Language } from 'uniswap/src/features/language/constants' +import { ModalName } from 'uniswap/src/features/telemetry/constants' +import { TransactionStatus, TransactionType } from 'uniswap/src/features/transactions/types/transactionDetails' +import { getWalletDeviceLanguage } from 'uniswap/src/i18n/utils' +import { DappRequestType } from 'uniswap/src/types/walletConnect' +import { createThrowingProxy } from 'utilities/src/test/utils' +import { SwapProtectionSetting } from 'wallet/src/features/wallet/slice' + +jest.mock('uniswap/src/i18n/utils', () => ({ + getWalletDeviceLanguage: jest.fn(), +})) + +describe('restructureTransactionsAndNotifications', () => { + it('restructures transactions from byChainId to address-based format', () => { + const state = { + transactions: { + byChainId: { + '1': { + tx1: { id: 'tx1', from: '0x123', chainId: 1 }, + }, + }, + lastTxHistoryUpdate: { + '0x123': 1234567890, + }, + }, + notifications: {}, + } + const result = restructureTransactionsAndNotifications(state) + expect(result.transactions['0x123']['1'].tx1).toEqual({ id: 'tx1', from: '0x123', chainId: 1 }) + expect(result.notifications.lastTxNotificationUpdate['0x123'][UniverseChainId.Mainnet]).toBe(1234567890) + }) + + it('handles missing transaction state', () => { + const state = { notifications: {} } + const result = restructureTransactionsAndNotifications(state) + expect(result.transactions).toEqual({}) + }) + + it('falls back to empty state on error', () => { + const state = { + transactions: createThrowingProxy({}, { throwingMethods: ['*'] }), + notifications: {}, + } + const result = restructureTransactionsAndNotifications(state) + expect(result.transactions).toEqual({}) + expect(result.notifications.lastTxNotificationUpdate).toEqual({}) + }) +}) + +describe('removeWalletConnectModalState', () => { + it('removes modalState from walletConnect', () => { + const state = { walletConnect: { modalState: 'open', otherData: 'preserved' } } + const result = removeWalletConnectModalState(state) + expect(result.walletConnect.modalState).toBeUndefined() + expect(result.walletConnect.otherData).toBe('preserved') + }) + + it('handles missing walletConnect state', () => { + const state = { otherData: 'preserved' } + const result = removeWalletConnectModalState(state) + expect(result.otherData).toBe('preserved') + }) +}) + +describe('renameFollowedAddressesToWatchedAddresses', () => { + it('renames followedAddresses to watchedAddresses', () => { + const state = { favorites: { followedAddresses: ['0x123'] } } + const result = renameFollowedAddressesToWatchedAddresses(state) + expect(result.favorites.watchedAddresses).toEqual(['0x123']) + expect(result.favorites.followedAddresses).toBeUndefined() + }) + + it('handles missing favorites state', () => { + const state = { otherData: 'preserved' } + const result = renameFollowedAddressesToWatchedAddresses(state) + expect(result.otherData).toBe('preserved') + }) +}) + +describe('addSearchHistory', () => { + it('adds searchHistory with empty results', () => { + const state = { otherData: 'preserved' } + const result = addSearchHistory(state) + expect(result.searchHistory).toEqual({ results: [] }) + expect(result.otherData).toBe('preserved') + }) +}) + +describe('addTimeImportedAndDerivationIndex', () => { + it('adds timeImportedMs to all accounts and derivationIndex to native accounts', () => { + const state = { + wallet: { + accounts: { + '0x123': { type: 'native', address: '0x123' }, + '0x456': { type: 'readonly', address: '0x456' }, + }, + }, + } + const result = addTimeImportedAndDerivationIndex(state) + expect(result.wallet.accounts['0x123'].timeImportedMs).toBeDefined() + expect(result.wallet.accounts['0x123'].derivationIndex).toBe(0) + expect(result.wallet.accounts['0x456'].timeImportedMs).toBeDefined() + expect(result.wallet.accounts['0x456'].derivationIndex).toBeUndefined() + }) + + it('handles missing accounts', () => { + const state = { wallet: {} } + const result = addTimeImportedAndDerivationIndex(state) + expect(result.wallet).toBeDefined() + }) +}) + +describe('addModalsState', () => { + it('adds modals state and removes balances', () => { + const state = { balances: { someData: true } } + const result = addModalsState(state) + expect(result.modals[ModalName.WalletConnectScan]).toEqual({ isOpen: false, initialState: 0 }) + expect(result.modals[ModalName.Swap]).toEqual({ isOpen: false, initialState: undefined }) + expect(result.modals[ModalName.Send]).toEqual({ isOpen: false, initialState: undefined }) + expect(result.balances).toBeUndefined() + }) +}) + +describe('addWalletConnectPendingSessionAndSettings', () => { + it('adds pendingSession and settings', () => { + const state = { walletConnect: {}, wallet: { bluetooth: true } } + const result = addWalletConnectPendingSessionAndSettings(state) + expect(result.walletConnect.pendingSession).toBeNull() + expect(result.wallet.settings).toEqual({}) + expect(result.wallet.bluetooth).toBeUndefined() + }) +}) + +describe('removeNonZeroDerivationIndexAccounts', () => { + it('removes accounts with non-zero derivation index and sets mnemonicId', () => { + const state = { + wallet: { + accounts: { + '0x123': { type: 'native', derivationIndex: 0, address: '0x123' }, + '0x456': { type: 'native', derivationIndex: 1, address: '0x456' }, + }, + }, + } + const result = removeNonZeroDerivationIndexAccounts(state) + expect(result.wallet.accounts['0x123'].mnemonicId).toBe('0x123') + expect(result.wallet.accounts['0x456']).toBeUndefined() + }) +}) + +describe('addCloudBackup', () => { + it('adds cloudBackup with empty backupsFound', () => { + const state = { otherData: 'preserved' } + const result = addCloudBackup(state) + expect(result.cloudBackup).toEqual({ backupsFound: [] }) + }) +}) + +describe('removeLocalTypeAccounts', () => { + it('removes accounts with local type', () => { + const state = { + wallet: { + accounts: { + '0x123': { type: 'local' }, + '0x456': { type: 'native' }, + }, + }, + } + const result = removeLocalTypeAccounts(state) + expect(result.wallet.accounts['0x123']).toBeUndefined() + expect(result.wallet.accounts['0x456']).toBeDefined() + }) +}) + +describe('removeDemoAccount', () => { + it('removes the demo account', () => { + const state = { + wallet: { + accounts: { + [OLD_DEMO_ACCOUNT_ADDRESS]: { type: 'demo' }, + '0x456': { type: 'native' }, + }, + }, + } + const result = removeDemoAccount(state) + expect(result.wallet.accounts[OLD_DEMO_ACCOUNT_ADDRESS]).toBeUndefined() + expect(result.wallet.accounts['0x456']).toBeDefined() + }) + + it('handles missing demo account', () => { + const state = { wallet: { accounts: { '0x123': { type: 'native' } } } } + const result = removeDemoAccount(state) + expect(result.wallet.accounts['0x123']).toBeDefined() + }) +}) + +describe('addBiometricSettings', () => { + it('adds biometricSettings with defaults', () => { + const state = { otherData: 'preserved' } + const result = addBiometricSettings(state) + expect(result.biometricSettings).toEqual({ + requiredForAppAccess: false, + requiredForTransactions: false, + }) + }) +}) + +describe('addPushNotificationsEnabledToAccounts', () => { + it('adds pushNotificationsEnabled to all accounts', () => { + const state = { + wallet: { + accounts: { + '0x123': { address: '0x123' }, + '0x456': { address: '0x456' }, + }, + }, + } + const result = addPushNotificationsEnabledToAccounts(state) + expect(result.wallet.accounts['0x123'].pushNotificationsEnabled).toBe(false) + expect(result.wallet.accounts['0x456'].pushNotificationsEnabled).toBe(false) + }) + + it('handles missing accounts', () => { + const state = { wallet: {} } + const result = addPushNotificationsEnabledToAccounts(state) + expect(result.wallet.accounts).toEqual({}) + }) +}) + +describe('addEnsState', () => { + it('adds ens state with empty ensForAddress', () => { + const state = { otherData: 'preserved' } + const result = addEnsState(state) + expect(result.ens).toEqual({ ensForAddress: {} }) + }) +}) + +describe('migrateBiometricSettings', () => { + it('migrates isBiometricAuthEnabled to biometricSettings', () => { + const state = { wallet: { isBiometricAuthEnabled: true } } + const result = migrateBiometricSettings(state) + expect(result.biometricSettings).toEqual({ + requiredForAppAccess: true, + requiredForTransactions: true, + }) + expect(result.wallet.isBiometricAuthEnabled).toBeUndefined() + }) + + it('defaults to false when isBiometricAuthEnabled is missing', () => { + const state = { wallet: {} } + const result = migrateBiometricSettings(state) + expect(result.biometricSettings).toEqual({ + requiredForAppAccess: false, + requiredForTransactions: false, + }) + }) + + it('falls back to default settings on error', () => { + const state = { wallet: createThrowingProxy({}, { throwingMethods: ['*'] }) } + const result = migrateBiometricSettings(state) + expect(result.biometricSettings).toEqual({ + requiredForAppAccess: false, + requiredForTransactions: false, + }) + }) +}) + +describe('changeNativeTypeToSignerMnemonic', () => { + it('changes native type to SignerMnemonic', () => { + const state = { + wallet: { + accounts: { + '0x123': { type: 'native' }, + '0x456': { type: 'readonly' }, + }, + }, + } + const result = changeNativeTypeToSignerMnemonic(state) + expect(result.wallet.accounts['0x123'].type).toBe(AccountType.SignerMnemonic) + expect(result.wallet.accounts['0x456'].type).toBe('readonly') + }) +}) + +describe('removeDataApi', () => { + it('removes dataApi from state', () => { + const state = { dataApi: { someData: true }, otherData: 'preserved' } + const result = removeDataApi(state) + expect(result.dataApi).toBeUndefined() + expect(result.otherData).toBe('preserved') + }) +}) + +describe('resetPushNotificationsEnabled', () => { + it('resets pushNotificationsEnabled to false for all accounts', () => { + const state = { + wallet: { + accounts: { + '0x123': { pushNotificationsEnabled: true }, + '0x456': { pushNotificationsEnabled: true }, + }, + }, + } + const result = resetPushNotificationsEnabled(state) + expect(result.wallet.accounts['0x123'].pushNotificationsEnabled).toBe(false) + expect(result.wallet.accounts['0x456'].pushNotificationsEnabled).toBe(false) + }) + + it('returns state unchanged if no accounts', () => { + const state = { wallet: {} } + const result = resetPushNotificationsEnabled(state) + expect(result).toEqual(state) + }) +}) + +describe('removeEnsState', () => { + it('removes ens from state', () => { + const state = { ens: { someData: true }, otherData: 'preserved' } + const result = removeEnsState(state) + expect(result.ens).toBeUndefined() + expect(result.otherData).toBe('preserved') + }) +}) + +describe('filterToSupportedChains', () => { + it('filters chains, blocks, and transactions to supported chains only', () => { + const state = { + chains: { + byChainId: { + '1': { isActive: true }, + '99999': { isActive: true }, // unsupported + }, + }, + blocks: { + byChainId: { + '1': { blockNumber: 123 }, + '99999': { blockNumber: 456 }, + }, + }, + transactions: { + '0x123': { + '1': { tx1: { id: 'tx1' } }, + '99999': { tx2: { id: 'tx2' } }, + }, + }, + } + const result = filterToSupportedChains(state) + expect(result.chains.byChainId['1']).toBeDefined() + expect(result.chains.byChainId['99999']).toBeUndefined() + expect(result.blocks.byChainId['1']).toBeDefined() + expect(result.blocks.byChainId['99999']).toBeUndefined() + expect(result.transactions['0x123']['1']).toBeDefined() + expect(result.transactions['0x123']['99999']).toBeUndefined() + }) +}) + +describe('resetLastTxNotificationUpdate', () => { + it('resets lastTxNotificationUpdate to empty object', () => { + const state = { notifications: { lastTxNotificationUpdate: { '0x123': 12345 } } } + const result = resetLastTxNotificationUpdate(state) + expect(result.notifications.lastTxNotificationUpdate).toEqual({}) + }) +}) + +describe('addExperimentsSlice', () => { + it('adds experiments slice with empty experiments and featureFlags', () => { + const state = { otherData: 'preserved' } + const result = addExperimentsSlice(state) + expect(result.experiments).toEqual({ experiments: {}, featureFlags: {} }) + }) +}) + +describe('removeCoingeckoApiAndTokenLists', () => { + it('removes coingeckoApi and token-related data', () => { + const state = { + coingeckoApi: { data: true }, + tokens: { watchedTokens: [], tokenPairs: [], otherData: true }, + } + const result = removeCoingeckoApiAndTokenLists(state) + expect(result.coingeckoApi).toBeUndefined() + expect(result.tokens.watchedTokens).toBeUndefined() + expect(result.tokens.tokenPairs).toBeUndefined() + expect(result.tokens.otherData).toBe(true) + }) +}) + +describe('resetTokensOrderByAndMetadataDisplayType', () => { + it('removes tokensOrderBy and tokensMetadataDisplayType from wallet settings', () => { + const state = { + wallet: { + settings: { tokensOrderBy: 'volume', tokensMetadataDisplayType: 'full', otherSetting: true }, + }, + } + const result = resetTokensOrderByAndMetadataDisplayType(state) + expect(result.wallet.settings.tokensOrderBy).toBeUndefined() + expect(result.wallet.settings.tokensMetadataDisplayType).toBeUndefined() + expect(result.wallet.settings.otherSetting).toBe(true) + }) +}) + +describe('transformNotificationCountToStatus', () => { + it('transforms notificationCount to notificationStatus', () => { + const state = { + notifications: { + notificationCount: { '0x123': 5, '0x456': 0 }, + }, + } + const result = transformNotificationCountToStatus(state) + expect(result.notifications.notificationStatus['0x123']).toBe(true) + expect(result.notifications.notificationStatus['0x456']).toBe(false) + expect(result.notifications.notificationCount).toBeUndefined() + }) + + it('falls back to empty notificationStatus on error', () => { + const state = { + notifications: { notificationCount: createThrowingProxy({}, { throwingMethods: ['*'] }) }, + } + const result = transformNotificationCountToStatus(state) + expect(result.notifications.notificationStatus).toEqual({}) + }) +}) + +describe('addPasswordLockout', () => { + it('adds passwordLockout with zero attempts', () => { + const state = { otherData: 'preserved' } + const result = addPasswordLockout(state) + expect(result.passwordLockout).toEqual({ passwordAttempts: 0 }) + }) +}) + +describe('removeShowSmallBalances', () => { + it('removes showSmallBalances from wallet settings', () => { + const state = { wallet: { settings: { showSmallBalances: true, otherSetting: true } } } + const result = removeShowSmallBalances(state) + expect(result.wallet.settings.showSmallBalances).toBeUndefined() + expect(result.wallet.settings.otherSetting).toBe(true) + }) +}) + +describe('resetTokensOrderBy', () => { + it('removes tokensOrderBy from wallet settings', () => { + const state = { wallet: { settings: { tokensOrderBy: 'volume' } } } + const result = resetTokensOrderBy(state) + expect(result.wallet.settings.tokensOrderBy).toBeUndefined() + }) +}) + +describe('removeTokensMetadataDisplayType', () => { + it('removes tokensMetadataDisplayType from wallet settings', () => { + const state = { wallet: { settings: { tokensMetadataDisplayType: 'full' } } } + const result = removeTokensMetadataDisplayType(state) + expect(result.wallet.settings.tokensMetadataDisplayType).toBeUndefined() + }) +}) + +describe('removeTokenListsAndCustomTokens', () => { + it('removes tokenLists and customTokens', () => { + const state = { tokenLists: { lists: [] }, tokens: { customTokens: [], otherData: true } } + const result = removeTokenListsAndCustomTokens(state) + expect(result.tokenLists).toBeUndefined() + expect(result.tokens.customTokens).toBeUndefined() + expect(result.tokens.otherData).toBe(true) + }) +}) + +describe('migrateFiatPurchaseTransactionInfo', () => { + it('migrates fiat purchase transaction info format', () => { + const state = { + transactions: { + '0x123': { + '1': { + tx1: { + id: 'tx1', + status: TransactionStatus.Success, + typeInfo: { + type: TransactionType.FiatPurchaseDeprecated, + explorerUrl: 'https://example.com', + outputTokenAddress: '0xtoken', + outputCurrencyAmountFormatted: 100, + outputCurrencyAmountPrice: 2, + syncedWithBackend: true, + }, + }, + }, + }, + }, + } + const result = migrateFiatPurchaseTransactionInfo(state) + const txTypeInfo = result.transactions['0x123']['1'].tx1.typeInfo + expect(txTypeInfo.inputCurrency).toBeUndefined() + expect(txTypeInfo.outputCurrency.type).toBe('crypto') + }) + + it('removes failed fiat purchase transactions', () => { + const state = { + transactions: { + '0x123': { + '1': { + tx1: { + id: 'tx1', + status: TransactionStatus.Failed, + typeInfo: { type: TransactionType.FiatPurchaseDeprecated }, + }, + }, + }, + }, + } + const result = migrateFiatPurchaseTransactionInfo(state) + // Failed FiatPurchaseDeprecated transactions are skipped entirely + expect(result.transactions['0x123']?.['1']?.tx1).toBeUndefined() + }) + + it('falls back to empty transactions on error', () => { + const state = { + transactions: createThrowingProxy({}, { throwingMethods: ['*'] }), + otherData: 'preserved', + } + const result = migrateFiatPurchaseTransactionInfo(state) + expect(result.transactions).toEqual({}) + expect(result.otherData).toBe('preserved') + }) +}) + +describe('emptyMigration', () => { + it('returns state unchanged', () => { + const state = { someData: 'preserved' } + const result = emptyMigration(state) + expect(result).toEqual(state) + }) +}) + +describe('resetEnsApi', () => { + it('removes ENS from state', () => { + const state = { ENS: { data: true }, otherData: 'preserved' } + const result = resetEnsApi(state) + expect(result.ENS).toBeUndefined() + expect(result.otherData).toBe('preserved') + }) +}) + +describe('addReplaceAccountOptions', () => { + it('adds replaceAccountOptions to wallet', () => { + const state = { wallet: {} } + const result = addReplaceAccountOptions(state) + expect(result.wallet.replaceAccountOptions).toEqual({ + isReplacingAccount: false, + skipToSeedPhrase: false, + }) + }) +}) + +describe('addLastBalancesReport', () => { + it('adds telemetry with lastBalancesReport', () => { + const state = { otherData: 'preserved' } + const result = addLastBalancesReport(state) + expect(result.telemetry).toEqual({ lastBalancesReport: 0 }) + }) +}) + +describe('addAppearanceSetting', () => { + it('adds appearanceSettings with system default', () => { + const state = { otherData: 'preserved' } + const result = addAppearanceSetting(state) + expect(result.appearanceSettings).toEqual({ selectedAppearanceSettings: 'system' }) + }) +}) + +describe('addHiddenNfts', () => { + it('adds hiddenNfts to favorites', () => { + const state = { favorites: { tokens: [] } } + const result = addHiddenNfts(state) + expect(result.favorites.hiddenNfts).toEqual({}) + expect(result.favorites.tokens).toEqual([]) + }) +}) + +describe('correctFailedFiatOnRampTxIds', () => { + it('extracts id from explorerUrl for failed fiat purchase transactions', () => { + const state = { + transactions: { + '0x123': { + '1': { + tx1: { + id: 'tx1', + status: TransactionStatus.Failed, + typeInfo: { + type: TransactionType.FiatPurchaseDeprecated, + explorerUrl: 'https://example.com?id=extractedId123', + }, + }, + }, + }, + }, + } + const result = correctFailedFiatOnRampTxIds(state) + expect(result.transactions['0x123']['1'].tx1.typeInfo.id).toBe('extractedId123') + }) + + it('falls back to empty transactions on error', () => { + const state = { + transactions: createThrowingProxy({}, { throwingMethods: ['*'] }), + } + const result = correctFailedFiatOnRampTxIds(state) + expect(result.transactions).toEqual({}) + }) +}) + +describe('removeReplaceAccountOptions', () => { + it('removes replaceAccountOptions from wallet', () => { + const state = { wallet: { replaceAccountOptions: { isReplacingAccount: false }, otherData: true } } + const result = removeReplaceAccountOptions(state) + expect(result.wallet.replaceAccountOptions).toBeUndefined() + expect(result.wallet.otherData).toBe(true) + }) +}) + +describe('removeExperimentsSlice', () => { + it('removes experiments from state', () => { + const state = { experiments: { data: true }, otherData: 'preserved' } + const result = removeExperimentsSlice(state) + expect(result.experiments).toBeUndefined() + expect(result.otherData).toBe('preserved') + }) +}) + +describe('removePersistedWalletConnectSlice', () => { + it('removes walletConnect from state', () => { + const state = { walletConnect: { data: true }, otherData: 'preserved' } + const result = removePersistedWalletConnectSlice(state) + expect(result.walletConnect).toBeUndefined() + expect(result.otherData).toBe('preserved') + }) +}) + +describe('addLastBalancesReportValue', () => { + it('adds lastBalancesReportValue to telemetry', () => { + const state = { telemetry: { lastBalancesReport: 0 } } + const result = addLastBalancesReportValue(state) + expect(result.telemetry.lastBalancesReportValue).toBe(0) + expect(result.telemetry.lastBalancesReport).toBe(0) + }) +}) + +describe('removeFlashbotsEnabledFromWalletSlice', () => { + it('removes flashbotsEnabled from wallet', () => { + const state = { wallet: { flashbotsEnabled: true, otherData: true } } + const result = removeFlashbotsEnabledFromWalletSlice(state) + expect(result.wallet.flashbotsEnabled).toBeUndefined() + expect(result.wallet.otherData).toBe(true) + }) +}) + +describe('convertHiddenNftsToNftsData', () => { + it('converts hiddenNfts to nftsData format', () => { + const state = { + favorites: { + hiddenNfts: { + '0x123': { + 'mainnet.0xcontract.123': true, + }, + }, + }, + } + const result = convertHiddenNftsToNftsData(state) + expect(result.favorites.nftsData['0x123']).toBeDefined() + expect(result.favorites.hiddenNfts).toBeUndefined() + }) + + it('falls back to empty nftsData on error', () => { + const state = { + favorites: { hiddenNfts: createThrowingProxy({}, { throwingMethods: ['*'] }) }, + } + const result = convertHiddenNftsToNftsData(state) + expect(result.favorites.nftsData).toEqual({}) + }) +}) + +describe('removeProviders', () => { + it('removes providers from state', () => { + const state = { providers: { data: true }, otherData: 'preserved' } + const result = removeProviders(state) + expect(result.providers).toBeUndefined() + expect(result.otherData).toBe('preserved') + }) +}) + +describe('addTokensVisibility', () => { + it('adds tokensVisibility to favorites', () => { + const state = { favorites: { tokens: [] } } + const result = addTokensVisibility(state) + expect(result.favorites.tokensVisibility).toEqual({}) + }) +}) + +describe('deleteRTKQuerySlices', () => { + it('removes RTK Query slices', () => { + const state = { + ENS: {}, + ens: {}, + gasApi: {}, + onChainBalanceApi: {}, + routingApi: {}, + trmApi: {}, + otherData: 'preserved', + } + const result = deleteRTKQuerySlices(state) + expect(result.ENS).toBeUndefined() + expect(result.ens).toBeUndefined() + expect(result.gasApi).toBeUndefined() + expect(result.onChainBalanceApi).toBeUndefined() + expect(result.routingApi).toBeUndefined() + expect(result.trmApi).toBeUndefined() + expect(result.otherData).toBe('preserved') + }) +}) + +describe('resetActiveChains', () => { + it('resets byChainId to default active chains', () => { + const state = { chains: { byChainId: { '1': { isActive: false } } } } + const result = resetActiveChains(state) + expect(result.chains.byChainId['1']).toEqual({ isActive: true }) + expect(result.chains.byChainId['10']).toEqual({ isActive: true }) + expect(result.chains.byChainId['56']).toEqual({ isActive: true }) + expect(result.chains.byChainId['137']).toEqual({ isActive: true }) + expect(result.chains.byChainId['8453']).toEqual({ isActive: true }) + expect(result.chains.byChainId['42161']).toEqual({ isActive: true }) + }) +}) + +describe('addTweaksStartingState', () => { + it('adds empty tweaks object', () => { + const state = { otherData: 'preserved' } + const result = addTweaksStartingState(state) + expect(result.tweaks).toEqual({}) + }) +}) + +describe('addSwapProtectionSetting', () => { + it('adds swapProtection setting to wallet settings', () => { + const state = { wallet: { settings: {} } } + const result = addSwapProtectionSetting(state) + expect(result.wallet.settings.swapProtection).toBe(SwapProtectionSetting.On) + }) +}) + +describe('deleteChainsSlice', () => { + it('removes chains from state', () => { + const state = { chains: { byChainId: {} }, otherData: 'preserved' } + const result = deleteChainsSlice(state) + expect(result.chains).toBeUndefined() + expect(result.otherData).toBe('preserved') + }) +}) + +describe('addLanguageSettings', () => { + it('adds languageSettings with English default', () => { + const state = { otherData: 'preserved' } + const result = addLanguageSettings(state) + expect(result.languageSettings).toEqual({ currentLanguage: Language.English }) + }) +}) + +describe('addFiatCurrencySettings', () => { + it('adds fiatCurrencySettings with USD default', () => { + const state = { otherData: 'preserved' } + const result = addFiatCurrencySettings(state) + expect(result.fiatCurrencySettings).toEqual({ currentCurrency: FiatCurrency.UnitedStatesDollar }) + }) +}) + +describe('updateLanguageSettings', () => { + it('sets languageSettings to English', () => { + const state = { languageSettings: { currentLanguage: 'es' } } + const result = updateLanguageSettings(state) + expect(result.languageSettings).toEqual({ currentLanguage: Language.English }) + }) +}) + +describe('setWalletDeviceLanguage', () => { + const mockGetWalletDeviceLanguage = jest.mocked(getWalletDeviceLanguage) + + beforeEach(() => { + mockGetWalletDeviceLanguage.mockReturnValue(Language.SpanishSpain) + }) + + it('sets currentLanguage to device language when userSettings exists', () => { + const state = { + userSettings: { + currentLanguage: Language.English, + otherSetting: 'preserved', + }, + } + const result = setWalletDeviceLanguage(state) + expect(result.userSettings.currentLanguage).toBe(Language.SpanishSpain) + expect(result.userSettings.otherSetting).toBe('preserved') + }) + + it('returns state unchanged when userSettings does not exist', () => { + const state = { otherData: 'preserved' } + const result = setWalletDeviceLanguage(state) + expect(result.userSettings).toBeUndefined() + expect(result.otherData).toBe('preserved') + }) + + it('falls back to English when getWalletDeviceLanguage throws', () => { + mockGetWalletDeviceLanguage.mockImplementation(() => { + throw new Error('device locales unavailable') + }) + + const state = { + userSettings: { + currentLanguage: Language.French, + otherSetting: 'preserved', + }, + } + const result = setWalletDeviceLanguage(state) + expect(result.userSettings.currentLanguage).toBe(Language.English) + expect(result.userSettings.otherSetting).toBe('preserved') + }) +}) + +describe('addWalletIsFunded', () => { + it('adds walletIsFunded to telemetry', () => { + const state = { telemetry: { lastBalancesReport: 0 } } + const result = addWalletIsFunded(state) + expect(result.telemetry.walletIsFunded).toBe(false) + expect(result.telemetry.lastBalancesReport).toBe(0) + }) +}) + +describe('addBehaviorHistory', () => { + it('adds behaviorHistory with default values', () => { + const state = { otherData: 'preserved' } + const result = addBehaviorHistory(state) + expect(result.behaviorHistory).toEqual({ + hasViewedReviewScreen: false, + hasSubmittedHoldToSwap: false, + }) + }) +}) + +describe('addAllowAnalyticsSwitch', () => { + it('adds analytics settings to telemetry', () => { + const state = { telemetry: { walletIsFunded: false } } + const result = addAllowAnalyticsSwitch(state) + expect(result.telemetry.allowAnalytics).toBe(true) + expect(result.telemetry.lastHeartbeat).toBe(0) + expect(result.telemetry.walletIsFunded).toBe(false) + }) +}) + +describe('moveSettingStateToGlobal', () => { + it('moves showSmallBalances and showSpamTokens from accounts to global settings', () => { + const state = { + wallet: { + accounts: { + '0x123': { showSmallBalances: false, showSpamTokens: true }, + }, + settings: {}, + }, + } + const result = moveSettingStateToGlobal(state) + expect(result.wallet.settings.hideSmallBalances).toBe(true) + expect(result.wallet.settings.hideSpamTokens).toBe(false) + expect(result.wallet.accounts['0x123'].showSmallBalances).toBeUndefined() + expect(result.wallet.accounts['0x123'].showSpamTokens).toBeUndefined() + }) + + it('defaults to hiding when no accounts exist', () => { + const state = { wallet: { accounts: {}, settings: {} } } + const result = moveSettingStateToGlobal(state) + expect(result.wallet.settings.hideSmallBalances).toBe(true) + expect(result.wallet.settings.hideSpamTokens).toBe(true) + }) + + it('falls back to default settings on error', () => { + const state = { + wallet: { accounts: createThrowingProxy({}, { throwingMethods: ['*'] }), settings: {} }, + } + const result = moveSettingStateToGlobal(state) + expect(result.wallet.settings.hideSmallBalances).toBe(true) + expect(result.wallet.settings.hideSpamTokens).toBe(true) + }) +}) + +describe('addSkippedUnitagBoolean', () => { + it('adds hasSkippedUnitagPrompt to behaviorHistory', () => { + const state = { behaviorHistory: { otherFlag: true } } + const result = addSkippedUnitagBoolean(state) + expect(result.behaviorHistory.hasSkippedUnitagPrompt).toBe(false) + expect(result.behaviorHistory.otherFlag).toBe(true) + }) +}) + +describe('addCompletedUnitagsIntroBoolean', () => { + it('adds hasCompletedUnitagsIntroModal to behaviorHistory', () => { + const state = { behaviorHistory: { otherFlag: true } } + const result = addCompletedUnitagsIntroBoolean(state) + expect(result.behaviorHistory.hasCompletedUnitagsIntroModal).toBe(false) + expect(result.behaviorHistory.otherFlag).toBe(true) + }) +}) + +describe('addUniconV2IntroModalBoolean', () => { + it('adds hasViewedUniconV2IntroModal to behaviorHistory', () => { + const state = { behaviorHistory: { otherFlag: true } } + const result = addUniconV2IntroModalBoolean(state) + expect(result.behaviorHistory.hasViewedUniconV2IntroModal).toBe(false) + expect(result.behaviorHistory.otherFlag).toBe(true) + }) +}) + +describe('flattenTokenVisibility', () => { + it('flattens tokensVisibility and nftsData from account-based to flat structure', () => { + const state = { + favorites: { + tokensVisibility: { + '0x123': { token1: { isVisible: true } }, + '0x456': { token2: { isVisible: false } }, + }, + nftsData: { + '0x123': { nft1: { isHidden: false } }, + }, + }, + } + const result = flattenTokenVisibility(state) + expect(result.favorites.tokensVisibility['token1']).toEqual({ isVisible: true }) + expect(result.favorites.tokensVisibility['token2']).toEqual({ isVisible: false }) + expect(result.favorites.nftsVisibility).toBeDefined() + expect(result.favorites.nftsData).toBeUndefined() + }) + + it('falls back to empty objects on error', () => { + const state = { + favorites: { tokensVisibility: createThrowingProxy({}, { throwingMethods: ['*'] }) }, + } + const result = flattenTokenVisibility(state) + expect(result.favorites.tokensVisibility).toEqual({}) + expect(result.favorites.nftsVisibility).toEqual({}) + }) +}) + +describe('addExtensionOnboardingState', () => { + it('adds extensionOnboardingState to behaviorHistory', () => { + const state = { behaviorHistory: { otherFlag: true } } + const result = addExtensionOnboardingState(state) + expect(result.behaviorHistory.extensionOnboardingState).toBe('Undefined') + expect(result.behaviorHistory.otherFlag).toBe(true) + }) +}) + +describe('resetOnboardingStateForGA', () => { + it('resets extensionOnboardingState to Undefined', () => { + const state = { behaviorHistory: { extensionOnboardingState: 'Completed' } } + const result = resetOnboardingStateForGA(state) + expect(result.behaviorHistory.extensionOnboardingState).toBe('Undefined') + }) +}) + +describe('deleteOldOnRampTxData', () => { + it('removes FiatPurchaseDeprecated transactions', () => { + const state = { + transactions: { + '0x123': { + '1': { + tx1: { typeInfo: { type: TransactionType.FiatPurchaseDeprecated } }, + tx2: { typeInfo: { type: TransactionType.Swap } }, + }, + }, + }, + } + const result = deleteOldOnRampTxData(state) + expect(result.transactions['0x123']['1'].tx1).toBeUndefined() + expect(result.transactions['0x123']['1'].tx2).toBeDefined() + }) + + it('falls back to empty transactions on error', () => { + // Create a proxy with a non-empty target so Object.keys returns keys, + // then accessing those keys through get will throw + const state = { + transactions: { + '0x123': createThrowingProxy({ '1': {} }, { throwingMethods: ['*'] }), + }, + } + const result = deleteOldOnRampTxData(state) + expect(result.transactions).toEqual({}) + }) +}) + +describe('addPushNotifications', () => { + it('enables push notifications if any account has notifications enabled', () => { + const state = { + wallet: { + accounts: { + '0x123': { pushNotificationsEnabled: true }, + '0x456': { pushNotificationsEnabled: false }, + }, + }, + } + const result = addPushNotifications(state) + expect(result.pushNotifications.generalUpdatesEnabled).toBe(true) + expect(result.pushNotifications.priceAlertsEnabled).toBe(true) + }) + + it('disables push notifications if all accounts have notifications disabled', () => { + const state = { + wallet: { + accounts: { + '0x123': { pushNotificationsEnabled: false }, + '0x456': { pushNotificationsEnabled: false }, + }, + }, + } + const result = addPushNotifications(state) + expect(result.pushNotifications.generalUpdatesEnabled).toBe(false) + expect(result.pushNotifications.priceAlertsEnabled).toBe(false) + }) + + it('falls back to enabled on error', () => { + const state = { + wallet: { + accounts: { + '0x123': createThrowingProxy({}, { throwingMethods: ['*'] }), + }, + }, + } + const result = addPushNotifications(state) + expect(result.pushNotifications.generalUpdatesEnabled).toBe(true) + expect(result.pushNotifications.priceAlertsEnabled).toBe(true) + }) +}) + +describe('migrateDappRequestInfoTypes', () => { + it('migrates uwulink source to requestType', () => { + const state = { + transactions: { + '0x123': { + '1': { + tx1: { + typeInfo: { + externalDappInfo: { source: 'uwulink' }, + }, + }, + }, + }, + }, + } + const result = migrateDappRequestInfoTypes(state) + const dappInfo = result.transactions['0x123']['1'].tx1.typeInfo.externalDappInfo + expect(dappInfo.requestType).toBe(DappRequestType.UwULink) + expect(dappInfo.source).toBeUndefined() + }) + + it('migrates walletconnect source to requestType', () => { + const state = { + transactions: { + '0x123': { + '1': { + tx1: { + typeInfo: { + externalDappInfo: { source: 'walletconnect' }, + }, + }, + }, + }, + }, + } + const result = migrateDappRequestInfoTypes(state) + const dappInfo = result.transactions['0x123']['1'].tx1.typeInfo.externalDappInfo + expect(dappInfo.requestType).toBe(DappRequestType.WalletConnectSessionRequest) + expect(dappInfo.source).toBeUndefined() + }) + + it('migrates WCConfirm dapp to dappRequestInfo', () => { + const state = { + transactions: { + '0x123': { + '1': { + tx1: { + typeInfo: { + type: TransactionType.WCConfirm, + dapp: { name: 'TestDapp' }, + }, + }, + }, + }, + }, + } + const result = migrateDappRequestInfoTypes(state) + const typeInfo = result.transactions['0x123']['1'].tx1.typeInfo + expect(typeInfo.dappRequestInfo).toEqual({ name: 'TestDapp' }) + expect(typeInfo.dapp).toBeUndefined() + }) + + it('handles missing transactions state', () => { + const state = { otherData: 'preserved' } + const result = migrateDappRequestInfoTypes(state) + expect(result.otherData).toBe('preserved') + }) + + it('falls back to empty transactions on error', () => { + const state = { + transactions: createThrowingProxy({}, { throwingMethods: ['*'] }), + otherData: 'preserved', + } + const result = migrateDappRequestInfoTypes(state) + expect(result.transactions).toEqual({}) + expect(result.otherData).toBe('preserved') + }) +}) + +describe('migrateAndRemoveCloudBackupSlice', () => { + it('migrates cloudBackup email to wallet and removes cloudBackup', () => { + const state = { + cloudBackup: { + backupsFound: [{ email: 'test@example.com' }], + }, + wallet: {}, + } + const result = migrateAndRemoveCloudBackupSlice(state) + expect(result.wallet.androidCloudBackupEmail).toBe('test@example.com') + expect(result.cloudBackup).toBeUndefined() + }) + + it('removes cloudBackup without email if no backup has email', () => { + const state = { + cloudBackup: { backupsFound: [] }, + wallet: {}, + } + const result = migrateAndRemoveCloudBackupSlice(state) + expect(result.wallet.androidCloudBackupEmail).toBeUndefined() + expect(result.cloudBackup).toBeUndefined() + }) + + it('falls back to removing cloudBackup on error', () => { + const state = { + cloudBackup: createThrowingProxy({}, { throwingMethods: ['*'] }), + wallet: {}, + } + const result = migrateAndRemoveCloudBackupSlice(state) + expect(result.cloudBackup).toBeUndefined() + }) +}) diff --git a/apps/mobile/src/app/mobileMigrations.ts b/apps/mobile/src/app/mobileMigrations.ts new file mode 100644 index 00000000..e2f6618b --- /dev/null +++ b/apps/mobile/src/app/mobileMigrations.ts @@ -0,0 +1,1027 @@ +// Type information currently gets lost after a migration +/* oxlint-disable typescript/no-explicit-any -- Migration logic requires flexible typing */ +/* oxlint-disable typescript/explicit-function-return-type */ +/* oxlint-disable typescript/no-unsafe-return */ +/* oxlint-disable max-lines */ + +import dayjs from 'dayjs' +import { AccountType } from 'uniswap/src/features/accounts/types' +import { UniverseChainId } from 'uniswap/src/features/chains/types' +import { toSupportedChainId } from 'uniswap/src/features/chains/utils' +import { FiatCurrency } from 'uniswap/src/features/fiatCurrency/constants' +import { Language } from 'uniswap/src/features/language/constants' +import { getNFTAssetKey } from 'uniswap/src/features/nfts/utils' +import { ModalName } from 'uniswap/src/features/telemetry/constants' +import { type TransactionsState } from 'uniswap/src/features/transactions/slice' +import { + type ChainIdToTxIdToDetails, + TransactionStatus, + TransactionType, +} from 'uniswap/src/features/transactions/types/transactionDetails' +import { getWalletDeviceLanguage } from 'uniswap/src/i18n/utils' +import { createSafeMigrationFactory } from 'uniswap/src/state/createSafeMigration' +import { DappRequestType } from 'uniswap/src/types/walletConnect' +import { type Account } from 'wallet/src/features/wallet/accounts/types' +import { SwapProtectionSetting } from 'wallet/src/features/wallet/slice' + +const createSafeMigration = createSafeMigrationFactory('mobileMigrations') + +export const OLD_DEMO_ACCOUNT_ADDRESS = '0xdd0E380579dF30E38524F9477808d9eE37E2dEa6' + +export const restructureTransactionsAndNotifications = createSafeMigration({ + name: 'restructureTransactionsAndNotifications', + migrate: (state: any) => { + const oldTransactionState = state?.transactions + const newTransactionState: any = {} + + const chainIds = Object.keys(oldTransactionState?.byChainId ?? {}) + for (const chainId of chainIds) { + const transactions = oldTransactionState.byChainId?.[chainId] ?? [] + const txIds = Object.keys(transactions) + for (const txId of txIds) { + const txDetails = transactions[txId] + const address = txDetails.from + newTransactionState[address] ??= {} + newTransactionState[address][chainId] ??= {} + newTransactionState[address][chainId][txId] = { ...txDetails } + } + } + + const oldNotificationState = state.notifications + const newNotificationState = { ...oldNotificationState, lastTxNotificationUpdate: {} } + const addresses = Object.keys(oldTransactionState?.lastTxHistoryUpdate || []) + for (const address of addresses) { + newNotificationState.lastTxNotificationUpdate[address] = { + [UniverseChainId.Mainnet]: oldTransactionState.lastTxHistoryUpdate[address], + } + } + + return { ...state, transactions: newTransactionState, notifications: newNotificationState } + }, + onError: (state: any) => ({ + ...state, + transactions: {}, + notifications: { ...state.notifications, lastTxNotificationUpdate: {} }, + }), +}) + +export function removeWalletConnectModalState(state: any) { + const newState = { ...state } + delete newState.walletConnect?.modalState + return newState +} + +export function renameFollowedAddressesToWatchedAddresses(state: any) { + const newState = { ...state } + const oldFollowingAddresses = state?.favorites?.followedAddresses + if (oldFollowingAddresses) { + newState.favorites.watchedAddresses = oldFollowingAddresses + } + delete newState?.favorites?.followedAddresses + return newState +} + +export function addSearchHistory(state: any) { + const newState = { ...state } + newState.searchHistory = { results: [] } + return newState +} + +export function addTimeImportedAndDerivationIndex(state: any) { + const newState = { ...state } + const accounts = newState?.wallet?.accounts ?? {} + let derivationIndex = 0 + for (const account of Object.keys(accounts)) { + newState.wallet.accounts[account].timeImportedMs = dayjs().valueOf() + if (newState.wallet.accounts[account].type === 'native') { + newState.wallet.accounts[account].derivationIndex = derivationIndex + derivationIndex += 1 + } + } + return newState +} + +export function addModalsState(state: any) { + const newState = { ...state } + newState.modals = { + [ModalName.WalletConnectScan]: { + isOpen: false, + initialState: 0, + }, + [ModalName.Swap]: { + isOpen: false, + initialState: undefined, + }, + [ModalName.Send]: { + isOpen: false, + initialState: undefined, + }, + } + + delete newState?.balances + return newState +} + +export function addWalletConnectPendingSessionAndSettings(state: any) { + const newState = { ...state } + newState.walletConnect = { ...newState.walletConnect, pendingSession: null } + newState.wallet = { ...newState.wallet, settings: {} } + + delete newState?.wallet?.bluetooth + return newState +} + +export function removeNonZeroDerivationIndexAccounts(state: any) { + const newState = { ...state } + const accounts = newState?.wallet?.accounts ?? {} + const originalAccountValues = Object.keys(accounts) + for (const account of originalAccountValues) { + if (accounts[account].type === 'native' && accounts[account].derivationIndex !== 0) { + delete accounts[account] + } else if (accounts[account].type === 'native' && accounts[account].derivationIndex === 0) { + accounts[account].mnemonicId = accounts[account].address + } + } + return newState +} + +export function addCloudBackup(state: any) { + const newState = { ...state } + newState.cloudBackup = { backupsFound: [] } + return newState +} + +export function removeLocalTypeAccounts(state: any) { + const newState = { ...state } + const accounts = newState?.wallet?.accounts ?? {} + for (const account of Object.keys(accounts)) { + if (newState.wallet?.accounts?.[account]?.type === 'local') { + delete newState.wallet.accounts[account] + } + } + return newState +} + +export function removeDemoAccount(state: any) { + const newState = { ...state } + const accounts = newState?.wallet?.accounts ?? {} + + if (accounts[OLD_DEMO_ACCOUNT_ADDRESS]) { + delete accounts[OLD_DEMO_ACCOUNT_ADDRESS] + } + + return newState +} + +export function addBiometricSettings(state: any) { + const newState = { ...state } + newState.biometricSettings = { + requiredForAppAccess: false, + requiredForTransactions: false, + } + + return newState +} + +export function addPushNotificationsEnabledToAccounts(state: any) { + const accounts: Record | undefined = state?.wallet?.accounts + const newAccounts = Object.values(accounts ?? {}).map((account: Account) => { + const newAccount = { ...account } + newAccount.pushNotificationsEnabled = false + return newAccount + }) + + const newAccountObj = newAccounts.reduce>((accountObj, account) => { + accountObj[account.address] = account + return accountObj + }, {}) + + const newState = { ...state } + newState.wallet = { ...state.wallet, accounts: newAccountObj } + return newState +} + +export function addEnsState(state: any) { + const newState = { ...state } + newState.ens = { ensForAddress: {} } + return newState +} + +export const migrateBiometricSettings = createSafeMigration({ + name: 'migrateBiometricSettings', + migrate: (state: any) => { + const newState = { ...state } + newState.biometricSettings = { + requiredForAppAccess: state.wallet?.isBiometricAuthEnabled ?? false, + requiredForTransactions: state.wallet?.isBiometricAuthEnabled ?? false, + } + delete newState.wallet?.isBiometricAuthEnabled + return newState + }, + onError: (state: any) => ({ + ...state, + biometricSettings: { + requiredForAppAccess: false, + requiredForTransactions: false, + }, + }), +}) + +export function changeNativeTypeToSignerMnemonic(state: any) { + const newState = { ...state } + const accounts = newState?.wallet?.accounts ?? {} + for (const account of Object.keys(accounts)) { + if (newState.wallet.accounts[account].type === 'native') { + newState.wallet.accounts[account].type = AccountType.SignerMnemonic + } + } + return newState +} + +export function removeDataApi(state: any) { + const newState = { ...state } + delete newState.dataApi + return newState +} + +export function resetPushNotificationsEnabled(state: any) { + const accounts: Record | undefined = state?.wallet?.accounts + if (!accounts) { + return state + } + + for (const account of Object.values(accounts)) { + account.pushNotificationsEnabled = false + } + + const newState = { ...state } + newState.wallet = { ...state.wallet, accounts } + return newState +} + +export function removeEnsState(state: any) { + const newState = { ...state } + delete newState.ens + return newState +} + +export function filterToSupportedChains(state: any) { + const newState = { ...state } + + const chainState: + | { + byChainId: Partial> + } + | undefined = newState?.chains + const newChainState = Object.keys(chainState?.byChainId ?? {}).reduce<{ + byChainId: Partial> + }>( + (tempState, chainIdString) => { + const chainId = toSupportedChainId(chainIdString) + if (!chainId) { + return tempState + } + + const chainInfo = chainState?.byChainId[chainId] + if (!chainInfo) { + return tempState + } + + tempState.byChainId[chainId] = chainInfo + return tempState + }, + { byChainId: {} }, + ) + + const blockState: any | undefined = newState?.blocks + const newBlockState = Object.keys(blockState?.byChainId ?? {}).reduce( + (tempState, chainIdString) => { + const chainId = toSupportedChainId(chainIdString) + if (!chainId) { + return tempState + } + + const blockInfo = blockState?.byChainId[chainId] + if (!blockInfo) { + return tempState + } + + tempState.byChainId[chainId] = blockInfo + return tempState + }, + { byChainId: {} }, + ) + + const transactionState: TransactionsState | undefined = newState?.transactions + const newTransactionState = Object.keys(transactionState ?? {}).reduce((tempState, address) => { + const txs = transactionState?.[address] + if (!txs) { + return tempState + } + + const newAddressTxState = Object.keys(txs).reduce((tempAddressState, chainIdString) => { + const chainId = toSupportedChainId(chainIdString) + if (!chainId) { + return tempAddressState + } + + const txInfo = txs[chainId] + if (!txInfo) { + return tempAddressState + } + + tempAddressState[chainId] = txInfo + return tempAddressState + }, {}) + + tempState[address] = newAddressTxState + return tempState + }, {}) + + return { + ...newState, + chains: newChainState, + blocks: newBlockState, + transactions: newTransactionState, + } +} + +export function resetLastTxNotificationUpdate(state: any) { + const newState = { ...state } + newState.notifications = { ...state?.notifications, lastTxNotificationUpdate: {} } + return newState +} + +export function addExperimentsSlice(state: any) { + const newState = { ...state } + return { + ...newState, + experiments: { experiments: {}, featureFlags: {} }, + } +} + +export function removeCoingeckoApiAndTokenLists(state: any) { + const newState = { ...state } + delete newState.coingeckoApi + delete newState.tokens?.watchedTokens + delete newState.tokens?.tokenPairs + return newState +} + +export function resetTokensOrderByAndMetadataDisplayType(state: any) { + const newState = { ...state } + delete newState.wallet.settings?.tokensOrderBy + delete newState.wallet.settings?.tokensMetadataDisplayType + return newState +} + +export const transformNotificationCountToStatus = createSafeMigration({ + name: 'transformNotificationCountToStatus', + migrate: (state: any) => { + const newState = { ...state } + const notificationCount = state.notifications?.notificationCount + const notificationStatus = Object.keys(notificationCount ?? {}).reduce((obj, address) => { + const count = notificationCount[address] + if (count) { + return { ...obj, [address]: true } + } + + return { ...obj, [address]: false } + }, {}) + + delete newState.notifications?.notificationCount + newState.notifications = { ...newState.notifications, notificationStatus } + return newState + }, + onError: (state: any) => ({ + ...state, + notifications: { ...state?.notifications, notificationStatus: {} }, + }), +}) + +export function addPasswordLockout(state: any) { + return { + ...state, + passwordLockout: { passwordAttempts: 0 }, + } +} + +export function removeShowSmallBalances(state: any) { + const newState = { ...state } + delete newState.wallet.settings.showSmallBalances + return newState +} + +export function resetTokensOrderBy(state: any) { + const newState = { ...state } + delete newState.wallet.settings.tokensOrderBy + return newState +} + +export function removeTokensMetadataDisplayType(state: any) { + const newState = { ...state } + delete newState.wallet.settings.tokensMetadataDisplayType + return newState +} + +export function removeTokenListsAndCustomTokens(state: any) { + const newState = { ...state } + delete newState.tokenLists + delete newState.tokens?.customTokens + return newState +} + +export const migrateFiatPurchaseTransactionInfo = createSafeMigration({ + name: 'migrateFiatPurchaseTransactionInfo', + migrate: (state: any) => { + const newState = { ...state } + + const oldTransactionState = state?.transactions + const newTransactionState: any = {} + + const addresses = Object.keys(oldTransactionState ?? {}) + for (const address of addresses) { + const chainIds = Object.keys(oldTransactionState[address] ?? {}) + for (const chainId of chainIds) { + const transactions = oldTransactionState[address][chainId] + const txIds = Object.keys(transactions ?? {}) + + for (const txId of txIds) { + const txDetails = transactions[txId] + + if (!txDetails) { + continue + } + + if (txDetails.typeInfo?.type !== TransactionType.FiatPurchaseDeprecated) { + newTransactionState[address] ??= {} + newTransactionState[address][chainId] ??= {} + newTransactionState[address][chainId][txId] = txDetails + + continue + } + + if (txDetails.status === TransactionStatus.Failed) { + continue + } + + const { + explorerUrl, + outputTokenAddress, + outputCurrencyAmountFormatted, + outputCurrencyAmountPrice, + syncedWithBackend, + } = txDetails.typeInfo + + const newTypeInfo = { + type: TransactionType.FiatPurchaseDeprecated, + explorerUrl, + inputCurrency: undefined, + inputCurrencyAmount: outputCurrencyAmountFormatted / outputCurrencyAmountPrice, + outputCurrency: { + type: 'crypto', + metadata: { chainId: undefined, contractAddress: outputTokenAddress }, + }, + outputCurrencyAmount: undefined, + syncedWithBackend, + } + + newTransactionState[address] ??= {} + newTransactionState[address][chainId] ??= {} + newTransactionState[address][chainId][txId] = { ...txDetails, typeInfo: newTypeInfo } + } + } + } + + return { ...newState, transactions: newTransactionState } + }, + onError: (state: any) => ({ ...state, transactions: {} }), +}) + +export function emptyMigration(state: any) { + return state +} + +export function resetEnsApi(state: any) { + const newState = { ...state } + + delete newState.ENS + + return newState +} + +export function addReplaceAccountOptions(state: any) { + const newState = { ...state } + + newState.wallet.replaceAccountOptions = { + isReplacingAccount: false, + skipToSeedPhrase: false, + } + return newState +} + +export function addLastBalancesReport(state: any) { + const newState = { ...state } + + newState.telemetry = { + lastBalancesReport: 0, + } + return newState +} + +export function addAppearanceSetting(state: any) { + const newState = { ...state } + + newState.appearanceSettings = { + selectedAppearanceSettings: 'system', + } + return newState +} + +export function addHiddenNfts(state: any) { + const newState = { ...state } + + newState.favorites = { + ...state.favorites, + hiddenNfts: {}, + } + return newState +} + +export const correctFailedFiatOnRampTxIds = createSafeMigration({ + name: 'correctFailedFiatOnRampTxIds', + migrate: (state: any) => { + const newState = { ...state } + + const oldTransactionState = state?.transactions + const newTransactionState: any = {} + + const addresses = Object.keys(oldTransactionState ?? {}) + for (const address of addresses) { + const chainIds = Object.keys(oldTransactionState[address] ?? {}) + for (const chainId of chainIds) { + const transactions = oldTransactionState[address][chainId] + const txIds = Object.keys(transactions ?? {}) + + for (const txId of txIds) { + const txDetails = transactions[txId] + + if (!txDetails) { + continue + } + + newTransactionState[address] ??= {} + newTransactionState[address][chainId] ??= {} + newTransactionState[address][chainId][txId] = + txDetails.typeInfo?.type === TransactionType.FiatPurchaseDeprecated && + txDetails.status === TransactionStatus.Failed + ? { + ...txDetails, + typeInfo: { + ...txDetails.typeInfo, + id: txDetails.typeInfo?.explorerUrl?.split('=')?.[1], + }, + } + : txDetails + } + } + } + return { ...newState, transactions: newTransactionState } + }, + onError: (state: any) => ({ ...state, transactions: {} }), +}) + +export function removeReplaceAccountOptions(state: any) { + const newState = { ...state } + delete newState.wallet.replaceAccountOptions + return newState +} + +export function removeExperimentsSlice(state: any) { + const newState = { ...state } + delete newState.experiments + return newState +} + +export function removePersistedWalletConnectSlice(state: any) { + const newState = { ...state } + delete newState.walletConnect + return newState +} + +export function addLastBalancesReportValue(state: any) { + const newState = { ...state } + + newState.telemetry = { + ...state.telemetry, + lastBalancesReportValue: 0, + } + return newState +} + +export function removeFlashbotsEnabledFromWalletSlice(state: any) { + const newState = { ...state } + + delete newState.wallet.flashbotsEnabled + + return newState +} + +export const convertHiddenNftsToNftsData = createSafeMigration({ + name: 'convertHiddenNftsToNftsData', + migrate: (state: any) => { + const newState = { ...state } + + const accountAddresses = Object.keys(state.favorites?.hiddenNfts ?? {}) + + type AccountToNftData = Record> + + const nftsData: AccountToNftData = {} + for (const accountAddress of accountAddresses) { + // oxlint-disable-next-line typescript/no-unnecessary-condition + nftsData[accountAddress] ??= {} + const hiddenNftKeys = Object.keys(state.favorites.hiddenNfts[accountAddress]) + + for (const hiddenNftKey of hiddenNftKeys) { + const [, nftKey, tokenId] = hiddenNftKey.split('.') + + const newNftKey = nftKey && tokenId && getNFTAssetKey(nftKey, tokenId) + + const accountNftsData = nftsData[accountAddress] + + if (newNftKey) { + accountNftsData[newNftKey] = { isHidden: true } + } + } + } + + newState.favorites = { + ...state.favorites, + nftsData, + } + delete newState.favorites.hiddenNfts + return newState + }, + onError: (state: any) => ({ + ...state, + favorites: { + ...state?.favorites, + nftsData: {}, + }, + }), +}) + +export function removeProviders(state: any) { + const newState = { ...state } + + delete newState.providers + + return newState +} + +export function addTokensVisibility(state: any) { + const newState = { ...state } + + newState.favorites = { + ...state.favorites, + tokensVisibility: {}, + } + return newState +} + +export function deleteRTKQuerySlices(state: any) { + const newState = { ...state } + + delete newState.ENS + delete newState.ens + delete newState.gasApi + delete newState.onChainBalanceApi + delete newState.routingApi + delete newState.trmApi + + return newState +} + +export function resetActiveChains(state: any) { + const newState = { ...state } + + newState.chains.byChainId = { + '1': { isActive: true }, + '10': { isActive: true }, + '56': { isActive: true }, + '137': { isActive: true }, + '8453': { isActive: true }, + '42161': { isActive: true }, + } + + return newState +} + +export function addTweaksStartingState(state: any) { + const newState = { ...state } + + newState.tweaks = {} + + return newState +} + +export function addSwapProtectionSetting(state: any) { + const newState = { ...state } + newState.wallet.settings = { + ...state.wallet.settings, + swapProtection: SwapProtectionSetting.On, + } + return newState +} + +export function deleteChainsSlice(state: any) { + const newState = { ...state } + delete newState.chains + return newState +} + +export function addLanguageSettings(state: any) { + return { + ...state, + languageSettings: { currentLanguage: Language.English }, + } +} + +export function addFiatCurrencySettings(state: any) { + return { + ...state, + fiatCurrencySettings: { currentCurrency: FiatCurrency.UnitedStatesDollar }, + } +} + +export function updateLanguageSettings(state: any) { + return { + ...state, + languageSettings: { currentLanguage: Language.English }, + } +} + +export function addWalletIsFunded(state: any) { + const newState = { ...state } + + newState.telemetry = { + ...state.telemetry, + walletIsFunded: false, + } + + return newState +} + +export function addBehaviorHistory(state: any) { + const newState = { ...state } + + newState.behaviorHistory = { + hasViewedReviewScreen: false, + hasSubmittedHoldToSwap: false, + } + + return newState +} + +export function addAllowAnalyticsSwitch(state: any) { + const newState = { ...state } + + newState.telemetry = { + ...state.telemetry, + allowAnalytics: true, + lastHeartbeat: 0, + } + + return newState +} + +export const moveSettingStateToGlobal = createSafeMigration({ + name: 'moveSettingStateToGlobal', + migrate: (state: any) => { + const newState = { ...state } + + const accounts = newState?.wallet?.accounts ?? {} + const firstAccountKey = Object.keys(accounts)[0] + + const hideSmallBalances = firstAccountKey ? !accounts[firstAccountKey].showSmallBalances : true + const hideSpamTokens = firstAccountKey ? !accounts[firstAccountKey].showSpamTokens : true + + newState.wallet = { + ...newState.wallet, + settings: { + ...newState.wallet?.settings, + hideSmallBalances, + hideSpamTokens, + }, + } + + const accountKeys = Object.keys(accounts ?? {}) + for (const accountKey of accountKeys) { + delete accounts[accountKey].showSmallBalances + delete accounts[accountKey].showSpamTokens + } + + return newState + }, + onError: (state: any) => ({ + ...state, + wallet: { + ...state?.wallet, + settings: { + ...state?.wallet?.settings, + hideSmallBalances: true, + hideSpamTokens: true, + }, + }, + }), +}) + +export function addSkippedUnitagBoolean(state: any) { + const newState = { ...state } + + newState.behaviorHistory = { + ...state.behaviorHistory, + hasSkippedUnitagPrompt: false, + } + + return newState +} + +export function addCompletedUnitagsIntroBoolean(state: any) { + const newState = { ...state } + + newState.behaviorHistory = { + ...state.behaviorHistory, + hasCompletedUnitagsIntroModal: false, + } + + return newState +} + +export function addUniconV2IntroModalBoolean(state: any) { + const newState = { ...state } + + newState.behaviorHistory = { + ...state.behaviorHistory, + hasViewedUniconV2IntroModal: false, + } + + return newState +} + +export const flattenTokenVisibility = createSafeMigration({ + name: 'flattenTokenVisibility', + migrate: (state: any) => { + const newState = { ...state } + + type AccountToNftData = Record> + type NFTKeyToVisibility = Record + + type AccountToTokenVisibility = Record> + type CurrencyIdToVisibility = Record + + const tokenVisibilityByAccount: AccountToTokenVisibility = state.favorites?.tokensVisibility ?? {} + const flattenedTokenVisibility: CurrencyIdToVisibility = Object.values(tokenVisibilityByAccount).reduce( + (acc, currencyIdToVisibility) => ({ ...acc, ...currencyIdToVisibility }), + {}, + ) + + const nftDataByAccount: AccountToNftData = state.favorites?.nftsData ?? {} + const flattenedNFTData = Object.values(nftDataByAccount).reduce( + (acc, nftIdToVisibility) => ({ ...acc, ...nftIdToVisibility }), + {}, + ) + + const flattenedTransformedNFTData: NFTKeyToVisibility = Object.keys(flattenedNFTData).reduce( + (acc, nftKey) => { + const { isHidden, isSpamIgnored } = flattenedNFTData[nftKey] ?? {} + return { + ...acc, + [nftKey]: { isVisible: isHidden === false || isSpamIgnored === true }, + } + }, + {}, + ) + + newState.favorites = { + ...state.favorites, + tokensVisibility: flattenedTokenVisibility, + nftsVisibility: flattenedTransformedNFTData, + } + + delete newState.favorites.nftsData + + return newState + }, + onError: (state: any) => ({ + ...state, + favorites: { + ...state?.favorites, + tokensVisibility: {}, + nftsVisibility: {}, + }, + }), +}) + +export function addExtensionOnboardingState(state: any) { + const newState = { ...state } + + newState.behaviorHistory = { + ...state.behaviorHistory, + extensionOnboardingState: 'Undefined', + } + + return newState +} + +export function resetOnboardingStateForGA(state: any) { + const newState = { ...state } + + newState.behaviorHistory = { + ...state.behaviorHistory, + extensionOnboardingState: 'Undefined', + } + + return newState +} + +export const deleteOldOnRampTxData = createSafeMigration({ + name: 'deleteOldOnRampTxData', + migrate: (state: any) => { + const newState = { ...state } + + const transactionsState = newState.transactions + + const addresses = Object.keys(transactionsState ?? {}) + for (const address of addresses) { + const chainIds = Object.keys(transactionsState[address] ?? {}) + for (const chainId of chainIds) { + const transactions = transactionsState[address][chainId] + const txIds = Object.keys(transactions ?? {}) + for (const txId of txIds) { + if (transactions[txId]?.typeInfo?.type === TransactionType.FiatPurchaseDeprecated) { + delete transactionsState[address][chainId][txId] + } + } + } + } + + return { ...newState, transactions: transactionsState } + }, + onError: (state: any) => ({ ...state, transactions: {} }), +}) + +export const addPushNotifications = createSafeMigration({ + name: 'addPushNotifications', + migrate: (state: any) => { + const hasAllWalletNotifsDisabled = Object.values(state.wallet?.accounts ?? {}).every( + (account) => + account && + typeof account === 'object' && + 'pushNotificationsEnabled' in account && + !account.pushNotificationsEnabled, + ) + + return { + ...state, + pushNotifications: { + generalUpdatesEnabled: !hasAllWalletNotifsDisabled, + priceAlertsEnabled: !hasAllWalletNotifsDisabled, + }, + } + }, + onError: (state: any) => ({ + ...state, + pushNotifications: { + generalUpdatesEnabled: true, + priceAlertsEnabled: true, + }, + }), +}) + +export const migrateDappRequestInfoTypes = createSafeMigration({ + name: 'migrateDappRequestInfoTypes', + +export const setWalletDeviceLanguage = createSafeMigration({ + name: 'setWalletDeviceLanguage', + migrate: (state: any) => { + if (!state?.userSettings) { + return state + } + + return { + ...state, + userSettings: { + ...state.userSettings, + currentLanguage: getWalletDeviceLanguage(), + }, + } + }, + onError: (state: any) => ({ + ...state, + userSettings: { + ...(state?.userSettings ?? {}), + currentLanguage: Language.English, + }, + }), +}) diff --git a/apps/mobile/src/app/mobileReducer.ts b/apps/mobile/src/app/mobileReducer.ts new file mode 100644 index 00000000..abe49f50 --- /dev/null +++ b/apps/mobile/src/app/mobileReducer.ts @@ -0,0 +1,40 @@ +import { combineReducers } from '@reduxjs/toolkit' +import { monitoredSagaReducers } from 'src/app/monitoredSagas' +import { appStateReducer } from 'src/features/appState/appStateSlice' +import { biometricsReducer } from 'src/features/biometrics/biometricsSlice' +import { biometricSettingsReducer } from 'src/features/biometricsSettings/slice' +import { passwordLockoutReducer } from 'src/features/CloudBackup/passwordLockoutSlice' +import { lockScreenReducer } from 'src/features/lockScreen/lockScreenSlice' +import { modalsReducer } from 'src/features/modals/modalSlice' +import { pushNotificationsReducer } from 'src/features/notifications/slice' +import { splashScreenReducer } from 'src/features/splashScreen/splashScreenSlice' +import { tweaksReducer } from 'src/features/tweaks/slice' +import { walletConnectReducer } from 'src/features/walletConnect/walletConnectSlice' +import { walletPersistedStateList, walletReducers } from 'wallet/src/state/walletReducer' + +const mobileReducers = { + ...walletReducers, + biometrics: biometricsReducer, + biometricSettings: biometricSettingsReducer, + modals: modalsReducer, + passwordLockout: passwordLockoutReducer, + pushNotifications: pushNotificationsReducer, + saga: monitoredSagaReducers, + tweaks: tweaksReducer, + walletConnect: walletConnectReducer, + appState: appStateReducer, + splashScreen: splashScreenReducer, + lockScreen: lockScreenReducer, +} as const + +export const mobileReducer = combineReducers(mobileReducers) + +export const mobilePersistedStateList: Array = [ + ...walletPersistedStateList, + 'biometricSettings', + 'passwordLockout', + 'tweaks', + 'pushNotifications', +] + +export type MobileState = ReturnType diff --git a/apps/mobile/src/app/modals/AccountSwitcherModal.test.tsx b/apps/mobile/src/app/modals/AccountSwitcherModal.test.tsx new file mode 100644 index 00000000..ac1b2df9 --- /dev/null +++ b/apps/mobile/src/app/modals/AccountSwitcherModal.test.tsx @@ -0,0 +1,20 @@ +import React from 'react' +import { AccountSwitcher } from 'src/app/modals/AccountSwitcherModal' +import { preloadedMobileState } from 'src/test/fixtures' +import { cleanup, render } from 'src/test/test-utils' +import { noOpFunction } from 'utilities/src/test/utils' +import { ACCOUNT } from 'wallet/src/test/fixtures' + +const preloadedState = preloadedMobileState({ + account: ACCOUNT, +}) + +// TODO [MOB-259]: Figure out how to do snapshot tests when there is a Modal +describe(AccountSwitcher, () => { + it('renders correctly', async () => { + const tree = render(, { preloadedState }) + + expect(tree.toJSON()).toMatchSnapshot() + cleanup() + }) +}) diff --git a/apps/mobile/src/app/modals/AccountSwitcherModal.tsx b/apps/mobile/src/app/modals/AccountSwitcherModal.tsx new file mode 100644 index 00000000..aa6cb7b8 --- /dev/null +++ b/apps/mobile/src/app/modals/AccountSwitcherModal.tsx @@ -0,0 +1,335 @@ +import { useIsFocused } from '@react-navigation/core' +import { FeatureFlags, useFeatureFlag } from '@universe/gating' +import React, { useCallback, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useDispatch, useSelector } from 'react-redux' +import { navigate } from 'src/app/navigation/rootNavigation' +import { AccountList } from 'src/components/accounts/AccountList' +import { checkCloudBackupOrShowAlert } from 'src/components/mnemonic/cloudImportUtils' +import { useReactNavigationModal } from 'src/components/modals/useReactNavigationModal' +import { WalletRestoreType } from 'src/components/RestoreWalletModal/RestoreWalletModalState' +import { useWalletRestore } from 'src/features/wallet/useWalletRestore' +import { Button, Flex, Text, TouchableArea, useSporeColors } from 'ui/src' +import { useDeviceDimensions } from 'ui/src/hooks/useDeviceDimensions' +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 { ActionSheetModal, MenuItemProp } from 'uniswap/src/components/modals/ActionSheetModal' +import { Modal } from 'uniswap/src/components/modals/Modal' +import { UNISWAP_WEB_URL } from 'uniswap/src/constants/urls' +import { AccountType } from 'uniswap/src/features/accounts/types' +import { setHasDismissedUniswapWrapped2025Banner } from 'uniswap/src/features/behaviorHistory/slice' +import { Platform } from 'uniswap/src/features/platforms/types/Platform' +import { ElementName, ModalName, WalletEventName } from 'uniswap/src/features/telemetry/constants' +import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { useAppInsets } from 'uniswap/src/hooks/useAppInsets' +import { TestID } from 'uniswap/src/test/fixtures/testIDs' +import { ImportType, OnboardingEntryPoint } from 'uniswap/src/types/onboarding' +import { MobileScreens, OnboardingScreens } from 'uniswap/src/types/screens/mobile' +import { areAddressesEqual } from 'uniswap/src/utils/addresses' +import { openUri } from 'uniswap/src/utils/linking' +import { logger } from 'utilities/src/logger/logger' +import { isAndroid } from 'utilities/src/platform' +import { PlusCircle } from 'wallet/src/components/icons/PlusCircle' +import { createOnboardingAccount } from 'wallet/src/features/onboarding/createOnboardingAccount' +import { BackupType } 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 { useActiveAccountAddress, useNativeAccountExists } from 'wallet/src/features/wallet/hooks' +import { selectAllAccountsSorted, selectSortedSignerMnemonicAccounts } from 'wallet/src/features/wallet/selectors' +import { setAccountAsActive } from 'wallet/src/features/wallet/slice' + +export function AccountSwitcherModal(): JSX.Element { + const colors = useSporeColors() + const { onClose } = useReactNavigationModal() + + return ( + + + + + + ) +} + +/** + * Exported for testing only. + * TODO [MOB-259] Once testing works with the Modal stop exporting this component. + */ +export function AccountSwitcher({ onClose }: { onClose: () => void }): JSX.Element | null { + const insets = useAppInsets() + const dimensions = useDeviceDimensions() + const { t } = useTranslation() + const activeAccountAddress = useActiveAccountAddress() + const dispatch = useDispatch() + const hasImportedSeedPhrase = useNativeAccountExists() + const isModalOpen = useIsFocused() + const { openWalletRestoreModal, walletRestoreType } = useWalletRestore() + + const isWrappedBannerEnabled = useFeatureFlag(FeatureFlags.UniswapWrapped2025) + + const sortedMnemonicAccounts = useSelector(selectSortedSignerMnemonicAccounts) + + const [showAddWalletModal, setShowAddWalletModal] = useState(false) + + const accounts = useSelector(selectAllAccountsSorted) + + const onPressAccount = useCallback( + (address: Address) => { + onClose() + // allow close modal logic to finish in JS thread before `setAccountAsActive` logic kicks in + setImmediate(() => { + dispatch(setAccountAsActive(address)) + }) + }, + [dispatch, onClose], + ) + + const onPressAddWallet = (): void => { + setShowAddWalletModal(true) + } + + const onCloseAddWallet = (): void => { + setShowAddWalletModal(false) + } + + const onManageWallet = (): void => { + if (!activeAccountAddress) { + return + } + + onClose() + navigate(ModalName.ManageWalletsModal, { + address: activeAccountAddress, + }) + } + + const onPressWrappedCard = useCallback(async () => { + if (!activeAccountAddress) { + return + } + + try { + const url = buildWrappedUrl(UNISWAP_WEB_URL, activeAccountAddress) + await openUri({ uri: url, openExternalBrowser: true }) + onClose() + dispatch(setHasDismissedUniswapWrapped2025Banner(true)) + } catch (error) { + logger.error(error, { tags: { file: 'AccountSwitcherModal', function: 'onPressWrappedCard' } }) + } + }, [activeAccountAddress, onClose, dispatch]) + + const addWalletOptions = useMemo(() => { + const createAdditionalAccount = async (): Promise => { + // Generate new account + const newAccount = await createOnboardingAccount(sortedMnemonicAccounts) + + // Create new account in redux + dispatch( + createAccountsActions.trigger({ + accounts: [newAccount], + }), + ) + + sendAnalyticsEvent(WalletEventName.WalletAdded, { + wallet_type: ImportType.CreateAdditional, + accounts_imported_count: 1, + wallets_imported: [newAccount.address], + cloud_backup_used: hasBackup(BackupType.Cloud, newAccount), + modal: ModalName.AccountSwitcher, + }) + } + + const onPressCreateNewWallet = async (): Promise => { + setShowAddWalletModal(false) + onClose() + + if (walletRestoreType === WalletRestoreType.SeedPhrase) { + openWalletRestoreModal() + return + } + + if (hasImportedSeedPhrase) { + await createAdditionalAccount() + } else { + // create pending account and place into welcome flow + navigate(MobileScreens.OnboardingStack, { + screen: OnboardingScreens.WelcomeWallet, + params: { + importType: ImportType.CreateNew, + entryPoint: OnboardingEntryPoint.Sidebar, + }, + }) + } + } + + const onPressAddViewOnlyWallet = (): void => { + onClose() + navigate(MobileScreens.OnboardingStack, { + screen: OnboardingScreens.WatchWallet, + params: { + importType: ImportType.Watch, + entryPoint: OnboardingEntryPoint.Sidebar, + }, + }) + setShowAddWalletModal(false) + } + + const onPressImportWallet = (): void => { + onClose() + if (hasImportedSeedPhrase && activeAccountAddress) { + navigate(ModalName.RemoveWallet, { + replaceMnemonic: true, + }) + } else { + navigate(MobileScreens.OnboardingStack, { + screen: OnboardingScreens.SeedPhraseInput, + params: { importType: ImportType.SeedPhrase, entryPoint: OnboardingEntryPoint.Sidebar }, + }) + } + setShowAddWalletModal(false) + } + + const onPressRestore = async (): Promise => { + const hasCloudBackup = await checkCloudBackupOrShowAlert(t) + if (!hasCloudBackup) { + return + } + + onClose() + navigate(MobileScreens.OnboardingStack, { + screen: OnboardingScreens.RestoreCloudBackupLoading, + params: { importType: ImportType.Restore, entryPoint: OnboardingEntryPoint.Sidebar }, + }) + setShowAddWalletModal(false) + } + + const options: MenuItemProp[] = [ + { + key: ElementName.CreateAccount, + onPress: onPressCreateNewWallet, + render: () => ( + + {t('account.wallet.button.create')} + + ), + }, + { + key: ElementName.AddViewOnlyWallet, + onPress: onPressAddViewOnlyWallet, + render: () => ( + + {t('account.wallet.button.addViewOnly')} + + ), + }, + { + key: ElementName.ImportAccount, + onPress: onPressImportWallet, + render: () => ( + + {t('account.wallet.button.import')} + + ), + }, + ] + + if (!hasImportedSeedPhrase) { + options.push({ + key: ElementName.RestoreFromCloud, + onPress: onPressRestore, + render: () => ( + + + {isAndroid ? t('account.cloud.button.restore.android') : t('account.cloud.button.restore.ios')} + + + ), + }) + } + + return options + }, [ + activeAccountAddress, + dispatch, + hasImportedSeedPhrase, + onClose, + sortedMnemonicAccounts, + t, + openWalletRestoreModal, + walletRestoreType, + ]) + + const accountsWithoutActive = accounts.filter((a) => a.address !== activeAccountAddress) + + const isViewOnly = + accounts.find((a) => + // TODO(WALL-7065): Update to support solana + areAddressesEqual({ + addressInput1: { address: a.address, platform: Platform.EVM }, + addressInput2: { address: activeAccountAddress, platform: Platform.EVM }, + }), + )?.type === AccountType.Readonly + + if (!activeAccountAddress) { + return null + } + + const fullScreenContentHeight = dimensions.fullHeight - insets.top - insets.bottom - spacing.spacing36 // approximate bottom sheet handle height + padding bottom + + return ( + + + + {isWrappedBannerEnabled && ( + + + + )} + + + + + + + + + + + + {t('account.wallet.button.add')} + + + + + + ) +} diff --git a/apps/mobile/src/app/modals/AppModals.tsx b/apps/mobile/src/app/modals/AppModals.tsx new file mode 100644 index 00000000..c1355a3d --- /dev/null +++ b/apps/mobile/src/app/modals/AppModals.tsx @@ -0,0 +1,41 @@ +import React from 'react' +import { LazyModalRenderer } from 'src/app/modals/LazyModalRenderer' +import { SendTokenModal } from 'src/app/modals/SendTokenModal' +import { ForceUpgradeModal } from 'src/components/forceUpgrade/ForceUpgradeModal' +import { WalletConnectModals } from 'src/components/Requests/WalletConnectModals' +import { FiatOnRampAggregatorModal } from 'src/features/fiatOnRamp/FiatOnRampAggregatorModal' +import { LockScreenModal } from 'src/features/lockScreen/LockScreenModal' +import { ModalName } from 'uniswap/src/features/telemetry/constants' +import { QueuedOrderModal } from 'wallet/src/features/transactions/swap/modals/QueuedOrderModal' + +/** + * *********** DEPRECATION NOTICE *********** + * + * This modal system is deprecated in favor of React Navigation. + * Please do not add any new modals to this redux slice. + * See apps/mobile/src/app/navigation/navigation.tsx + * + * *********** DEPRECATION NOTICE *********** + */ + +export function AppModals(): JSX.Element { + return ( + <> + + + + + + + + + + + + + + + + + ) +} diff --git a/apps/mobile/src/app/modals/BackupReminderModal.tsx b/apps/mobile/src/app/modals/BackupReminderModal.tsx new file mode 100644 index 00000000..f982f154 --- /dev/null +++ b/apps/mobile/src/app/modals/BackupReminderModal.tsx @@ -0,0 +1,116 @@ +import { useRef } from 'react' +import { useTranslation } from 'react-i18next' +import { useDispatch } from 'react-redux' +import { navigate } from 'src/app/navigation/rootNavigation' +import { useReactNavigationModal } from 'src/components/modals/useReactNavigationModal' +import { LockPreviewImage } from 'src/features/onboarding/LockPreviewImage' +import { Button, Flex, Text } from 'ui/src' +import { Modal } from 'uniswap/src/components/modals/Modal' +import { WarningSeverity } from 'uniswap/src/components/modals/WarningModal/types' +import WarningIcon from 'uniswap/src/components/warnings/WarningIcon' +import { usePortfolioTotalValue } from 'uniswap/src/features/dataApi/balances/balancesRest' +import { useLocalizationContext } from 'uniswap/src/features/language/LocalizationContext' +import { ElementName, ModalName } from 'uniswap/src/features/telemetry/constants' +import Trace from 'uniswap/src/features/telemetry/Trace' +import { ImportType, OnboardingEntryPoint } from 'uniswap/src/types/onboarding' +import { MobileScreens, OnboardingScreens } from 'uniswap/src/types/screens/mobile' +import { NumberType } from 'utilities/src/format/types' +import { setBackupReminderLastSeenTs } from 'wallet/src/features/behaviorHistory/slice' +import { useActiveAccountAddress } from 'wallet/src/features/wallet/hooks' + +interface BackupReminderModalProps { + /** Optional close handler provided by notification service renderer */ + onClose?: () => void +} + +export function BackupReminderModal({ onClose: externalOnClose }: BackupReminderModalProps): JSX.Element { + const { t } = useTranslation() + const dispatch = useDispatch() + const closedByButtonRef = useRef(false) + const { onClose: navigationOnClose } = useReactNavigationModal() + const onClose = externalOnClose ?? navigationOnClose + const { convertFiatAmountFormatted } = useLocalizationContext() + + const activeAddress = useActiveAccountAddress() + const { data: portfolioData } = usePortfolioTotalValue({ + evmAddress: activeAddress ?? undefined, + }) + + const checkForSwipeToDismiss = (): void => { + onClose() + if (!closedByButtonRef.current) { + // Modal was swiped to dismiss, should open the BackupReminderWarning modal + navigate(ModalName.BackupReminderWarning) + } + + // Reset the ref and close the modal + closedByButtonRef.current = false + } + + const onPressMaybeLater = (): void => { + closedByButtonRef.current = true + dispatch(setBackupReminderLastSeenTs(Date.now())) + onClose() + navigate(ModalName.BackupReminderWarning) + } + + const onPressBackup = (): void => { + closedByButtonRef.current = true + dispatch(setBackupReminderLastSeenTs(Date.now())) + onClose() + navigate(MobileScreens.OnboardingStack, { + screen: OnboardingScreens.Backup, + params: { importType: ImportType.BackupOnly, entryPoint: OnboardingEntryPoint.BackupCard }, + }) + } + + const unprotectedFunds = portfolioData?.balanceUSD ?? 0 + const formattedUnprotectedFunds = convertFiatAmountFormatted(unprotectedFunds, NumberType.FiatTokenQuantity) + + return ( + + + + + {t('onboarding.backup.reminder.title')} + + {t('onboarding.backup.reminder.warning.description')} + + + + {t('onboarding.backup.reminder.warning.fundsLabel')} + + + + + {formattedUnprotectedFunds} + + + + + + + + + + + + + + + + + ) +} diff --git a/apps/mobile/src/app/modals/BackupWarningModal.tsx b/apps/mobile/src/app/modals/BackupWarningModal.tsx new file mode 100644 index 00000000..6fec2e50 --- /dev/null +++ b/apps/mobile/src/app/modals/BackupWarningModal.tsx @@ -0,0 +1,62 @@ +import { useRef } from 'react' +import { useTranslation } from 'react-i18next' +import { useDispatch } from 'react-redux' +import { navigate } from 'src/app/navigation/rootNavigation' +import { useReactNavigationModal } from 'src/components/modals/useReactNavigationModal' +import { WarningSeverity } from 'uniswap/src/components/modals/WarningModal/types' +import { WarningModal } from 'uniswap/src/components/modals/WarningModal/WarningModal' +import { ElementName, ModalName, WalletEventName } from 'uniswap/src/features/telemetry/constants' +import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { setBackupReminderLastSeenTs } from 'wallet/src/features/behaviorHistory/slice' + +export function BackupWarningModal(): JSX.Element { + const { t } = useTranslation() + const dispatch = useDispatch() + const closedByButtonRef = useRef(false) + const { onClose } = useReactNavigationModal() + + const checkForSwipeToDismiss = (): void => { + const markReminderAsSeen = !closedByButtonRef.current + if (markReminderAsSeen) { + // Modal was swiped to dismiss, should set backup reminder timestamp + dispatch(setBackupReminderLastSeenTs(Date.now())) + } + + sendAnalyticsEvent(WalletEventName.ModalClosed, { + element: ElementName.BackButton, + modal: ModalName.BackupReminderWarning, + markReminderAsSeen, + }) + + // Reset the ref and close the modal + closedByButtonRef.current = false + onClose() + } + + const openBackupReminderModal = (): void => { + closedByButtonRef.current = true + onClose() + navigate(ModalName.BackupReminder) + } + + const onConfirm = (): void => { + closedByButtonRef.current = true + dispatch(setBackupReminderLastSeenTs(Date.now())) + onClose() + } + + return ( + + ) +} diff --git a/apps/mobile/src/app/modals/BridgedAssetWarningWrapper.tsx b/apps/mobile/src/app/modals/BridgedAssetWarningWrapper.tsx new file mode 100644 index 00000000..89cf251e --- /dev/null +++ b/apps/mobile/src/app/modals/BridgedAssetWarningWrapper.tsx @@ -0,0 +1,55 @@ +import { AppStackScreenProp } from 'src/app/navigation/types' +import { useReactNavigationModal } from 'src/components/modals/useReactNavigationModal' +import { BridgedAssetModal } from 'uniswap/src/components/BridgedAsset/BridgedAssetModal' +import { useEnabledChains } from 'uniswap/src/features/chains/hooks/useEnabledChains' +import { ModalName } from 'uniswap/src/features/telemetry/constants' +import { useCurrencyInfo } from 'uniswap/src/features/tokens/useCurrencyInfo' +import { useDismissedBridgedAssetWarnings } from 'uniswap/src/features/tokens/warnings/slice/hooks' +import { currencyIdToAddress, currencyIdToChain, isNativeCurrencyAddress } from 'uniswap/src/utils/currencyId' + +export function BridgedAssetWarningWrapper({ + route, +}: AppStackScreenProp): JSX.Element | null { + const { defaultChainId } = useEnabledChains() + const { onClose } = useReactNavigationModal() + + const { currencyId, onAcknowledge } = route.params.initialState ?? {} + const currencyChainId = (currencyId && currencyIdToChain(currencyId)) || defaultChainId + const currencyAddress = currencyId ? currencyIdToAddress(currencyId) : undefined + const currencyInfo = useCurrencyInfo(currencyId) + + // Get the token info only if we have a valid non-native currency + const isNativeCurrency = isNativeCurrencyAddress(currencyChainId, currencyAddress) + const { tokenWarningDismissed: bridgedAssetWarningDismissed } = useDismissedBridgedAssetWarnings( + isNativeCurrency || !currencyAddress ? undefined : { chainId: currencyChainId, address: currencyAddress }, + ) + + // Return null if modal state is malformed + if (!route.params.initialState) { + return null + } + + // If no currency info found, skip warning and proceed to SwapFlow + if (!currencyInfo) { + onAcknowledge?.() + return null + } + + const isBridgedAsset = Boolean(currencyInfo.isBridged) + + // If token is not bridged or warning was dismissed and not blocked, skip warning and proceed to SwapFlow + if (!isBridgedAsset || bridgedAssetWarningDismissed) { + onAcknowledge?.() + return null + } + + return ( + + ) +} diff --git a/apps/mobile/src/app/modals/ExperimentsModal.tsx b/apps/mobile/src/app/modals/ExperimentsModal.tsx new file mode 100644 index 00000000..cd7198e9 --- /dev/null +++ b/apps/mobile/src/app/modals/ExperimentsModal.tsx @@ -0,0 +1,46 @@ +import React from 'react' +import { ScrollView } from 'react-native-gesture-handler' +import { SeedPhraseAndPrivateKeysDevSection } from 'src/components/experiments/SeedPhraseAndPrivateKeysDevSection' +import { ServerOverrides } from 'src/components/experiments/ServerOverrides' +import { useReactNavigationModal } from 'src/components/modals/useReactNavigationModal' +import { E2EPixel } from 'src/test/E2EPixel' +import { getFullAppVersion } from 'src/utils/version' +import { Accordion, Text } from 'ui/src' +import { spacing } from 'ui/src/theme' +import { CacheConfig } from 'uniswap/src/components/gating/CacheConfig' +import { GatingOverrides } from 'uniswap/src/components/gating/GatingOverrides' +import { Modal } from 'uniswap/src/components/modals/Modal' +import { ModalName } from 'uniswap/src/features/telemetry/constants' +import { useAppInsets } from 'uniswap/src/hooks/useAppInsets' +import { TestID } from 'uniswap/src/test/fixtures/testIDs' + +export function ExperimentsModal(): JSX.Element { + const insets = useAppInsets() + const { onClose } = useReactNavigationModal() + + return ( + + + + {`Version: ${getFullAppVersion({ includeBuildNumber: true })}`} + + Gating + + + + Miscellaneous + + + + + + + + ) +} diff --git a/apps/mobile/src/app/modals/ExploreModal.tsx b/apps/mobile/src/app/modals/ExploreModal.tsx new file mode 100644 index 00000000..311fc3b5 --- /dev/null +++ b/apps/mobile/src/app/modals/ExploreModal.tsx @@ -0,0 +1,34 @@ +import { ExploreStackNavigator } from 'src/app/navigation/ExploreStackNavigator' +import { AppStackScreenProp } from 'src/app/navigation/types' +import { useReactNavigationModal } from 'src/components/modals/useReactNavigationModal' +import { useSporeColors } from 'ui/src' +import { Modal } from 'uniswap/src/components/modals/Modal' +import { ModalName } from 'uniswap/src/features/telemetry/constants' +import { MobileScreens } from 'uniswap/src/types/screens/mobile' + +/** + * Component for the main BSM that contains the ExploreStackNavigator. + * + * This screen shows search, favorite tokens, wallets, and filterable top tokens. + */ +export function ExploreModal({ route }: AppStackScreenProp): JSX.Element { + const { onClose } = useReactNavigationModal() + const colors = useSporeColors() + + // Extract the params that should be passed to the initial screen + const initialParams = route.params?.screen === MobileScreens.Explore ? route.params.params : undefined + + return ( + + + + ) +} diff --git a/apps/mobile/src/app/modals/KoreaCexTransferInfoModal.tsx b/apps/mobile/src/app/modals/KoreaCexTransferInfoModal.tsx new file mode 100644 index 00000000..985dd210 --- /dev/null +++ b/apps/mobile/src/app/modals/KoreaCexTransferInfoModal.tsx @@ -0,0 +1,59 @@ +import React, { useCallback } from 'react' +import { useTranslation } from 'react-i18next' +import { useReactNavigationModal } from 'src/components/modals/useReactNavigationModal' +import { useOpenReceiveModal } from 'src/features/modals/hooks/useOpenReceiveModal' +import { Button, Flex, Image, Text, useIsDarkMode, useSporeColors } from 'ui/src' +import { CEX_TRANSFER_MODAL_BG_DARK, CEX_TRANSFER_MODAL_BG_LIGHT } from 'ui/src/assets' +import { Modal } from 'uniswap/src/components/modals/Modal' +import { uniswapUrls } from 'uniswap/src/constants/urls' +import { ModalName } from 'uniswap/src/features/telemetry/constants' +import { openUri } from 'uniswap/src/utils/linking' + +const BG_IMAGE_MAX_HEIGHT = 80 + +export function KoreaCexTransferInfoModal(): JSX.Element { + const color = useSporeColors() + const { t } = useTranslation() + const isDarkMode = useIsDarkMode() + const { onClose } = useReactNavigationModal() + const openReceiveModal = useOpenReceiveModal() + + const onPressReceive = useCallback(() => { + onClose() + openReceiveModal() + }, [onClose, openReceiveModal]) + + return ( + + + + + + + {t('fiatOnRamp.cexTransferModal.title')} + + {t('fiatOnRamp.cexTransferModal.description')} + + + + + + + + + ) +} diff --git a/apps/mobile/src/app/modals/LazyModalRenderer.test.tsx b/apps/mobile/src/app/modals/LazyModalRenderer.test.tsx new file mode 100644 index 00000000..952b7c9b --- /dev/null +++ b/apps/mobile/src/app/modals/LazyModalRenderer.test.tsx @@ -0,0 +1,40 @@ +import React from 'react' +import { Text } from 'react-native' +import { LazyModalRenderer } from 'src/app/modals/LazyModalRenderer' +import { preloadedMobileState, preloadedModalsState } from 'src/test/fixtures' +import { renderWithProviders } from 'src/test/render' +import { ModalName } from 'uniswap/src/features/telemetry/constants' + +describe(LazyModalRenderer, () => { + it('renders null when modal is not open', () => { + const tree = renderWithProviders( + + Rendered + , + { preloadedState: preloadedMobileState() }, + ) + + expect(tree.toJSON()).toBeNull() + }) + + it('renders modal when modal is open', () => { + const tree = renderWithProviders( + + Rendered + , + { + preloadedState: preloadedMobileState({ + modals: preloadedModalsState({ + [ModalName.FiatOnRampAggregator]: { isOpen: true }, + }), + }), + }, + ) + + expect(tree.toJSON()).toMatchInlineSnapshot(` + + Rendered + + `) + }) +}) diff --git a/apps/mobile/src/app/modals/LazyModalRenderer.tsx b/apps/mobile/src/app/modals/LazyModalRenderer.tsx new file mode 100644 index 00000000..b7887909 --- /dev/null +++ b/apps/mobile/src/app/modals/LazyModalRenderer.tsx @@ -0,0 +1,39 @@ +import { useDispatch, useSelector } from 'react-redux' +import { closeModal } from 'src/features/modals/modalSlice' +import { ModalsState } from 'src/features/modals/ModalsState' +import { selectModalState } from 'src/features/modals/selectModalState' +import { ErrorBoundary } from 'wallet/src/components/ErrorBoundary/ErrorBoundary' + +/** + * Delays evaluating `children` until modal is open + * @param modalName name of the modal for which to track open state + * @param WrappedComponent react node to render once modal opens + */ +export function LazyModalRenderer({ + name, + children, + disableErrorBoundary = false, +}: { + name: keyof ModalsState + children: JSX.Element + disableErrorBoundary?: boolean +}): JSX.Element | null { + const dispatch = useDispatch() + + const modalState = useSelector(selectModalState(name)) + + if (!modalState.isOpen) { + // avoid doing any work until the modal needs to be open + return null + } + + if (disableErrorBoundary) { + return children + } + + return ( + dispatch(closeModal({ name }))}> + {children} + + ) +} diff --git a/apps/mobile/src/app/modals/NotificationsOSSettingsModal.tsx b/apps/mobile/src/app/modals/NotificationsOSSettingsModal.tsx new file mode 100644 index 00000000..3eb6b538 --- /dev/null +++ b/apps/mobile/src/app/modals/NotificationsOSSettingsModal.tsx @@ -0,0 +1,91 @@ +import { useFocusEffect } from '@react-navigation/core' +import React, { useCallback, useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { SettingsStackNavigationProp } from 'src/app/navigation/types' +import { NotificationsBackgroundImage } from 'src/components/notifications/NotificationsBGImage' +import { + NotificationPermission, + useNotificationOSPermissionsEnabled, +} from 'src/features/notifications/hooks/useNotificationOSPermissionsEnabled' +import { usePromptPushPermission } from 'src/features/notifications/hooks/usePromptPushPermission' +import { openNotificationSettings } from 'src/utils/linking' +import { Button, Flex } from 'ui/src' +import { BellOn } from 'ui/src/components/icons/BellOn' +import { GenericHeader } from 'uniswap/src/components/misc/GenericHeader' +import { Modal } from 'uniswap/src/components/modals/Modal' +import { ElementName, ModalName } from 'uniswap/src/features/telemetry/constants' +import Trace from 'uniswap/src/features/telemetry/Trace' +import { MobileScreens } from 'uniswap/src/types/screens/mobile' + +type NotificationsOSSettingsModalProps = { + navigation: SettingsStackNavigationProp +} + +/** + * This modal is used to inform the user that they need to enable notifications in the + * OS settings for the app + */ +export function NotificationsOSSettingsModal({ navigation }: NotificationsOSSettingsModalProps): JSX.Element { + const { notificationPermissionsEnabled, checkNotificationPermissions } = useNotificationOSPermissionsEnabled() + const promptPushPermission = usePromptPushPermission() + const { t } = useTranslation() + + const shouldNavigateToSettings = useMemo(() => { + return notificationPermissionsEnabled === NotificationPermission.Enabled + }, [notificationPermissionsEnabled]) + + const navigateToSettings = useCallback(() => { + navigation.navigate(MobileScreens.SettingsStack, { + screen: MobileScreens.SettingsNotifications, + }) + }, [navigation]) + + useFocusEffect( + useCallback(() => { + if (shouldNavigateToSettings) { + navigation.goBack() + } + }, [shouldNavigateToSettings, navigation]), + ) + + const onPressEnableNotifications = useCallback(async () => { + const arePushNotificationsEnabled = await promptPushPermission() + if (!arePushNotificationsEnabled) { + await openNotificationSettings() + } else { + await checkNotificationPermissions() + } + }, [checkNotificationPermissions, promptPushPermission]) + + const onClose = useCallback(() => { + if (shouldNavigateToSettings) { + navigateToSettings() + } else { + navigation.goBack() + } + }, [navigation, shouldNavigateToSettings, navigateToSettings]) + + return ( + + + + + + + + + + + + + + + ) +} diff --git a/apps/mobile/src/app/modals/SendTokenModal.tsx b/apps/mobile/src/app/modals/SendTokenModal.tsx new file mode 100644 index 00000000..c2ea9077 --- /dev/null +++ b/apps/mobile/src/app/modals/SendTokenModal.tsx @@ -0,0 +1,6 @@ +import React from 'react' +import { SendFlow } from 'src/features/send/SendFlow' + +export function SendTokenModal(): JSX.Element { + return +} diff --git a/apps/mobile/src/app/modals/SmartWalletInfoModal.tsx b/apps/mobile/src/app/modals/SmartWalletInfoModal.tsx new file mode 100644 index 00000000..cb27dfbd --- /dev/null +++ b/apps/mobile/src/app/modals/SmartWalletInfoModal.tsx @@ -0,0 +1,46 @@ +import React from 'react' +import { useTranslation } from 'react-i18next' +import { useReactNavigationModal } from 'src/components/modals/useReactNavigationModal' +import { Button, Flex, Text, TouchableArea, useSporeColors } from 'ui/src' +import { SmartWallet } from 'ui/src/components/icons' +import { Modal } from 'uniswap/src/components/modals/Modal' +import { uniswapUrls } from 'uniswap/src/constants/urls' +import { ModalName } from 'uniswap/src/features/telemetry/constants' +import { openUri } from 'uniswap/src/utils/linking' + +const onPressLearnMore = (url: string): Promise => openUri({ uri: url }) + +export function SmartWalletInfoModal(): JSX.Element { + const color = useSporeColors() + const { onClose } = useReactNavigationModal() + const { t } = useTranslation() + + return ( + + + + + + + {t('smartWallet.modal.title')} + + {t('smartWallet.modal.description.block1')} + + + {t('smartWallet.modal.description.block2')} + + onPressLearnMore(uniswapUrls.helpArticleUrls.smartWalletDelegation)}> + + {t('common.button.learn')} + + + + + + + + + ) +} diff --git a/apps/mobile/src/app/modals/SwapModal.test.tsx b/apps/mobile/src/app/modals/SwapModal.test.tsx new file mode 100644 index 00000000..b35f86a6 --- /dev/null +++ b/apps/mobile/src/app/modals/SwapModal.test.tsx @@ -0,0 +1,53 @@ +import { configureStore } from '@reduxjs/toolkit' +import React from 'react' +import { SwapModal } from 'src/app/modals/SwapModal' +import { AppStackScreenProp } from 'src/app/navigation/types' +import { persistedReducer } from 'src/app/store' +import { preloadedMobileState } from 'src/test/fixtures' +import { renderWithProviders } from 'src/test/render' +import { ModalName } from 'uniswap/src/features/telemetry/constants' + +// Mock required modules with simpler implementation +jest.mock('wallet/src/features/transactions/swap/WalletSwapFlow', () => ({ + WalletSwapFlow: function MockWalletSwapFlow(): string { + return 'MockedWalletSwapFlow' + }, +})) + +// Simple tests to boost code coverage +describe('SwapModal', () => { + const mockProps: AppStackScreenProp = { + navigation: { + navigate: jest.fn(), + goBack: jest.fn(), + } as unknown as AppStackScreenProp['navigation'], + route: { + key: 'swap-modal', + name: ModalName.Swap, + params: undefined, + }, + } + + beforeEach(() => { + jest.clearAllMocks() + }) + + it('renders without crashing', () => { + const preloadedState = preloadedMobileState({}) + + // Create a test store with serialization check disabled + const store = configureStore({ + reducer: persistedReducer, + middleware: (getDefaultMiddleware) => + getDefaultMiddleware({ + serializableCheck: false, // Disable serialization check for tests + }), + }) + + const tree = renderWithProviders(, { + preloadedState, + store, + }) + expect(tree.toJSON()).toBeTruthy() + }) +}) diff --git a/apps/mobile/src/app/modals/SwapModal.tsx b/apps/mobile/src/app/modals/SwapModal.tsx new file mode 100644 index 00000000..5f8c19de --- /dev/null +++ b/apps/mobile/src/app/modals/SwapModal.tsx @@ -0,0 +1,78 @@ +import React, { useCallback, useEffect } from 'react' +import { useDispatch } from 'react-redux' +import { AppStackScreenProp } from 'src/app/navigation/types' +import { BiometricsIconProps, useBiometricsIcon } from 'src/components/icons/useBiometricsIcon' +import { useReactNavigationModal } from 'src/components/modals/useReactNavigationModal' +import { WalletRestoreType } from 'src/components/RestoreWalletModal/RestoreWalletModalState' +import { useBiometricAppSettings } from 'src/features/biometrics/useBiometricAppSettings' +import { useOsBiometricAuthEnabled } from 'src/features/biometrics/useOsBiometricAuthEnabled' +import { useBiometricPrompt } from 'src/features/biometricsSettings/hooks' +import { useWalletRestore } from 'src/features/wallet/useWalletRestore' +import { clearNotificationsByType } from 'uniswap/src/features/notifications/slice/slice' +import { AppNotificationType } from 'uniswap/src/features/notifications/slice/types' +import { useHapticFeedback } from 'uniswap/src/features/settings/useHapticFeedback/useHapticFeedback' +import { ModalName } from 'uniswap/src/features/telemetry/constants' +import { updateSwapStartTimestamp } from 'uniswap/src/features/timing/slice' +import { useSwapPrefilledState } from 'uniswap/src/features/transactions/swap/form/hooks/useSwapPrefilledState' +import { logger } from 'utilities/src/logger/logger' +import { WalletSwapFlow } from 'wallet/src/features/transactions/swap/WalletSwapFlow' +import { invalidateAndRefetchWalletDelegationQueries } from 'wallet/src/features/transactions/watcher/transactionFinalizationSaga' + +export function SwapModal({ route }: AppStackScreenProp): JSX.Element { + const appDispatch = useDispatch() + const initialState = route.params + const { hapticFeedback } = useHapticFeedback() + + const { onClose: onCloseModal } = useReactNavigationModal() + + // Clear network change notification toasts when the swap modal closes + const onClose = useCallback(() => { + appDispatch( + clearNotificationsByType({ + types: [AppNotificationType.NetworkChanged, AppNotificationType.NetworkChangedBridge], + }), + ) + onCloseModal() + }, [appDispatch, onCloseModal]) + + // Update flow start timestamp every time modal is opened for logging + useEffect(() => { + const timestamp = Date.now() + appDispatch(updateSwapStartTimestamp({ timestamp })) + invalidateAndRefetchWalletDelegationQueries().catch((error) => + logger.debug('SwapModal', 'useEffect', 'Failed to invalidate and refetch wallet delegation queries', error), + ) + }, [appDispatch]) + + const { openWalletRestoreModal, walletRestoreType } = useWalletRestore() + + const swapPrefilledState = useSwapPrefilledState(initialState) + + const { requiredForTransactions: requiresBiometrics } = useBiometricAppSettings() + const { trigger: biometricsTrigger } = useBiometricPrompt() + const renderBiometricsIcon = useSwapBiometricsIcon() + + return ( + + ) +} + +function useSwapBiometricsIcon(): (({ color }: BiometricsIconProps) => JSX.Element) | null { + const isBiometricAuthEnabled = useOsBiometricAuthEnabled() + const { requiredForTransactions } = useBiometricAppSettings() + const renderBiometricsIcon = useBiometricsIcon() + + if (isBiometricAuthEnabled && requiredForTransactions && renderBiometricsIcon) { + return renderBiometricsIcon + } + + return null +} diff --git a/apps/mobile/src/app/modals/TokenWarningModalState.ts b/apps/mobile/src/app/modals/TokenWarningModalState.ts new file mode 100644 index 00000000..9385d9cd --- /dev/null +++ b/apps/mobile/src/app/modals/TokenWarningModalState.ts @@ -0,0 +1,4 @@ +export interface TokenWarningModalState { + currencyId: string + onAcknowledge: () => void +} diff --git a/apps/mobile/src/app/modals/TokenWarningModalWrapper.tsx b/apps/mobile/src/app/modals/TokenWarningModalWrapper.tsx new file mode 100644 index 00000000..ea871b10 --- /dev/null +++ b/apps/mobile/src/app/modals/TokenWarningModalWrapper.tsx @@ -0,0 +1,61 @@ +import { AppStackScreenProp } from 'src/app/navigation/types' +import { useReactNavigationModal } from 'src/components/modals/useReactNavigationModal' +import { useEnabledChains } from 'uniswap/src/features/chains/hooks/useEnabledChains' +import { TokenList } from 'uniswap/src/features/dataApi/types' +import { ModalName } from 'uniswap/src/features/telemetry/constants' +import { useCurrencyInfo } from 'uniswap/src/features/tokens/useCurrencyInfo' +import { getTokenProtectionWarning } from 'uniswap/src/features/tokens/warnings/safetyUtils' +import { useDismissedTokenWarnings } from 'uniswap/src/features/tokens/warnings/slice/hooks' +import TokenWarningModal from 'uniswap/src/features/tokens/warnings/TokenWarningModal' +import { currencyIdToAddress, currencyIdToChain, isNativeCurrencyAddress } from 'uniswap/src/utils/currencyId' + +export function TokenWarningModalWrapper({ + route, +}: AppStackScreenProp): JSX.Element | null { + const { defaultChainId } = useEnabledChains() + const { onClose } = useReactNavigationModal() + + const { currencyId, onAcknowledge } = route.params.initialState ?? {} + const currencyChainId = (currencyId && currencyIdToChain(currencyId)) || defaultChainId + const currencyAddress = currencyId ? currencyIdToAddress(currencyId) : undefined + const currencyInfo = useCurrencyInfo(currencyId) + const tokenProtectionWarning = getTokenProtectionWarning(currencyInfo) + + // Get the token info only if we have a valid non-native currency + const isNativeCurrency = isNativeCurrencyAddress(currencyChainId, currencyAddress) + const { tokenWarningDismissed } = useDismissedTokenWarnings( + isNativeCurrency || !currencyAddress ? undefined : { chainId: currencyChainId, address: currencyAddress }, + tokenProtectionWarning, + ) + + // Return null if modal state is malformed + if (!route.params.initialState) { + return null + } + + // If no currency info found, skip warning and proceed to SwapFlow + if (!currencyInfo) { + onClose() + onAcknowledge?.() + return null + } + + const tokenList = currencyInfo.safetyInfo?.tokenList + const isBlocked = tokenList === TokenList.Blocked + + // If token is verified or warning was dismissed and not blocked, skip warning and proceed to next step. + if (!isBlocked && (tokenList === TokenList.Default || tokenWarningDismissed)) { + onAcknowledge?.() + return null + } + + return ( + onAcknowledge?.()} + /> + ) +} diff --git a/apps/mobile/src/app/modals/ViewOnlyExplainerModal.tsx b/apps/mobile/src/app/modals/ViewOnlyExplainerModal.tsx new file mode 100644 index 00000000..0914e6f7 --- /dev/null +++ b/apps/mobile/src/app/modals/ViewOnlyExplainerModal.tsx @@ -0,0 +1,68 @@ +import { useTranslation } from 'react-i18next' +import { navigate } from 'src/app/navigation/rootNavigation' +import { useReactNavigationModal } from 'src/components/modals/useReactNavigationModal' +import { Button, Flex, Text, useIsDarkMode } from 'ui/src' +import ViewOnlyWalletDark from 'ui/src/assets/graphics/view-only-wallet-dark.svg' +import ViewOnlyWalletLight from 'ui/src/assets/graphics/view-only-wallet-light.svg' +import { Modal } from 'uniswap/src/components/modals/Modal' +import { ModalName } from 'uniswap/src/features/telemetry/constants' +import { ImportType, OnboardingEntryPoint } from 'uniswap/src/types/onboarding' +import { MobileScreens, OnboardingScreens } from 'uniswap/src/types/screens/mobile' +import { useActiveAccountAddress, useNativeAccountExists } from 'wallet/src/features/wallet/hooks' + +const WALLET_IMAGE_ASPECT_RATIO = 327 / 215 + +export function ViewOnlyExplainerModal(): JSX.Element { + const { t } = useTranslation() + const activeAccountAddress = useActiveAccountAddress() + const hasImportedSeedPhrase = useNativeAccountExists() + const isDarkMode = useIsDarkMode() + + const { onClose } = useReactNavigationModal() + + const onPressImportWallet = (): void => { + onClose() + if (hasImportedSeedPhrase && activeAccountAddress) { + navigate(ModalName.RemoveWallet, { + replaceMnemonic: true, + }) + } else { + navigate(MobileScreens.OnboardingStack, { + screen: OnboardingScreens.SeedPhraseInput, + params: { importType: ImportType.SeedPhrase, entryPoint: OnboardingEntryPoint.Sidebar }, + }) + } + } + + const WalletImage = isDarkMode ? ViewOnlyWalletDark : ViewOnlyWalletLight + + return ( + + + + + + + + {t('account.wallet.viewOnly.title')} + + {t('account.wallet.viewOnly.description')} + + + + + + + + + + + + + + ) +} diff --git a/apps/mobile/src/app/modals/__snapshots__/AccountSwitcherModal.test.tsx.snap b/apps/mobile/src/app/modals/__snapshots__/AccountSwitcherModal.test.tsx.snap new file mode 100644 index 00000000..f21d8327 --- /dev/null +++ b/apps/mobile/src/app/modals/__snapshots__/AccountSwitcherModal.test.tsx.snap @@ -0,0 +1,811 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`AccountSwitcher renders correctly 1`] = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0x​82D56A...373Fa6 + + + + + + + + + + + + + + + + + + + + + Wallet settings + + + + + + + ExpoLinearGradient + + + + ExpoLinearGradient + + + + + + + + + + + + + Add wallet + + + + +`; diff --git a/apps/mobile/src/app/monitoredSagas.ts b/apps/mobile/src/app/monitoredSagas.ts new file mode 100644 index 00000000..3d049544 --- /dev/null +++ b/apps/mobile/src/app/monitoredSagas.ts @@ -0,0 +1,75 @@ +import { getMonitoredSagaReducers, type MonitoredSaga } from 'uniswap/src/utils/saga' +import { + removeDelegationActions, + removeDelegationReducer, + removeDelegationSaga, + removeDelegationSagaName, +} from 'wallet/src/features/smartWallet/sagas/removeDelegationSaga' +import { + executePlanActions, + executePlanReducer, + executePlanSaga, + executePlanSagaName, + executeSwapActions, + executeSwapReducer, + executeSwapSaga, + executeSwapSagaName, + prepareAndSignSwapActions, + prepareAndSignSwapReducer, + prepareAndSignSwapSaga, + prepareAndSignSwapSagaName, +} from 'wallet/src/features/transactions/swap/configuredSagas' +import { + editAccountActions, + editAccountReducer, + editAccountSaga, + editAccountSagaName, +} from 'wallet/src/features/wallet/accounts/editAccountSaga' +import { + createAccountsActions, + createAccountsReducer, + createAccountsSaga, + createAccountsSagaName, +} from 'wallet/src/features/wallet/create/createAccountsSaga' + +// All monitored sagas must be included here +export const monitoredSagas: Record = { + [createAccountsSagaName]: { + name: createAccountsSagaName, + wrappedSaga: createAccountsSaga, + reducer: createAccountsReducer, + actions: createAccountsActions, + }, + [editAccountSagaName]: { + name: editAccountSagaName, + wrappedSaga: editAccountSaga, + reducer: editAccountReducer, + actions: editAccountActions, + }, + [prepareAndSignSwapSagaName]: { + name: prepareAndSignSwapSagaName, + wrappedSaga: prepareAndSignSwapSaga, + reducer: prepareAndSignSwapReducer, + actions: prepareAndSignSwapActions, + }, + [executeSwapSagaName]: { + name: executeSwapSagaName, + wrappedSaga: executeSwapSaga, + reducer: executeSwapReducer, + actions: executeSwapActions, + }, + [executePlanSagaName]: { + name: executePlanSagaName, + wrappedSaga: executePlanSaga, + reducer: executePlanReducer, + actions: executePlanActions, + }, + [removeDelegationSagaName]: { + name: removeDelegationSagaName, + wrappedSaga: removeDelegationSaga, + reducer: removeDelegationReducer, + actions: removeDelegationActions, + }, +} + +export const monitoredSagaReducers = getMonitoredSagaReducers(monitoredSagas) diff --git a/apps/mobile/src/app/navigation/ExploreStackNavigator.tsx b/apps/mobile/src/app/navigation/ExploreStackNavigator.tsx new file mode 100644 index 00000000..f6bcc947 --- /dev/null +++ b/apps/mobile/src/app/navigation/ExploreStackNavigator.tsx @@ -0,0 +1,59 @@ +import { DefaultTheme, NavigationContainer, NavigationIndependentTree } from '@react-navigation/native' +import { createNativeStackNavigator } from '@react-navigation/native-stack' +import React from 'react' +import { exploreNavigationRef } from 'src/app/navigation/navigationRef' +import { navNativeStackOptions } from 'src/app/navigation/navStackOptions' +import { startTracking, stopTracking } from 'src/app/navigation/trackingHelpers' +import { ExploreStackParamList } from 'src/app/navigation/types' +import { HorizontalEdgeGestureTarget } from 'src/components/layout/screens/EdgeGestureTarget' +import { ExploreScreen } from 'src/screens/ExploreScreen' +import { ExternalProfileScreen } from 'src/screens/ExternalProfileScreen' +import { TokenDetailsScreen } from 'src/screens/TokenDetailsScreen/TokenDetailsScreen' +import { useSporeColors } from 'ui/src' +import { MobileScreens } from 'uniswap/src/types/screens/mobile' + +const ExploreStack = createNativeStackNavigator() + +export function ExploreStackNavigator({ + initialParams, +}: { + initialParams?: ExploreStackParamList[MobileScreens.Explore] +}): JSX.Element { + const colors = useSporeColors() + + return ( + + startTracking(exploreNavigationRef)} + > + + + + + + {(props): JSX.Element => } + + + + + + + ) +} diff --git a/apps/mobile/src/app/navigation/NavBar.tsx b/apps/mobile/src/app/navigation/NavBar.tsx new file mode 100644 index 00000000..566d8e69 --- /dev/null +++ b/apps/mobile/src/app/navigation/NavBar.tsx @@ -0,0 +1,260 @@ +import { SharedEventName } from '@uniswap/analytics-events' +import { BlurView } from 'expo-blur' +import React, { memo, useCallback, useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { LayoutChangeEvent, LayoutRectangle, StyleSheet } from 'react-native' +import { TapGestureHandler, TapGestureHandlerGestureEvent } from 'react-native-gesture-handler' +import { + cancelAnimation, + runOnJS, + useAnimatedGestureHandler, + useAnimatedStyle, + useSharedValue, +} from 'react-native-reanimated' +import { useSafeAreaFrame } from 'react-native-safe-area-context' +import { useDispatch, useSelector } from 'react-redux' +import { useAppStackNavigation } from 'src/app/navigation/types' +import { pulseAnimation } from 'src/components/buttons/utils' +import { Flex, FlexProps, LinearGradient, Text, TouchableArea, useIsDarkMode, useSporeColors } from 'ui/src' +import { Search } from 'ui/src/components/icons' +import { AnimatedFlex } from 'ui/src/components/layout/AnimatedFlex' +import { borderRadii, fonts, opacify } from 'ui/src/theme' +import { useEnabledChains } from 'uniswap/src/features/chains/hooks/useEnabledChains' +import { useHighestBalanceNativeCurrencyId } from 'uniswap/src/features/dataApi/balances/balances' +import { useHapticFeedback } from 'uniswap/src/features/settings/useHapticFeedback/useHapticFeedback' +import { ElementName, ModalName } from 'uniswap/src/features/telemetry/constants' +import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { selectFilteredChainIds } from 'uniswap/src/features/transactions/swap/state/selectors' +import { prepareSwapFormState } from 'uniswap/src/features/transactions/types/transactionState' +import { useAppInsets } from 'uniswap/src/hooks/useAppInsets' +import { TestID } from 'uniswap/src/test/fixtures/testIDs' +import { CurrencyField } from 'uniswap/src/types/currency' +import { MobileScreens } from 'uniswap/src/types/screens/mobile' +import { isAndroid, isIOS } from 'utilities/src/platform' +import { setHasUsedExplore } from 'wallet/src/features/behaviorHistory/slice' +import { useActiveAccountAddressWithThrow } from 'wallet/src/features/wallet/hooks' + +const NAV_BAR_MARGIN_SIDES = 24 +const NAV_BAR_GAP = 12 + +export const SWAP_BUTTON_HEIGHT = 56 +const SWAP_BUTTON_SHADOW_OFFSET = { width: 0, height: 4 } + +function sendSwapPressAnalyticsEvent(): void { + sendAnalyticsEvent(SharedEventName.ELEMENT_CLICKED, { + screen: MobileScreens.Home, + element: ElementName.Swap, + }) +} + +export function NavBar(): JSX.Element { + const insets = useAppInsets() + const { width: screenWidth } = useSafeAreaFrame() + const [isNarrow, setIsNarrow] = useState(false) + const [exploreButtonLayout, setExploreButtonLayout] = useState(null) + const [swapButtonLayout, setSwapButtonLayout] = useState(null) + + const colors = useSporeColors() + const isDarkMode = useIsDarkMode() + + // oxlint-disable-next-line react/exhaustive-deps -- we want to ignore isNarrow because of unknown reason + useEffect(() => { + if (isNarrow || !exploreButtonLayout?.width || !swapButtonLayout?.width) { + return + } + + // When the 2 buttons overflow, we set `isNarrow` to true and adjust the design accordingly. + // To test this, you can use an iPhone Mini set to Spanish. + setIsNarrow(exploreButtonLayout.width + swapButtonLayout.width + NAV_BAR_GAP + NAV_BAR_MARGIN_SIDES > screenWidth) +const SwapFAB = memo(function SwapFABInner({ activeScale = 0.96, onSwapLayout }: SwapTabBarButtonProps) { + const { t } = useTranslation() + const { defaultChainId } = useEnabledChains() + const { hapticFeedback } = useHapticFeedback() + const { navigate } = useAppStackNavigation() + + const isDarkMode = useIsDarkMode() + const activeAccountAddress = useActiveAccountAddressWithThrow() + const persistedFilteredChainIds = useSelector(selectFilteredChainIds) + const inputCurrencyId = useHighestBalanceNativeCurrencyId({ + evmAddress: activeAccountAddress, + chainId: persistedFilteredChainIds?.[CurrencyField.INPUT], + }) + + const onPress = useCallback(async () => { + navigate( + ModalName.Swap, + prepareSwapFormState({ + inputCurrencyId, + defaultChainId, + filteredChainIdsOverride: persistedFilteredChainIds, + }), + ) + + await hapticFeedback.light() + }, [inputCurrencyId, defaultChainId, hapticFeedback, persistedFilteredChainIds, navigate]) + + const scale = useSharedValue(1) + const animatedStyle = useAnimatedStyle(() => ({ transform: [{ scale: scale.value }] }), [scale]) + const onGestureEvent = useAnimatedGestureHandler({ + onStart: () => { + cancelAnimation(scale) + scale.value = pulseAnimation(activeScale) + }, + onEnd: () => { + runOnJS(onPress)() + runOnJS(sendSwapPressAnalyticsEvent)() + }, + }) + + return ( + + + + + + {t('common.button.swap')} + + + + + ) +}) + +type ExploreTabBarButtonProps = { + /** + * The value to scale to when the Pressable is being pressed. + * @default 0.98 + */ + activeScale?: number + isNarrow: boolean + onLayout: (event: LayoutChangeEvent) => void +} + +function ExploreTabBarButton({ activeScale = 0.98, onLayout, isNarrow }: ExploreTabBarButtonProps): JSX.Element { + const dispatch = useDispatch() + const colors = useSporeColors() + const isDarkMode = useIsDarkMode() + const { t } = useTranslation() + const { isTestnetModeEnabled } = useEnabledChains() + const { navigate } = useAppStackNavigation() + + const onPress = (): void => { + if (isTestnetModeEnabled) { + navigate(ModalName.TestnetMode, { unsupported: true }) + return + } + navigate(ModalName.Explore) + dispatch(setHasUsedExplore(true)) + } + const scale = useSharedValue(1) + + const animatedStyle = useAnimatedStyle(() => ({ transform: [{ scale: scale.value }] }), [scale]) + const onGestureEvent = useAnimatedGestureHandler({ + onStart: () => { + cancelAnimation(scale) + scale.value = pulseAnimation(activeScale) + }, + onEnd: () => { + runOnJS(onPress)() + }, + }) + + const contentProps: FlexProps = isIOS + ? { + backgroundColor: '$surface2', + opacity: isDarkMode ? 0.6 : 0.8, + } + : { + backgroundColor: '$surface1', + style: { + borderWidth: 1, + borderColor: colors.surface3.val, + }, + } + + const [height, setHeight] = useState(undefined) + + const internalOnLayout = (e: LayoutChangeEvent): void => { + setHeight(e.nativeEvent.layout.height) + onLayout(e) + } + + const Wrapper = isIOS ? BlurView : Flex + + return ( + + + + + + + {isNarrow ? undefined : ( + + {t('common.input.search')} + + )} + + + + + + ) +} + +const styles = StyleSheet.create({ + searchBar: { + flexGrow: 1, + }, +}) diff --git a/apps/mobile/src/app/navigation/NavigationContainer.tsx b/apps/mobile/src/app/navigation/NavigationContainer.tsx new file mode 100644 index 00000000..42ecef4f --- /dev/null +++ b/apps/mobile/src/app/navigation/NavigationContainer.tsx @@ -0,0 +1,133 @@ +import { DdRumReactNavigationTracking } from '@datadog/mobile-react-navigation' +import { + DefaultTheme, + NavigationContainer as NativeNavigationContainer, + NavigationContainerRefWithCurrent, +} from '@react-navigation/native' +import { useMutation } from '@tanstack/react-query' +import { SharedEventName } from '@uniswap/analytics-events' +import React, { FC, PropsWithChildren, useEffect, useRef, useState } from 'react' +import { EmitterSubscription, Linking } from 'react-native' +import { useDispatch } from 'react-redux' +import { navigationRef } from 'src/app/navigation/navigationRef' +import { RootParamList } from 'src/app/navigation/types' +import { openDeepLink } from 'src/features/deepLinking/handleDeepLinkSaga' +import { DIRECT_LOG_ONLY_SCREENS } from 'src/features/telemetry/directLogScreens' +import { getEventParams } from 'src/features/telemetry/utils' +import { processWidgetEvents } from 'src/features/widgets/widgets' +import { useSporeColors } from 'ui/src' +import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import Trace from 'uniswap/src/features/telemetry/Trace' +import { MobileNavScreen } from 'uniswap/src/types/screens/mobile' +import { datadogEnabledBuild } from 'utilities/src/environment/constants' +import { logger } from 'utilities/src/logger/logger' +import { useEvent } from 'utilities/src/react/hooks' +import { sleep } from 'utilities/src/time/timing' + +interface Props { + onReady?: (navigationRef: NavigationContainerRefWithCurrent) => void +} + +/** Wrapped `NavigationContainer` with telemetry tracing. */ +export const NavigationContainer: FC> = ({ children, onReady }: PropsWithChildren) => { + const colors = useSporeColors() + const [routeName, setRouteName] = useState() + const [routeParams, setRouteParams] = useState | undefined>() + const [logImpression, setLogImpression] = useState(false) + + useManageDeepLinks() + + return ( + { + onReady?.(navigationRef) + sendAnalyticsEvent(SharedEventName.APP_LOADED) + // Process widget events on app load + processWidgetEvents().catch(() => undefined) + + // setting initial route name for telemetry + const initialRoute = navigationRef.getCurrentRoute()?.name as MobileNavScreen + setRouteName(initialRoute) + + if (datadogEnabledBuild) { + DdRumReactNavigationTracking.startTrackingViews(navigationRef.current) + } + }} + onStateChange={(): void => { + const previousRouteName = routeName + const currentRouteName: MobileNavScreen | undefined = navigationRef.getCurrentRoute()?.name as + | MobileNavScreen + | undefined + + if ( + currentRouteName && + previousRouteName !== currentRouteName && + !DIRECT_LOG_ONLY_SCREENS.includes(currentRouteName) + ) { + const currentRouteParams = getEventParams( + currentRouteName, + navigationRef.getCurrentRoute()?.params as RootParamList[MobileNavScreen], + ) + setLogImpression(true) + setRouteName(currentRouteName) + setRouteParams(currentRouteParams) + } else { + setLogImpression(false) + } + }} + > + + {children} + + + ) +} + +const useManageDeepLinks = (): void => { + const dispatch = useDispatch() + const urlListener = useRef(undefined) + + const deepLinkMutation = useMutation({ + mutationFn: async () => { + if (urlListener.current) { + return + } + + const url = await Linking.getInitialURL() + if (url) { + dispatch(openDeepLink({ url, coldStart: true })) + } + // we need to set an event listener for deep links, but we don't want to do it immediately on cold start, + // as then there is a change we dispatch `openDeepLink` action twice if app was launched by a deep link + await sleep(2000) // 2000 was chosen empirically + urlListener.current = Linking.addEventListener('url', (event: { url: string }) => + dispatch(openDeepLink({ url: event.url, coldStart: false })), + ) + }, + onError: (error) => { + logger.error(error, { + tags: { + file: 'NavigationContainer', + function: 'useManageDeepLinks', + }, + }) + }, + }) + + const deepLinkEvent = useEvent(deepLinkMutation.mutate) + + useEffect(() => { + deepLinkEvent() + return () => { + if (urlListener.current) { + urlListener.current.remove() + } + } + }, [deepLinkEvent]) +} diff --git a/apps/mobile/src/app/navigation/components.tsx b/apps/mobile/src/app/navigation/components.tsx new file mode 100644 index 00000000..ba872ab8 --- /dev/null +++ b/apps/mobile/src/app/navigation/components.tsx @@ -0,0 +1,27 @@ +import { useTranslation } from 'react-i18next' +import { BackButton } from 'src/components/buttons/BackButton' +import { Text, TouchableArea } from 'ui/src' +import { RotatableChevron } from 'ui/src/components/icons' +import { ElementName } from 'uniswap/src/features/telemetry/constants' +import Trace from 'uniswap/src/features/telemetry/Trace' +import { TestID } from 'uniswap/src/test/fixtures/testIDs' + +export const renderHeaderBackButton = (): JSX.Element => ( + +) + +export const renderHeaderBackImage = (): JSX.Element => + +export const HeaderSkipButton = ({ onPress }: { onPress: () => void }): JSX.Element => { + const { t } = useTranslation() + + return ( + + onPress()}> + + {t('common.button.skip')} + + + + ) +} diff --git a/apps/mobile/src/app/navigation/constants.ts b/apps/mobile/src/app/navigation/constants.ts new file mode 100644 index 00000000..778a6d63 --- /dev/null +++ b/apps/mobile/src/app/navigation/constants.ts @@ -0,0 +1,2 @@ +// Some pages in react native navigation require a delay before the modal is opened +export const MODAL_OPEN_WAIT_TIME = 300 diff --git a/apps/mobile/src/app/navigation/hooks.ts b/apps/mobile/src/app/navigation/hooks.ts new file mode 100644 index 00000000..e58ecff8 --- /dev/null +++ b/apps/mobile/src/app/navigation/hooks.ts @@ -0,0 +1,106 @@ +import { NavigationContainerRefContext, NavigationContext, useFocusEffect } from '@react-navigation/core' +import { useQueryClient } from '@tanstack/react-query' +import { useCallback, useContext } from 'react' +import { BackHandler } from 'react-native' +import { navigate as rootNavigate } from 'src/app/navigation/rootNavigation' +import { useExploreStackNavigation } from 'src/app/navigation/types' +import { HomeScreenTabIndex } from 'src/screens/HomeScreen/HomeScreenTabIndex' +import { getListTransactionsQuery } from 'uniswap/src/data/rest/listTransactions' +import { useEnabledChains } from 'uniswap/src/features/chains/hooks/useEnabledChains' +import { MobileScreens } from 'uniswap/src/types/screens/mobile' +import { useEvent } from 'utilities/src/react/hooks' + +interface EagerExternalProfileNavigationResult { + preload: (address: string) => Promise + navigate: (address: string) => void +} + +interface EagerExternalProfileRootNavigationResult { + preload: (address: string) => Promise + navigate: (address: string, callback?: () => void) => Promise +} + +/** + * Hook that returns external profile navigation utilities using REST API + * Utility hook to simplify navigating to Activity screen. + * Preloads query needed to render transaction list. + */ +export function useEagerExternalProfileNavigation(): EagerExternalProfileNavigationResult { + const navigation = useExploreStackNavigation() + const queryClient = useQueryClient() + const { chains: chainIds } = useEnabledChains() + + const preload = useCallback( + async (address: string) => { + await queryClient.prefetchQuery(getListTransactionsQuery({ input: { evmAddress: address, chainIds } })) + }, + [chainIds, queryClient], + ) + + const navigate = useCallback( + (address: string) => { + navigation.navigate(MobileScreens.ExternalProfile, { address }) + }, + [navigation], + ) + + return { preload, navigate } +} + +/** + * Hook that returns external profile root navigation utilities using REST API + */ +export function useEagerExternalProfileRootNavigation(): EagerExternalProfileRootNavigationResult { + const queryClient = useQueryClient() + const { chains: chainIds } = useEnabledChains() + + const preload = useCallback( + async (address: string) => { + await queryClient.prefetchQuery(getListTransactionsQuery({ input: { evmAddress: address, chainIds } })) + }, + [chainIds, queryClient], + ) + + const navigate = useEvent(async (address: string, callback?: () => void) => { + await rootNavigate(MobileScreens.ExternalProfile, { address }) + callback?.() + }) + + return { preload, navigate } +} + +/** + * Utility hook that checks if the caller is part of the navigation tree. + * + * Inspired by how the navigation library checks if the navigation object exists. + * https://github.com/react-navigation/react-navigation/blob/d7032ba8bb6ae24030a47f0724b61b561132fca6/packages/core/src/useNavigation.tsx#L18 + */ +export function useIsPartOfNavigationTree(): boolean { + const root = useContext(NavigationContainerRefContext) + const navigation = useContext(NavigationContext) + + return navigation !== undefined || root !== undefined +} + +/** + * Overrides android default back button behaviour to allow + * navigating back to Tokens Tab when other tab is picked + */ +export function useHomeScreenCustomAndroidBackButton( + routeTabIndex: HomeScreenTabIndex, + setRouteTabIndex: (value: React.SetStateAction) => void, +): void { + useFocusEffect( + useCallback(() => { + const onBackPress = (): boolean => { + if (routeTabIndex !== HomeScreenTabIndex.Tokens) { + setRouteTabIndex(HomeScreenTabIndex.Tokens) + return true + } + return false + } + const subscription = BackHandler.addEventListener('hardwareBackPress', onBackPress) + return subscription.remove + }, [routeTabIndex, setRouteTabIndex]), + ) +} diff --git a/apps/mobile/src/app/navigation/navStackOptions.ts b/apps/mobile/src/app/navigation/navStackOptions.ts new file mode 100644 index 00000000..55ca1cab --- /dev/null +++ b/apps/mobile/src/app/navigation/navStackOptions.ts @@ -0,0 +1,23 @@ +import { NativeStackNavigationOptions } from '@react-navigation/native-stack' +import { StackNavigationOptions } from '@react-navigation/stack' + +export const navNativeStackOptions = { + noHeader: { headerShown: false }, + presentationModal: { presentation: 'modal' }, + presentationBottomSheet: { + presentation: 'containedTransparentModal', + animation: 'none', + animationDuration: 0, + contentStyle: { backgroundColor: 'transparent' }, + }, + independentBsm: { + fullScreenGestureEnabled: true, + gestureEnabled: true, + headerShown: false, + animation: 'slide_from_right', + }, +} as const satisfies Record + +export const navStackOptions = { + noHeader: { headerShown: false }, +} as const satisfies Record diff --git a/apps/mobile/src/app/navigation/navigation.tsx b/apps/mobile/src/app/navigation/navigation.tsx new file mode 100644 index 00000000..23b6ec23 --- /dev/null +++ b/apps/mobile/src/app/navigation/navigation.tsx @@ -0,0 +1,452 @@ +import { NavigationContainer, NavigationIndependentTree } from '@react-navigation/native' +import { createNativeStackNavigator } from '@react-navigation/native-stack' +import { createStackNavigator, TransitionPresets } from '@react-navigation/stack' +import { FeatureFlags, useFeatureFlag } from '@universe/gating' +import React, { useEffect } from 'react' +import { DevSettings } from 'react-native' +import { INCLUDE_PROTOTYPE_FEATURES, IS_E2E_TEST } from 'react-native-dotenv' +import { useSelector } from 'react-redux' +import { AccountSwitcherModal } from 'src/app/modals/AccountSwitcherModal' +import { BackupReminderModal } from 'src/app/modals/BackupReminderModal' +import { BackupWarningModal } from 'src/app/modals/BackupWarningModal' +import { BridgedAssetWarningWrapper } from 'src/app/modals/BridgedAssetWarningWrapper' +import { ExperimentsModal } from 'src/app/modals/ExperimentsModal' +import { ExploreModal } from 'src/app/modals/ExploreModal' +import { KoreaCexTransferInfoModal } from 'src/app/modals/KoreaCexTransferInfoModal' +import { NotificationsOSSettingsModal } from 'src/app/modals/NotificationsOSSettingsModal' +import { SmartWalletInfoModal } from 'src/app/modals/SmartWalletInfoModal' +import { SwapModal } from 'src/app/modals/SwapModal' +import { TokenWarningModalWrapper } from 'src/app/modals/TokenWarningModalWrapper' +import { ViewOnlyExplainerModal } from 'src/app/modals/ViewOnlyExplainerModal' +import { renderHeaderBackButton, renderHeaderBackImage } from 'src/app/navigation/components' +import { fiatOnRampNavigationRef, navigationRef } from 'src/app/navigation/navigationRef' +import { navNativeStackOptions, navStackOptions } from 'src/app/navigation/navStackOptions' +import { TabsNavigator } from 'src/app/navigation/tabs/TabsNavigator' +import { startTracking, stopTracking } from 'src/app/navigation/trackingHelpers' +import { + type AppStackParamList, + type FiatOnRampStackParamList, + type OnboardingStackParamList, + type SettingsStackParamList, + useAppStackNavigation, +} from 'src/app/navigation/types' +import { FiatOnRampActionModal } from 'src/components/home/FiatOnRampActionModal' +import { FundWalletModal } from 'src/components/home/introCards/FundWalletModal' +import { HorizontalEdgeGestureTarget } from 'src/components/layout/screens/EdgeGestureTarget' +import { AdvancedSettingsModal } from 'src/components/modals/ReactNavigationModals/AdvancedSettingsModal' +import { BridgedAssetModalScreen } from 'src/components/modals/ReactNavigationModals/BridgedAssetModal' +import { HiddenTokenInfoModalScreen } from 'src/components/modals/ReactNavigationModals/HiddenTokenInfoModalScreen' +>>>>>>> upstream/main +import { PasskeyHelpModalScreen } from 'src/components/modals/ReactNavigationModals/PasskeyHelpModalScreen' +import { PasskeyManagementModalScreen } from 'src/components/modals/ReactNavigationModals/PasskeyManagementModalScreen' +import { PermissionsSettingsScreen } from 'src/components/modals/ReactNavigationModals/PermissionsSettingsScreen' +import { PortfolioBalanceSettingsScreen } from 'src/components/modals/ReactNavigationModals/PortfolioBalanceSettingsScreen' +import { ReportPortfolioDataModalScreen } from 'src/components/modals/ReactNavigationModals/ReportPortfolioDataModalScreen' +import { ReportTokenDataModalScreen } from 'src/components/modals/ReactNavigationModals/ReportTokenDataModalScreen' +import { ReportTokenIssueModalScreen } from 'src/components/modals/ReactNavigationModals/ReportTokenIssueModalScreen' +import { SmartWalletEnabledModalScreen } from 'src/components/modals/ReactNavigationModals/SmartWalletEnabledModalScreen' +import { SmartWalletNudgeScreen } from 'src/components/modals/ReactNavigationModals/SmartWalletNudgeScreen' +import { TestnetModeModalScreen } from 'src/components/modals/ReactNavigationModals/TestnetModeModalScreen' +import { WormholeModalScreen } from 'src/components/modals/ReactNavigationModals/WormholeModal' +import { RemoveWalletModal } from 'src/components/RemoveWallet/RemoveWalletModal' +import { PrivateKeySpeedBumpModal } from 'src/components/RestoreWalletModal/PrivateKeySpeedBumpModal' +import { RestoreWalletModal } from 'src/components/RestoreWalletModal/RestoreWalletModal' +import { ConnectionsDappListModal } from 'src/components/Settings/ConnectionsDappModal/ConnectionsDappListModal' +import { EditLabelSettingsModal } from 'src/components/Settings/EditWalletModal/EditLabelSettingsModal' +import { EditProfileSettingsModal } from 'src/components/Settings/EditWalletModal/EditProfileSettingsModal' +import { ManageWalletsModal } from 'src/components/Settings/ManageWalletsModal' +import { SettingsAppearanceModal } from 'src/components/Settings/SettingsAppearanceModal' +import { SettingsBiometricModal } from 'src/components/Settings/SettingsBiometricModal' +import { BuyNativeTokenModal } from 'src/components/TokenDetails/BuyNativeTokenModal' +import { UnitagsIntroModal } from 'src/components/unitags/UnitagsIntroModal' +import { ExchangeTransferModal } from 'src/features/fiatOnRamp/ExchangeTransferModal' +import { FiatOnRampProvider } from 'src/features/fiatOnRamp/FiatOnRampContext' +import { ScreenshotWarningModal } from 'src/features/onboarding/ScreenshotWarningModal' +import { ScantasticModal } from 'src/features/scantastic/ScantasticModal' +import { TestnetSwitchModal } from 'src/features/testnetMode/TestnetSwitchModal' +import { ClaimUnitagScreen } from 'src/features/unitags/ClaimUnitagScreen' +import { EditUnitagProfileScreen } from 'src/features/unitags/EditUnitagProfileScreen' +import { UnitagChooseProfilePicScreen } from 'src/features/unitags/UnitagChooseProfilePicScreen' +import { UnitagConfirmationScreen } from 'src/features/unitags/UnitagConfirmationScreen' +import { AppLoadingScreen } from 'src/screens/AppLoadingScreen' +import { DebugScreensScreen } from 'src/screens/DebugScreensScreen' +import { DevScreen } from 'src/screens/DevScreen' +import { EducationScreen } from 'src/screens/EducationScreen' +import { ExternalProfileScreen } from 'src/screens/ExternalProfileScreen' +import { FiatOnRampConnectingScreen } from 'src/screens/FiatOnRampConnecting' +import { FiatOnRampScreen } from 'src/screens/FiatOnRampScreen' +import { FiatOnRampServiceProvidersScreen } from 'src/screens/FiatOnRampServiceProviders' +import { WrappedHomeScreen } from 'src/screens/HomeScreen/HomeScreen' +import { ImportMethodScreen } from 'src/screens/Import/ImportMethodScreen' +import { OnDeviceRecoveryScreen } from 'src/screens/Import/OnDeviceRecoveryScreen' +import { OnDeviceRecoveryViewSeedPhraseScreen } from 'src/screens/Import/OnDeviceRecoveryViewSeedPhraseScreen' +import { PasskeyImportScreen } from 'src/screens/Import/PasskeyImportScreen' +import { RestoreCloudBackupLoadingScreen } from 'src/screens/Import/RestoreCloudBackupLoadingScreen' +import { RestoreCloudBackupPasswordScreen } from 'src/screens/Import/RestoreCloudBackupPasswordScreen' +import { RestoreCloudBackupScreen } from 'src/screens/Import/RestoreCloudBackupScreen' +import { RestoreMethodScreen } from 'src/screens/Import/RestoreMethodScreen' +import { SeedPhraseInputScreen } from 'src/screens/Import/SeedPhraseInputScreen/SeedPhraseInputScreen' +import { SelectWalletScreen } from 'src/screens/Import/SelectWalletScreen' +import { WatchWalletScreen } from 'src/screens/Import/WatchWalletScreen' +import { BackupScreen } from 'src/screens/Onboarding/BackupScreen' +import { CloudBackupPasswordConfirmScreen } from 'src/screens/Onboarding/CloudBackupPasswordConfirmScreen' +import { CloudBackupPasswordCreateScreen } from 'src/screens/Onboarding/CloudBackupPasswordCreateScreen' +import { CloudBackupProcessingScreen } from 'src/screens/Onboarding/CloudBackupProcessingScreen' +import { LandingScreen } from 'src/screens/Onboarding/LandingScreen' +import { ManualBackupScreen } from 'src/screens/Onboarding/ManualBackupScreen' +import { NotificationsSetupScreen } from 'src/screens/Onboarding/NotificationsSetupScreen' +import { SecuritySetupScreen } from 'src/screens/Onboarding/SecuritySetupScreen' +import { WelcomeWalletScreen } from 'src/screens/Onboarding/WelcomeWalletScreen' +import { ReceiveCryptoModal } from 'src/screens/ReceiveCryptoModal' +import { SettingsCloudBackupPasswordConfirmScreen } from 'src/screens/SettingsCloudBackupPasswordConfirmScreen' +import { SettingsCloudBackupPasswordCreateScreen } from 'src/screens/SettingsCloudBackupPasswordCreateScreen' +import { SettingsCloudBackupProcessingScreen } from 'src/screens/SettingsCloudBackupProcessingScreen' +import { SettingsCloudBackupStatus } from 'src/screens/SettingsCloudBackupStatus' +import { SettingsFiatCurrencyModal } from 'src/screens/SettingsFiatCurrencyModal' +import { useSporeColors } from 'ui/src' +import { spacing } from 'ui/src/theme' +import { ModalName } from 'uniswap/src/features/telemetry/constants' +import { useAppInsets } from 'uniswap/src/hooks/useAppInsets' +import { OnboardingEntryPoint } from 'uniswap/src/types/onboarding' +import { + FiatOnRampScreens, + MobileScreens, + OnboardingScreens, + UnitagScreens, + type UnitagStackParamList, +} from 'uniswap/src/types/screens/mobile' +import { OnboardingContextProvider } from 'wallet/src/features/onboarding/OnboardingContext' +import { selectFinishedOnboarding } from 'wallet/src/features/wallet/selectors' + +/** + * Note that we need to explicitly check for the imports from 'react-native-dotenv' + * in for the bundler to know to exclude this code from release builds. + */ +const enabledInEnvOrDev = + INCLUDE_PROTOTYPE_FEATURES === 'true' || + process.env.INCLUDE_PROTOTYPE_FEATURES === 'true' || + IS_E2E_TEST === 'true' || + process.env.IS_E2E_TEST === 'true' || + __DEV__ + +const OnboardingStack = createNativeStackNavigator() +const AppStack = createNativeStackNavigator() +const FiatOnRampStack = createNativeStackNavigator() +const SettingsStack = createNativeStackNavigator() +const UnitagStack = createStackNavigator() + +function SettingsStackGroup(): JSX.Element { + return ( + + + + + + + + + + + + + + + + + + + + + {enabledInEnvOrDev && + ((): JSX.Element => { + return + })()} + + + ) +} + +export function FiatOnRampStackNavigator(): JSX.Element { + return ( + + startTracking(fiatOnRampNavigationRef)} + onStateChange={stopTracking} + > + + + + + + + + + + + ) +} + +function OnboardingStackNavigator(): JSX.Element { + const colors = useSporeColors() + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} + +function UnitagStackNavigator(): JSX.Element { + const colors = useSporeColors() + const insets = useAppInsets() + + return ( + + + + + + + + + ) +} + +export function AppStackNavigator(): JSX.Element { + const finishedOnboarding = useSelector(selectFinishedOnboarding) + const navigation = useAppStackNavigation() + + const isBottomTabsEnabled = useFeatureFlag(FeatureFlags.BottomTabs) + + useEffect(() => { + // Adds a menu item to navigate to Storybook in debug builds + if (__DEV__) { + DevSettings.addMenuItem('Toggle Storybook', () => { + if (navigationRef.getCurrentRoute()?.name === MobileScreens.Storybook) { + navigation.goBack() + } else { + navigation.navigate(MobileScreens.Storybook) + } + }) + } + }, [navigation]) + + return ( + + {finishedOnboarding && ( + + )} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +<<<<<<< HEAD + +======= + + + + {enabledInEnvOrDev && + ((): JSX.Element => { + return + })()} + + {/* Explicitly using __DEV__ so that the bundler knows to exclude this code from release builds */} + {__DEV__ && + ((): JSX.Element => { + const StorybookUIRoot = require('src/../.storybook').default + const { HashcashBenchmarkScreen } = require('src/screens/HashcashBenchmarkScreen') + const { SessionsDebugScreen } = require('src/screens/SessionsDebugScreen') + return ( + <> + + + + + ) + })()} + + ) +} diff --git a/apps/mobile/src/app/navigation/navigationRef.tsx b/apps/mobile/src/app/navigation/navigationRef.tsx new file mode 100644 index 00000000..9cec78bf --- /dev/null +++ b/apps/mobile/src/app/navigation/navigationRef.tsx @@ -0,0 +1,8 @@ +import { createNavigationContainerRef } from '@react-navigation/native' +import { ExploreStackParamList, FiatOnRampStackParamList, RootParamList } from 'src/app/navigation/types' + +// this was moved to its own file to avoid circular dependencies +export const navigationRef = createNavigationContainerRef() +export const exploreNavigationRef = createNavigationContainerRef() +export const fiatOnRampNavigationRef = createNavigationContainerRef() +export const navRefs = [exploreNavigationRef, fiatOnRampNavigationRef, navigationRef] diff --git a/apps/mobile/src/app/navigation/rootNavigation.ts b/apps/mobile/src/app/navigation/rootNavigation.ts new file mode 100644 index 00000000..de54be4a --- /dev/null +++ b/apps/mobile/src/app/navigation/rootNavigation.ts @@ -0,0 +1,40 @@ +import { NavigationAction, NavigationState } from '@react-navigation/core' +import { navigationRef } from 'src/app/navigation/navigationRef' +import { RootParamList } from 'src/app/navigation/types' +import { logger } from 'utilities/src/logger/logger' + +type RootNavigationArgs = undefined extends RootParamList[RouteName] + ? [RouteName] | [RouteName, RootParamList[RouteName]] + : [RouteName, RootParamList[RouteName]] + +function isNavigationRefReady(): boolean { + if (!navigationRef.isReady()) { + logger.error(new Error('Navigator was called before it was initialized'), { + tags: { file: 'rootNavigation', function: 'navigate' }, + }) + return false + } + return true +} + +export function navigate(...args: RootNavigationArgs): void { + const [routeName, params] = args + if (!isNavigationRefReady()) { + return + } + + // Type assignment to `any` is a workaround until we figure out how to + // type `createNavigationContainerRef` in a way that's compatible + // oxlint-disable-next-line typescript/no-explicit-any -- Navigation refs need flexible typing + navigationRef.navigate(routeName as any, params as never) +} + +export function dispatchNavigationAction( + action: NavigationAction | ((state: NavigationState) => NavigationAction), +): void { + if (!isNavigationRefReady()) { + return + } + + navigationRef.dispatch(action) +} diff --git a/apps/mobile/src/app/navigation/tabs/CustomTabBar/CustomTabBar.tsx b/apps/mobile/src/app/navigation/tabs/CustomTabBar/CustomTabBar.tsx new file mode 100644 index 00000000..d1b9cddd --- /dev/null +++ b/apps/mobile/src/app/navigation/tabs/CustomTabBar/CustomTabBar.tsx @@ -0,0 +1,175 @@ +import type { BottomTabBarProps } from '@react-navigation/bottom-tabs' +import { useState } from 'react' +import type { LayoutChangeEvent } from 'react-native' +import { useAnimatedStyle, useDerivedValue, withTiming } from 'react-native-reanimated' +import { TAB_BAR_ANIMATION_DURATION, TAB_ITEMS } from 'src/app/navigation/tabs/CustomTabBar/constants' +import { SwapButton } from 'src/app/navigation/tabs/SwapButton' +import { SwapLongPressOverlay } from 'src/app/navigation/tabs/SwapLongPressOverlay' +import { Flex, TouchableArea, useIsDarkMode, useSporeColors } from 'ui/src' +import { AnimatedFlex } from 'ui/src/components/layout/AnimatedFlex' +import { iconSizes, spacing } from 'ui/src/theme' +import { useHapticFeedback } from 'uniswap/src/features/settings/useHapticFeedback/useHapticFeedback' +import { useAppInsets } from 'uniswap/src/hooks/useAppInsets' +import { useEvent } from 'utilities/src/react/hooks' +import { useBooleanState } from 'utilities/src/react/useBooleanState' + +interface TabItemProps { + tab: (typeof TAB_ITEMS)[number] + index: number + isFocused: boolean + onPress: (index: number) => void + colors: ReturnType +} + +const tabShadowOffset = { width: 0, height: 1 } +const tabBarShadowOffset = { width: 0, height: 6 } + +const MARGIN = spacing.spacing4 +const ANIMATED_VIEW_BORDER_WIDTH = spacing.spacing1 / 2 + +const TabItem = ({ tab, index, isFocused, onPress, colors }: TabItemProps): JSX.Element => { + const IconComponent = tab.icon + + const handlePress = useEvent(() => { + onPress(index) + }) + + return ( + + + + ) +} + +export function CustomTabBar({ state, navigation }: BottomTabBarProps): JSX.Element { + const colors = useSporeColors() + const insets = useAppInsets() + const { value: isSwapMenuOpen, setTrue: handleOpenSwapMenu, setFalse: handleCloseSwapMenu } = useBooleanState(false) + const [containerWidth, setContainerWidth] = useState(0) + const isDarkMode = useIsDarkMode() + const { hapticFeedback } = useHapticFeedback() + + const TAB_WIDTH = containerWidth / TAB_ITEMS.length + const ANIMATED_VIEW_WIDTH = TAB_WIDTH - MARGIN * 2 - ANIMATED_VIEW_BORDER_WIDTH * 2 + + const activeTabIndex = useDerivedValue(() => { + return withTiming(state.index, { + duration: TAB_BAR_ANIMATION_DURATION, + }) + }) + + const animatedBackgroundStyle = useAnimatedStyle(() => { + const translateX = TAB_WIDTH * activeTabIndex.value + MARGIN - activeTabIndex.value * ANIMATED_VIEW_BORDER_WIDTH + + return { + transform: [{ translateX }], + } + }) + + const onLayout = useEvent((event: LayoutChangeEvent) => { + const { width } = event.nativeEvent.layout + setContainerWidth(width) + }) + + const handleTabPress = useEvent((index: number) => { + const route = state.routes[index] + + const event = navigation.emit({ + type: 'tabPress', + target: route?.key ?? '', + canPreventDefault: true, + }) + + if (!event.defaultPrevented && route) { + navigation.navigate(route.name) + } + + // Non-blocking haptic feedback + hapticFeedback.light().catch(() => { + // Silently ignore haptic feedback errors + }) + }) + + return ( + <> + + + {/* Main parent container for TabItems */} + + {/* Animated sliding background */} + {containerWidth > 0 && ( + + + + )} + + {TAB_ITEMS.map((tab, index): JSX.Element => { + const isFocused = state.index === index + + return ( + + ) + })} + + + + + + + + + ) +} diff --git a/apps/mobile/src/app/navigation/tabs/CustomTabBar/Icons.tsx b/apps/mobile/src/app/navigation/tabs/CustomTabBar/Icons.tsx new file mode 100644 index 00000000..e25105ba --- /dev/null +++ b/apps/mobile/src/app/navigation/tabs/CustomTabBar/Icons.tsx @@ -0,0 +1,51 @@ +import { Flex } from 'ui/src' +import { Home, Search, SearchFilled, TimePast } from 'ui/src/components/icons' +import { iconSizes } from 'ui/src/theme' +import { useSelectAddressHasNotifications } from 'uniswap/src/features/notifications/slice/hooks' +import { useActiveAccountAddress } from 'wallet/src/features/wallet/hooks' + +export const TabHomeIcon = ({ color, size }: { color: string; focused: boolean; size: number }): JSX.Element => ( + +) + +export const TabExploreIcon = ({ + color, + size, + focused, +}: { + color: string + focused: boolean + size: number +}): JSX.Element => { + if (focused) { + return + } + + return +} + +export const TabActivityIcon = ({ color, size }: { color: string; focused: boolean; size: number }): JSX.Element => { + const activeAccountAddress = useActiveAccountAddress() + const hasNotifications = useSelectAddressHasNotifications(activeAccountAddress) + + const icon = + + if (hasNotifications) { + return ( + + {icon} + + + ) + } + + return icon +} diff --git a/apps/mobile/src/app/navigation/tabs/CustomTabBar/constants.ts b/apps/mobile/src/app/navigation/tabs/CustomTabBar/constants.ts new file mode 100644 index 00000000..c20b4026 --- /dev/null +++ b/apps/mobile/src/app/navigation/tabs/CustomTabBar/constants.ts @@ -0,0 +1,12 @@ +import { TabActivityIcon, TabExploreIcon, TabHomeIcon } from 'src/app/navigation/tabs/CustomTabBar/Icons' +import { MobileScreens } from 'uniswap/src/types/screens/mobile' + +export const TAB_BAR_ANIMATION_DURATION = 200 + +export const TAB_ITEMS = [ + { key: MobileScreens.Home, icon: TabHomeIcon }, + { key: MobileScreens.Explore, icon: TabExploreIcon }, + { key: MobileScreens.Activity, icon: TabActivityIcon }, +] as const + +export const ESTIMATED_BOTTOM_TABS_HEIGHT = 64 diff --git a/apps/mobile/src/app/navigation/tabs/SwapButton.tsx b/apps/mobile/src/app/navigation/tabs/SwapButton.tsx new file mode 100644 index 00000000..23365eb4 --- /dev/null +++ b/apps/mobile/src/app/navigation/tabs/SwapButton.tsx @@ -0,0 +1,125 @@ +import React, { useRef } from 'react' +import { cancelAnimation, useAnimatedStyle, useSharedValue, withSpring } from 'react-native-reanimated' +import { useSelector } from 'react-redux' +import { useAppStackNavigation } from 'src/app/navigation/types' +import { AnimatedTouchableArea, useSporeColors } from 'ui/src' +import { SwapDotted } from 'ui/src/components/icons' +import { iconSizes, spacing } from 'ui/src/theme' +import { useEnabledChains } from 'uniswap/src/features/chains/hooks/useEnabledChains' +import { useHighestBalanceNativeCurrencyId } from 'uniswap/src/features/dataApi/balances/balances' +import { useHapticFeedback } from 'uniswap/src/features/settings/useHapticFeedback/useHapticFeedback' +import { ElementName, ModalName } from 'uniswap/src/features/telemetry/constants' +import Trace from 'uniswap/src/features/telemetry/Trace' +import { selectFilteredChainIds } from 'uniswap/src/features/transactions/swap/state/selectors' +import { prepareSwapFormState } from 'uniswap/src/features/transactions/types/transactionState' +import { CurrencyField } from 'uniswap/src/types/currency' +import { useEvent } from 'utilities/src/react/hooks' +import { useActiveAccountAddressWithThrow } from 'wallet/src/features/wallet/hooks' + +const ACTIVE_SCALE = 0.96 +const LONG_PRESS_HAPTIC_DELAY = 200 // ms - faster than default long press (usually 500ms) + +const shadowOffset = { width: 0, height: 6 } + +interface SwapButtonProps { + onLongPress: () => void + onClose?: () => void +} + +export function SwapButton({ onLongPress, onClose }: SwapButtonProps): JSX.Element { + const colors = useSporeColors() + const { defaultChainId } = useEnabledChains() + const { hapticFeedback } = useHapticFeedback() + const { navigate } = useAppStackNavigation() + + const longPressTimerRef = useRef(null) + const hasTriggeredLongPressHaptic = useRef(false) + + const activeAccountAddress = useActiveAccountAddressWithThrow() + const persistedFilteredChainIds = useSelector(selectFilteredChainIds) + const inputCurrencyId = useHighestBalanceNativeCurrencyId({ + evmAddress: activeAccountAddress, + chainId: persistedFilteredChainIds?.[CurrencyField.INPUT], + }) + + const onPress = useEvent(async () => { + // Close modal if onClose is provided + onClose?.() + + navigate( + ModalName.Swap, + prepareSwapFormState({ + inputCurrencyId, + defaultChainId, + filteredChainIdsOverride: persistedFilteredChainIds, + }), + ) + + // Only trigger light haptic if we haven't already triggered long press haptic + if (!hasTriggeredLongPressHaptic.current) { + await hapticFeedback.light() + } + }) + + const clearLongPressTimer = useEvent(() => { + if (longPressTimerRef.current) { + clearTimeout(longPressTimerRef.current) + longPressTimerRef.current = null + } + hasTriggeredLongPressHaptic.current = false + }) + + const scale = useSharedValue(1) + const animatedStyle = useAnimatedStyle(() => ({ transform: [{ scale: scale.value }] }), [scale]) + + const onPressIn = useEvent(() => { + cancelAnimation(scale) + scale.value = withSpring(ACTIVE_SCALE, { + damping: 15, + stiffness: 300, + }) + + // Use a timer to trigger haptic feedback so that it activates faster than the default long press + longPressTimerRef.current = setTimeout(async () => { + if (!hasTriggeredLongPressHaptic.current) { + await hapticFeedback.success() + hasTriggeredLongPressHaptic.current = true + } + }, LONG_PRESS_HAPTIC_DELAY) + }) + + const onPressOut = useEvent(() => { + scale.value = withSpring(1, { + damping: 15, + stiffness: 300, + }) + + // Clear the timer when press ends + clearLongPressTimer() + }) + + return ( + + + + + + ) +} diff --git a/apps/mobile/src/app/navigation/tabs/SwapLongPressOverlay.tsx b/apps/mobile/src/app/navigation/tabs/SwapLongPressOverlay.tsx new file mode 100644 index 00000000..9b36790f --- /dev/null +++ b/apps/mobile/src/app/navigation/tabs/SwapLongPressOverlay.tsx @@ -0,0 +1,256 @@ +import { FeatureFlags, useFeatureFlag } from '@universe/gating' +import { BlurView } from 'expo-blur' +import { type ReactNode, useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { StyleSheet, type ViewStyle } from 'react-native' +import Animated, { FadeIn, FadeOut } from 'react-native-reanimated' +import { useDispatch } from 'react-redux' +import { navigate } from 'src/app/navigation/rootNavigation' +import { ESTIMATED_BOTTOM_TABS_HEIGHT } from 'src/app/navigation/tabs/CustomTabBar/constants' +import { SwapButton } from 'src/app/navigation/tabs/SwapButton' +import { useOpenReceiveModal } from 'src/features/modals/hooks/useOpenReceiveModal' +import { openModal } from 'src/features/modals/modalSlice' +import { Flex, Text, TouchableArea, useIsDarkMode, useSporeColors } from 'ui/src' +import { Bank, Buy, ReceiveAlt, SendAction } from 'ui/src/components/icons' +import { iconSizes } from 'ui/src/theme' +import { useEnabledChains } from 'uniswap/src/features/chains/hooks/useEnabledChains' +import { MobileEventName, ModalName } from 'uniswap/src/features/telemetry/constants' +import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { useAppInsets } from 'uniswap/src/hooks/useAppInsets' +import { isAndroid } from 'utilities/src/platform' +import { useEvent } from 'utilities/src/react/hooks' + +const ANIMATION_DURATION = 200 +const BASE_DELAY = 40 + +const AnimatedBlurView = Animated.createAnimatedComponent(BlurView) + +function AnimatedContainer({ + enteringDelay, + exitingDelay, + children, + style, + exitingDuration = ANIMATION_DURATION, +}: { + enteringDelay: number + exitingDelay: number + children: ReactNode + style?: ViewStyle + exitingDuration?: number +}): JSX.Element { + return ( + + {children} + + ) +} + +const styles = StyleSheet.create({ + blurView: { + height: '100%', + left: 0, + position: 'absolute', + top: 0, + width: '100%', + }, +}) +interface SwapLongPressOverlayProps { + isVisible: boolean + onClose: () => void + onSwapLongPress: () => void +} + +export function SwapLongPressOverlay({ + isVisible, + onClose, + onSwapLongPress, +}: SwapLongPressOverlayProps): JSX.Element | null { + const colors = useSporeColors() + const isDarkMode = useIsDarkMode() + const insets = useAppInsets() + const dispatch = useDispatch() + const { t } = useTranslation() + + const openReceiveModal = useOpenReceiveModal() + const { isTestnetModeEnabled } = useEnabledChains() + const disableForKorea = useFeatureFlag(FeatureFlags.DisableFiatOnRampKorea) + + const onBuyPress = useEvent(async (): Promise => { + sendAnalyticsEvent(MobileEventName.SwapLongPress, { element: 'buy' }) + + if (isTestnetModeEnabled) { + navigate(ModalName.TestnetMode, { + unsupported: true, + descriptionCopy: t('tdp.noTestnetSupportDescription'), + }) + return + } + disableForKorea + ? navigate(ModalName.KoreaCexTransferInfoModal) + : dispatch( + openModal({ + name: ModalName.FiatOnRampAggregator, + }), + ) + + onClose() + }) + + const onSellPress = useEvent(() => { + sendAnalyticsEvent(MobileEventName.SwapLongPress, { element: 'sell' }) + + if (isTestnetModeEnabled) { + navigate(ModalName.TestnetMode, { + unsupported: true, + descriptionCopy: t('tdp.noTestnetSupportDescription'), + }) + return + } + disableForKorea + ? navigate(ModalName.KoreaCexTransferInfoModal) + : dispatch( + openModal({ + name: ModalName.FiatOnRampAggregator, + initialState: { + isOfframp: true, + }, + }), + ) + + onClose() + }) + + const onReceivePress = useEvent(() => { + sendAnalyticsEvent(MobileEventName.SwapLongPress, { element: 'receive' }) + + openReceiveModal() + onClose() + }) + + const onSendPress = useEvent(() => { + sendAnalyticsEvent(MobileEventName.SwapLongPress, { element: 'send' }) + + dispatch(openModal({ name: ModalName.Send })) + onClose() + }) + + const swapMenuItems: { title: string; onPress: () => void; icon: ReactNode }[] = useMemo( + () => [ + { + title: t('common.button.receive'), + onPress: onReceivePress, + icon: , + }, + { + title: t('common.button.send'), + onPress: onSendPress, + icon: , + }, + { + title: t('common.button.buy'), + onPress: onBuyPress, + icon: , + }, + { + title: t('common.button.sell'), + onPress: onSellPress, + icon: , + }, + ], + [t, onReceivePress, onSendPress, onBuyPress, onSellPress, colors.accent1.val], + ) + + const NUM_OF_SWAP_MENU_ITEMS = swapMenuItems.length + const TOTAL_DELAY_FOR_EXIT_FROM_MENU_ITEMS = NUM_OF_SWAP_MENU_ITEMS * BASE_DELAY + + // Used for main container and SwapButton + const DELAY_FOR_MAIN_EXIT = TOTAL_DELAY_FOR_EXIT_FROM_MENU_ITEMS + ANIMATION_DURATION / 2 + + // We want to start animating the text out just before the main animation starts + const DELAY_FOR_SWAP_TEXT_EXIT = DELAY_FOR_MAIN_EXIT - ANIMATION_DURATION / 4 + + if (!isVisible) { + return null + } + + return ( + + + + {swapMenuItems.map((item, i) => { + const length = NUM_OF_SWAP_MENU_ITEMS + + // Wait for initial animation to complete, then animate each item in sequentially with a delay based on its position in the array. + const enteringDelay = ANIMATION_DURATION + (length - i) * BASE_DELAY + + // We want to start animating before the main animation starts + const exitingDelay = i * BASE_DELAY + + return ( + + + + ) + })} + + {/* Swap Button as last item in the column */} + + {/* We want to delay animating the text entering so the Swap button shows first, then the text animates in, followed by the actions + + Text animates out at the same time as the Swap button, so it looks like the text is animating in while the Swap button is animating out. + */} + + + {t('common.button.swap')} + + + {/* Swap Button doesn't animate in so it feels like the same button that was long pressed on HomeScreen is still there. It is the final thing to animate out (along with the Text above). */} + + + + + + + + ) +} + +function MenuItem({ title, icon, onPress }: { title: string; icon: ReactNode; onPress: () => void }): JSX.Element { + return ( + + + + {title} + + + {icon} + + + + ) +} diff --git a/apps/mobile/src/app/navigation/tabs/TabsNavigator.tsx b/apps/mobile/src/app/navigation/tabs/TabsNavigator.tsx new file mode 100644 index 00000000..8055f8ae --- /dev/null +++ b/apps/mobile/src/app/navigation/tabs/TabsNavigator.tsx @@ -0,0 +1,28 @@ +import type { BottomTabBarProps } from '@react-navigation/bottom-tabs' +import { createBottomTabNavigator } from '@react-navigation/bottom-tabs' +import { CustomTabBar } from 'src/app/navigation/tabs/CustomTabBar/CustomTabBar' +import { ActivityScreen } from 'src/screens/ActivityScreen' +import { ExploreScreen } from 'src/screens/ExploreScreen' +import { WrappedHomeScreen } from 'src/screens/HomeScreen/HomeScreen' +import { MobileScreens } from 'uniswap/src/types/screens/mobile' + +const Tab = createBottomTabNavigator() + +const WrappedCustomTabBar = (props: BottomTabBarProps): JSX.Element => + +export const TabsNavigator = (): JSX.Element => { + return ( + + + + + + ) +} diff --git a/apps/mobile/src/app/navigation/trackingHelpers.ts b/apps/mobile/src/app/navigation/trackingHelpers.ts new file mode 100644 index 00000000..61c5e480 --- /dev/null +++ b/apps/mobile/src/app/navigation/trackingHelpers.ts @@ -0,0 +1,42 @@ +import { DdRumReactNavigationTracking } from '@datadog/mobile-react-navigation' +import { NavigationContainerRefWithCurrent } from '@react-navigation/core' +import { NavigationState } from '@react-navigation/native' +import { navigationRef, navRefs } from 'src/app/navigation/navigationRef' +import { datadogEnabledBuild } from 'utilities/src/environment/constants' + +/** + * Since we are using multiple navigation containers, we need to start and stop tracking views + * manually since multiple nav containers are not supported by the Datadog RUM. + * + * https://docs.datadoghq.com/real_user_monitoring/mobile_and_tv_monitoring/integrated_libraries/reactnative/#track-view-navigation + */ +export const startTracking = ( + navRefToStartTracking: NavigationContainerRefWithCurrent, +): void => { + if (!datadogEnabledBuild) { + return + } + navRefs.forEach((navRef) => { + DdRumReactNavigationTracking.stopTrackingViews(navRef.current) + }) + DdRumReactNavigationTracking.startTrackingViews(navRefToStartTracking.current) +} + +/** + * Since we are using multiple navigation containers, we need to start and stop tracking views + * manually since multiple nav containers are not supported by the Datadog RUM. + * + * https://docs.datadoghq.com/real_user_monitoring/mobile_and_tv_monitoring/integrated_libraries/reactnative/#track-view-navigation + */ +export const stopTracking = (state: NavigationState | undefined): void => { + if (!datadogEnabledBuild) { + return + } + const navContainerIsClosing = !state || state.routes.length === 0 + if (navContainerIsClosing) { + navRefs.forEach((navRef) => { + DdRumReactNavigationTracking.stopTrackingViews(navRef.current) + }) + DdRumReactNavigationTracking.startTrackingViews(navigationRef.current) + } +} diff --git a/apps/mobile/src/app/navigation/types.ts b/apps/mobile/src/app/navigation/types.ts new file mode 100644 index 00000000..39715fdf --- /dev/null +++ b/apps/mobile/src/app/navigation/types.ts @@ -0,0 +1,272 @@ +import { + CompositeNavigationProp, + CompositeScreenProps, + NavigatorScreenParams, + useNavigation, +} from '@react-navigation/native' +import { NativeStackNavigationProp, NativeStackScreenProps } from '@react-navigation/native-stack' +import { TokenWarningModalState } from 'src/app/modals/TokenWarningModalState' +import { RemoveWalletModalState } from 'src/components/RemoveWallet/RemoveWalletModalState' +import { RestoreWalletModalState, WalletRestoreType } from 'src/components/RestoreWalletModal/RestoreWalletModalState' +import { ConnectionsDappsListModalState } from 'src/components/Settings/ConnectionsDappModal/ConnectionsDappsListModalState' +import { EditWalletSettingsModalState } from 'src/components/Settings/EditWalletModal/EditWalletSettingsModalState' +import { ManageWalletsModalState } from 'src/components/Settings/ManageWalletsModalState' +import { BuyNativeTokenModalState } from 'src/components/TokenDetails/BuyNativeTokenModalState' +import { UnitagsIntroModalState } from 'src/components/unitags/UnitagsIntroModalState' +import { CloudStorageMnemonicBackup } from 'src/features/CloudBackup/types' +import { ScantasticModalState } from 'src/features/scantastic/ScantasticModalState' +import { TestnetSwitchModalState } from 'src/features/testnetMode/TestnetSwitchModalState' +import { HomeScreenTabIndex } from 'src/screens/HomeScreen/HomeScreenTabIndex' +import { ReceiveCryptoModalState } from 'src/screens/ReceiveCryptoModalState' +import { ViewPrivateKeysScreenState } from 'src/screens/ViewPrivateKeys/ViewPrivateKeysScreenState' +import { BridgedAssetModalProps } from 'uniswap/src/components/BridgedAsset/BridgedAssetModal' +import { WormholeModalProps } from 'uniswap/src/components/BridgedAsset/WormholeModal' +import { ReportPortfolioDataModalProps } from 'uniswap/src/components/reporting/ReportPortfolioDataModal' +import { ReportTokenDataModalProps } from 'uniswap/src/components/reporting/ReportTokenDataModal' +import { ReportTokenModalProps } from 'uniswap/src/components/reporting/ReportTokenIssueModal' +import { UniverseChainId } from 'uniswap/src/features/chains/types' +import { FORServiceProvider } from 'uniswap/src/features/fiatOnRamp/types' +import { PasskeyManagementModalState } from 'uniswap/src/features/passkey/PasskeyManagementModal' +import { ModalName } from 'uniswap/src/features/telemetry/constants' +import { TestnetModeModalState } from 'uniswap/src/features/testnets/TestnetModeModal' +import { TransactionState } from 'uniswap/src/features/transactions/types/transactionState' +import { ImportType, OnboardingEntryPoint } from 'uniswap/src/types/onboarding' +import { + FiatOnRampScreens, + MobileScreens, + OnboardingScreens, + SharedUnitagScreenParams, + UnitagStackParamList, +} from 'uniswap/src/types/screens/mobile' +import { SmartWalletAdvancedSettingsModalState } from 'wallet/src/components/smartWallet/modals/SmartWalletAdvancedSettingsModal' +import { SmartWalletEnabledModalState } from 'wallet/src/components/smartWallet/modals/SmartWalletEnabledModal' +import { SmartWalletNudgeState } from 'wallet/src/components/smartWallet/modals/SmartWalletNudge' +import { ExploreOrderBy } from 'wallet/src/features/wallet/types' + +export type ExploreScreenParams = { + showFavorites?: boolean + orderByMetric?: ExploreOrderBy + chainId?: UniverseChainId +} + +type BackupFormParams = { + address: Address +} + +type CloudBackupFormParams = { + address: Address + password: string +} + +type PasskeyImportParams = { + passkeyCredential: string +} + +export type ExploreStackParamList = { + [MobileScreens.Explore]: ExploreScreenParams + [MobileScreens.ExternalProfile]: { + address: string + } + [MobileScreens.TokenDetails]: { + currencyId: string + } +} + +// The ExploreModalState allows a Screen and its Params to be defined. +// This workaround facilitates navigation to any screen within the ExploreStack from outside. +export type ExploreModalState = { + [V in keyof ExploreStackParamList]: { screen: V; params: ExploreStackParamList[V] } +}[keyof ExploreStackParamList] + +export type FiatOnRampStackParamList = { + [FiatOnRampScreens.AmountInput]: undefined + [FiatOnRampScreens.ServiceProviders]: undefined + [FiatOnRampScreens.Connecting]: undefined +} + +export type SettingsStackParamList = { + [MobileScreens.DebugScreens]: undefined + [MobileScreens.Dev]: undefined + [MobileScreens.Settings]: undefined + [MobileScreens.SettingsCloudBackupPasswordConfirm]: CloudBackupFormParams + [MobileScreens.SettingsCloudBackupPasswordCreate]: { address: Address } + [MobileScreens.SettingsCloudBackupProcessing]: CloudBackupFormParams + [MobileScreens.SettingsCloudBackupStatus]: { address: Address } + [MobileScreens.SettingsHelpCenter]: undefined + [MobileScreens.SettingsLanguage]: undefined + [MobileScreens.SettingsNotifications]: undefined + [MobileScreens.SettingsPrivacy]: undefined + [MobileScreens.SettingsSmartWallet]: undefined + [MobileScreens.SettingsStorage]: undefined + [MobileScreens.SettingsViewSeedPhrase]: { address?: Address; walletNeedsRestore?: boolean } | undefined + [MobileScreens.SettingsWallet]: { address: Address } + [MobileScreens.SettingsWalletEdit]: { address: Address } + [MobileScreens.SettingsWalletManageConnection]: { address: Address } + [MobileScreens.ViewPrivateKeys]?: ViewPrivateKeysScreenState + [MobileScreens.WebView]: { headerTitle: string; uriLink: string } + [ModalName.Experiments]: undefined + [ModalName.NotificationsOSSettings]: undefined + [ModalName.UnitagsIntro]: UnitagsIntroModalState + [ModalName.RestoreWallet]: RestoreWalletModalState +} + +export type OnboardingStackBaseParams = { + importType: ImportType + entryPoint: OnboardingEntryPoint + restoreType?: WalletRestoreType +} + +export type OnboardingStackParamList = { + [OnboardingScreens.AppLoading]: undefined + [OnboardingScreens.BackupManual]: BackupFormParams & OnboardingStackBaseParams & { fromCloudBackup?: boolean } + [OnboardingScreens.BackupCloudPasswordCreate]: BackupFormParams & OnboardingStackBaseParams + [OnboardingScreens.BackupCloudPasswordConfirm]: CloudBackupFormParams & OnboardingStackBaseParams + [OnboardingScreens.BackupCloudProcessing]: CloudBackupFormParams & OnboardingStackBaseParams + [OnboardingScreens.Backup]: OnboardingStackBaseParams + [OnboardingScreens.Landing]: OnboardingStackBaseParams + [OnboardingScreens.Notifications]: OnboardingStackBaseParams + [OnboardingScreens.WelcomeWallet]: OnboardingStackBaseParams + [OnboardingScreens.PasskeyImport]: PasskeyImportParams & OnboardingStackBaseParams + [OnboardingScreens.Security]: OnboardingStackBaseParams + [MobileScreens.ViewPrivateKeys]?: ViewPrivateKeysScreenState + + // import + [OnboardingScreens.ImportMethod]: OnboardingStackBaseParams + [OnboardingScreens.RestoreMethod]: OnboardingStackBaseParams + [OnboardingScreens.OnDeviceRecovery]: OnboardingStackBaseParams & { mnemonicIds: Address[] } + [OnboardingScreens.OnDeviceRecoveryViewSeedPhrase]: { + mnemonicId: string + } & OnboardingStackBaseParams + [OnboardingScreens.RestoreCloudBackupLoading]: OnboardingStackBaseParams + [OnboardingScreens.RestoreCloudBackup]: { + backups: CloudStorageMnemonicBackup[] + } & OnboardingStackBaseParams + [OnboardingScreens.RestoreCloudBackupPassword]: { + mnemonicId: string + } & OnboardingStackBaseParams + [OnboardingScreens.SeedPhraseInput]: OnboardingStackBaseParams & { + showAsCloudBackupFallback?: boolean + } + [OnboardingScreens.SelectWallet]: OnboardingStackBaseParams + [OnboardingScreens.WatchWallet]: OnboardingStackBaseParams + [ModalName.PrivateKeySpeedBumpModal]: undefined +} & SharedUnitagScreenParams + +export type AppStackParamList = { + [MobileScreens.Activity]: undefined + [MobileScreens.HashcashBenchmark]: undefined + [MobileScreens.SessionsDebug]: undefined + [MobileScreens.Education]: { + type: EducationContentType + } & OnboardingStackBaseParams + [MobileScreens.Home]?: { tab?: HomeScreenTabIndex } + [MobileScreens.OnboardingStack]: NavigatorScreenParams + [MobileScreens.SettingsStack]: NavigatorScreenParams + [MobileScreens.UnitagStack]: NavigatorScreenParams + [MobileScreens.TokenDetails]: { + currencyId: string + } + [MobileScreens.ExternalProfile]: { + address: string + } + [MobileScreens.ViewPrivateKeys]?: ViewPrivateKeysScreenState + [MobileScreens.WebView]: { headerTitle: string; uriLink: string } + [MobileScreens.Storybook]: undefined + [ModalName.Swap]: TransactionState | undefined + [ModalName.Explore]: ExploreModalState | undefined + [ModalName.NotificationsOSSettings]: undefined + [ModalName.FiatOnRampAction]: { entry: 'onramp' | 'offramp' } + [ModalName.FundWallet]: undefined + [ModalName.KoreaCexTransferInfoModal]: undefined + [ModalName.ExchangeTransferModal]: { initialState: { serviceProvider: FORServiceProvider } } + [ModalName.Experiments]: undefined + [ModalName.TestnetSwitchModal]: TestnetSwitchModalState + [ModalName.TokenWarning]: { initialState?: TokenWarningModalState } + [ModalName.BridgedAssetNav]: { initialState?: TokenWarningModalState } + [ModalName.ViewOnlyExplainer]: undefined + [ModalName.UnitagsIntro]: UnitagsIntroModalState + [ModalName.RestoreWallet]: RestoreWalletModalState + [ModalName.AccountSwitcher]: undefined + [ModalName.Scantastic]: ScantasticModalState + [ModalName.BackupReminder]: undefined + [ModalName.BackupReminderWarning]: undefined + [ModalName.RemoveWallet]: RemoveWalletModalState | undefined + [ModalName.ReceiveCryptoModal]: ReceiveCryptoModalState + [ModalName.TestnetMode]: TestnetModeModalState + [ModalName.BuyNativeToken]: BuyNativeTokenModalState + [ModalName.HiddenTokenInfoModal]: undefined + [ModalName.ScreenshotWarning]: { acknowledgeText?: string } | undefined + [ModalName.PasskeyManagement]: PasskeyManagementModalState + [ModalName.PasskeysHelp]: undefined + [ModalName.BiometricsModal]: undefined + [ModalName.FiatCurrencySelector]: undefined + [ModalName.ManageWalletsModal]: ManageWalletsModalState + [ModalName.EditLabelSettingsModal]: EditWalletSettingsModalState + [ModalName.EditProfileSettingsModal]: EditWalletSettingsModalState + [ModalName.ConnectionsDappListModal]: ConnectionsDappsListModalState + [ModalName.SmartWalletEnabledModal]: SmartWalletEnabledModalState + [ModalName.SmartWalletAdvancedSettingsModal]: SmartWalletAdvancedSettingsModalState + [ModalName.PrivateKeySpeedBumpModal]: undefined + [ModalName.SmartWalletNudge]: SmartWalletNudgeState + [ModalName.SettingsAppearance]: undefined + [ModalName.PermissionsModal]: undefined + [ModalName.PortfolioBalanceModal]: undefined + [ModalName.LanguageSelector]: undefined + [ModalName.SmartWalletInfoModal]: undefined + [ModalName.ConfirmDisableSmartWalletScreen]: undefined + [ModalName.BridgedAsset]: BridgedAssetModalProps + [ModalName.Wormhole]: WormholeModalProps + [ModalName.ReportTokenIssue]: ReportTokenModalProps + [ModalName.ReportPortfolioData]: ReportPortfolioDataModalProps + [ModalName.ReportTokenData]: ReportTokenDataModalProps +} + +export type AppStackNavigationProp = NativeStackNavigationProp +type AppStackScreenProps = NativeStackScreenProps +export type AppStackScreenProp = NativeStackScreenProps< + AppStackParamList, + Screen +> + +type ExploreStackNavigationProp = CompositeNavigationProp< + NativeStackNavigationProp, + AppStackNavigationProp +> + +export type SettingsStackNavigationProp = CompositeNavigationProp< + NativeStackNavigationProp, + AppStackNavigationProp +> + +export type SettingsStackScreenProp = CompositeScreenProps< + NativeStackScreenProps, + AppStackScreenProps +> + +export type OnboardingStackNavigationProp = CompositeNavigationProp< + NativeStackNavigationProp, + AppStackNavigationProp +> + +export type UnitagStackScreenProp = NativeStackScreenProps< + UnitagStackParamList, + Screen +> + +export type RootParamList = AppStackParamList & + ExploreStackParamList & + OnboardingStackParamList & + SettingsStackParamList & + UnitagStackParamList & + FiatOnRampStackParamList + +export enum EducationContentType { + SeedPhrase = 0, +} + +export const useAppStackNavigation = (): AppStackNavigationProp => useNavigation() +export const useExploreStackNavigation = (): ExploreStackNavigationProp => useNavigation() +export const useSettingsStackNavigation = (): SettingsStackNavigationProp => + useNavigation() +export const useOnboardingStackNavigation = (): OnboardingStackNavigationProp => + useNavigation() diff --git a/apps/mobile/src/app/saga.ts b/apps/mobile/src/app/saga.ts new file mode 100644 index 00000000..2e71b2b5 --- /dev/null +++ b/apps/mobile/src/app/saga.ts @@ -0,0 +1,63 @@ +import type { SagaIterator } from 'redux-saga' +import { monitoredSagas } from 'src/app/monitoredSagas' +import { appRatingWatcherSaga } from 'src/features/appRating/saga' +import { appStateSaga } from 'src/features/appState/appStateSaga' +import { biometricsSaga } from 'src/features/biometrics/biometricsSaga' +import { deepLinkWatcher } from 'src/features/deepLinking/handleDeepLinkSaga' +import { firebaseDataWatcher } from 'src/features/firebase/firebaseDataSaga' +import { lockScreenSaga } from 'src/features/lockScreen/lockScreenSaga' +import { pushNotificationsWatcherSaga } from 'src/features/notifications/saga' +import { splashScreenSaga } from 'src/features/splashScreen/splashScreenSaga' +import { telemetrySaga } from 'src/features/telemetry/saga' +import { restoreMnemonicCompleteWatcher } from 'src/features/wallet/saga' +import { walletConnectSaga } from 'src/features/walletConnect/saga' +import { signWcRequestSaga } from 'src/features/walletConnect/signWcRequestSaga' +import { call, fork, join, spawn } from 'typed-redux-saga' +import { waitForRehydration } from 'uniswap/src/utils/saga' +import { apolloClientRef } from 'wallet/src/data/apollo/usePersistedApolloClient' +import { transactionWatcher } from 'wallet/src/features/transactions/watcher/transactionWatcherSaga' + +// These sagas are not persisted, so we can run them before rehydration +const nonPersistedSagas = [appStateSaga, splashScreenSaga, biometricsSaga] + +// All regular sagas must be included here +const sagas = [ + lockScreenSaga, + appRatingWatcherSaga, + deepLinkWatcher, + firebaseDataWatcher, + pushNotificationsWatcherSaga, + restoreMnemonicCompleteWatcher, + signWcRequestSaga, + telemetrySaga, + walletConnectSaga, +] + +export function* rootMobileSaga(): SagaIterator { + // Start non-persisted sagas + for (const s of nonPersistedSagas) { + yield* spawn(s) + } + + // Fork the rehydration process to run in parallel + const rehydrationTask = yield* fork(waitForRehydration) + + // Initialize Apollo client in parallel + const apolloClient = yield* call(apolloClientRef.onReady) + + // Wait for rehydration to complete + yield* join(rehydrationTask) + + // Start regular sagas after rehydration is complete + for (const s of sagas) { + yield* spawn(s) + } + + // Start transaction watcher with Apollo client + yield* spawn(transactionWatcher, { apolloClient }) + + // Start monitored sagas + for (const m of Object.values(monitoredSagas)) { + yield* spawn(m['wrappedSaga']) + } +} diff --git a/apps/mobile/src/app/schema.ts b/apps/mobile/src/app/schema.ts new file mode 100644 index 00000000..198d48b9 --- /dev/null +++ b/apps/mobile/src/app/schema.ts @@ -0,0 +1,740 @@ +/* oxlint-disable max-lines */ +import { RankingType } from '@universe/api' +import { FiatCurrency } from 'uniswap/src/features/fiatCurrency/constants' +import { Language } from 'uniswap/src/features/language/constants' +import { ModalName } from 'uniswap/src/features/telemetry/constants' +import { SwapProtectionSetting } from 'wallet/src/features/wallet/slice' + +// only add fields that are persisted +export const initialSchema = { + balances: { + byChainId: {}, + }, + chains: { + byChainId: { + '1': { isActive: true }, + '10': { isActive: true }, + '137': { isActive: true }, + '42161': { isActive: true }, + }, + }, + favorites: { + tokens: [], + followedAddresses: [], + }, + notifications: { + notificationQueue: [], + notificationCount: {}, + }, + providers: { + isInitialized: false, + }, + saga: {}, + telemetry: { + lastBalancesReport: 0, + lastBalancesReportValue: 0, + }, + tokenLists: { + lastInitializedDefaultListOfLists: [], + byUrl: [], + activeListUrls: [], + }, + tokens: { + watchedTokens: {}, + customTokens: {}, + tokenPairs: {}, + dismissedWarningTokens: {}, + }, + transactions: { + byChainId: {}, + lastTxHistoryUpdate: {}, + }, + wallet: { + accounts: {}, + activeAccountAddress: null, + bluetooth: false, + flashbotsEnabled: false, + hardwareDevices: [], + isUnlocked: false, + }, + walletConnect: { + byAccount: {}, + pendingRequests: [], + modalState: 0, + }, +} + +const v0Schema = { + ...initialSchema, + transactions: {}, + notifications: { + ...initialSchema.notifications, + lastTxNotificationUpdate: {}, + }, +} + +export const v1Schema = { + ...v0Schema, + walletConnect: { + byAccount: {}, + pendingRequests: [], + }, + modals: { + [ModalName.WalletConnectScan]: { + isOpen: false, + initialState: 0, + }, + [ModalName.Swap]: { + isOpen: false, + initialState: undefined, + }, + [ModalName.Send]: { + isOpen: false, + initialState: undefined, + }, + }, +} + +export const v2Schema = { + ...v1Schema, + favorites: { + ...v1Schema.favorites, + followedAddresses: undefined, + watchedAddresses: [], + }, +} + +export const v3Schema = { + ...v2Schema, + searchHistory: { + results: [], + }, +} + +export const v4Schema = { + ...v3Schema, +} + +// oxlint-disable-next-line no-unused-vars -- Destructuring for schema migration +const { balances, ...restV4Schema } = v4Schema +delete restV4Schema.favorites.followedAddresses + +// adding in missed properties +export const v5Schema = { ...restV4Schema } + +const v5IntermediateSchema = { + ...v5Schema, + wallet: { + ...v5Schema.wallet, + bluetooth: undefined, + }, +} + +delete v5IntermediateSchema.wallet.bluetooth + +export const v6Schema = { + ...v5IntermediateSchema, + walletConnect: { ...v5IntermediateSchema.walletConnect, pendingSession: null }, + wallet: { + ...v5IntermediateSchema.wallet, + settings: {}, + }, +} + +export const v7Schema = { ...v6Schema } + +export const v8Schema = { + ...v7Schema, + cloudBackup: { + backupsFound: [], + }, +} +// schema did not change, but we removed private key wallets +export const v9Schema = { ...v8Schema } + +// schema did not change, removed the demo account +export const v10Schema = { ...v9Schema } + +export const v11Schema = { + ...v10Schema, + biometricSettings: { requiredForAppAccess: false, requiredForTransactions: false }, +} + +// schema did not change, added `pushNotificationsEnabled` prop to the Account type +export const v12Schema = { ...v11Schema } + +export const v13Schema = { ...v12Schema, ens: { ensForAddress: {} } } + +export const v14Schema = { ...v13Schema } + +export const v15Schema = { ...v14Schema } + +export const v16Schema = { ...v15Schema } + +export const v17Schema = { ...v16Schema } + +export const v18Schema = { ...v17Schema } + +export const v19Schema = { ...v18Schema } + +export const v20Schema = { ...v19Schema } + +export const v21Schema = { ...v20Schema, experiments: { experiments: {}, featureFlags: {} } } + +export const v22Schema = { ...v21Schema } + +// schema did not change, updated the types of `wallet.settings.tokensOrderBy` and `wallet.settings.tokensMetadataDisplayType` +export const v23Schema = { ...v22Schema } + +export const v24Schema = { + ...v23Schema, + notifications: { + notificationQueue: [], + notificationStatus: {}, + lastTxNotificationUpdate: {}, + }, +} + +export const v25Schema = { ...v24Schema, passwordLockout: { passwordAttempts: 0 } } + +export const v26Schema = { ...v25Schema } + +export const v27Schema = { ...v26Schema } + +export const v28Schema = { ...v27Schema } + +export const v29Schema = { ...v28Schema } + +const v30Schema = { ...v29Schema } + +// oxlint-disable-next-line no-unused-vars -- Destructuring for schema migration +const { tokenLists, ...v31SchemaIntermediate } = { ...v30Schema } +export const v31Schema = v31SchemaIntermediate + +export const v32Schema = { ...v31Schema } + +export const v33Schema = { + ...v32Schema, + wallet: { + ...v32Schema.wallet, + replaceAccountOptions: { + isReplacingAccount: false, + skipToSeedPhrase: false, + }, + }, +} + +export const v34Schema = { + ...v33Schema, + telemetry: { + lastBalancesReport: 0, + }, +} + +export const v35Schema = { + ...v34Schema, + appearanceSettings: { + selectedAppearanceSettings: 'system', + }, +} + +export const v36Schema = { + ...v35Schema, + favorites: { + ...v35Schema.favorites, + hiddenNfts: {}, + }, +} + +export const v37Schema = { ...v36Schema } + +const v37SchemaIntermediate = { + ...v37Schema, + wallet: { + ...v37Schema.wallet, + replaceAccountOptions: undefined, + }, +} +delete v37SchemaIntermediate.wallet.replaceAccountOptions + +export const v38Schema = { ...v37SchemaIntermediate } + +const v38SchemaIntermediate = { + ...v38Schema, + experiments: undefined, +} +delete v38SchemaIntermediate.experiments + +export const v39Schema = { ...v38SchemaIntermediate } + +// oxlint-disable-next-line no-unused-vars -- walletConnect removed in schema migration +const { walletConnect, ...v39SchemaIntermediate } = { ...v39Schema } + +export const v40Schema = { ...v39SchemaIntermediate } + +export const v41Schema = { + ...v40Schema, + telemetry: { + ...v40Schema.telemetry, + lastBalancesReportValue: 0, + }, +} + +export const v42Schema = { + ...v41Schema, + wallet: { ...v41Schema.wallet, flashbotsEnabled: undefined }, +} +delete v42Schema.wallet.flashbotsEnabled + +export const v43Schema = { + ...v42Schema, + favorites: { + ...v42Schema.favorites, + nftsData: {}, + hiddenNfts: undefined, + }, +} +delete v43Schema.favorites.hiddenNfts + +export const v44Schema = { + ...v43Schema, +} + +export const v45Schema = { + ...v44Schema, + favorites: { + ...v44Schema.favorites, + tokensVisibility: {}, + }, +} + +const v45SchemaIntermediate = { + ...v45Schema, + ENS: undefined, + ens: undefined, + gasApi: undefined, + onChainBalanceApi: undefined, + routingApi: undefined, + trmApi: undefined, +} + +delete v45SchemaIntermediate.ENS +delete v45SchemaIntermediate.ens +delete v45SchemaIntermediate.gasApi +delete v45SchemaIntermediate.onChainBalanceApi +delete v45SchemaIntermediate.routingApi +delete v45SchemaIntermediate.trmApi + +export const v46Schema = { ...v45SchemaIntermediate } + +// Remove reliance on env var config.activeChains +export const v47Schema = { + ...v46Schema, + chains: { + byChainId: { + '1': { isActive: true }, + '10': { isActive: true }, + '56': { isActive: true }, + '137': { isActive: true }, + '8453': { isActive: true }, + '42161': { isActive: true }, + }, + }, +} + +export const v48Schema = { ...v46Schema, tweaks: {} } + +export const v49Schema = { + ...v48Schema, + wallet: { + ...v48Schema.wallet, + settings: { + ...v48Schema.wallet.settings, + swapProtection: SwapProtectionSetting.On, + }, + }, +} + +const v50SchemaIntermediate = { ...v49Schema, chains: undefined } +delete v50SchemaIntermediate.chains +export const v50Schema = { ...v50SchemaIntermediate } + +export const v51Schema = { + ...v50Schema, + modals: { + ...v50Schema.modals, + 'language-selector': { + isOpen: false, + initialState: undefined, + }, + }, + languageSettings: { currentLanguage: Language.English }, +} + +export const v52Schema = { + ...v51Schema, + modals: { + ...v51Schema.modals, + [ModalName.FiatCurrencySelector]: { + isOpen: false, + initialState: undefined, + }, + }, + fiatCurrencySettings: { currentCurrency: FiatCurrency.UnitedStatesDollar }, +} + +const v53SchemaIntermediate = { + ...v52Schema, + languageSettings: { currentLanguage: Language.English }, + modals: { ...v52Schema.modals, 'language-selector': undefined }, +} +delete v53SchemaIntermediate.modals['language-selector'] + +export const v53Schema = v53SchemaIntermediate + +export const v54Schema = { + ...v53Schema, + telemetry: { + ...v53Schema.telemetry, + walletIsFunded: false, + }, +} + +export const v55Schema = { + ...v54Schema, + behaviorHistory: { + hasViewedReviewScreen: false, + hasSubmittedHoldToSwap: false, + }, +} + +export const v56Schema = { + ...v55Schema, + telemetry: { + ...v55Schema.telemetry, + allowAnalytics: true, + lastHeartbeat: 0, + }, +} + +export const v57Schema = { + ...v56Schema, + wallet: { + ...v56Schema.wallet, + settings: { + ...v56Schema.wallet.settings, + hideSmallBalances: true, + hideSpamTokens: true, + }, + }, +} + +export const v58Schema = { + ...v57Schema, + behaviorHistory: { + ...v57Schema.behaviorHistory, + hasSkippedUnitagPrompt: false, + }, +} + +export const v59Schema = { + ...v58Schema, + behaviorHistory: { + ...v58Schema.behaviorHistory, + hasCompletedUnitagsIntroModal: false, + }, +} + +export const v60Schema = { + ...v59Schema, + behaviorHistory: { + ...v59Schema.behaviorHistory, + hasViewedUniconV2IntroModal: false, + }, +} + +const v61SchemaIntermediate = { + ...v60Schema, + favorites: { ...v60Schema.favorites, nftsData: undefined }, +} + +delete v61SchemaIntermediate.favorites.nftsData + +export const v61Schema = { + ...v61SchemaIntermediate, + favorites: { + ...v61SchemaIntermediate.favorites, + nftsVisibility: {}, + }, +} + +export const v62Schema = { + ...v61Schema, + behaviorHistory: { + ...v61Schema.behaviorHistory, + // Removed in schema 69 + extensionOnboardingState: 'Undefined', + }, +} + +const v63SchemaIntermediate = { + ...v62Schema, + wallet: { + ...v62Schema.wallet, + isUnlocked: undefined, + }, +} + +// We will no longer keep track of this in the redux state. +delete v63SchemaIntermediate.wallet.isUnlocked + +export const v63Schema = v63SchemaIntermediate + +const v64SchemaIntermediate = { + ...v63Schema, + behaviorHistory: { + ...v63Schema.behaviorHistory, + hasViewedUniconV2IntroModal: undefined, + }, +} + +delete v64SchemaIntermediate.behaviorHistory.hasViewedUniconV2IntroModal + +export const v64Schema = v64SchemaIntermediate + +export const v65Schema = { ...v64Schema } + +export const v66Schema = { ...v65Schema } + +export const v67Schema = { ...v66Schema } + +const v68SchemaIntermediate = { + ...v67Schema, + behaviorHistory: { + ...v67Schema.behaviorHistory, + extensionBetaFeedbackState: undefined, + }, +} + +delete v68SchemaIntermediate.behaviorHistory.extensionBetaFeedbackState + +export const v68Schema = v68SchemaIntermediate + +const v69SchemaIntermediate = { + ...v68Schema, + behaviorHistory: { + ...v68Schema.behaviorHistory, + extensionOnboardingState: undefined, + }, +} +delete v69SchemaIntermediate.behaviorHistory.extensionOnboardingState +export const v69Schema = v69SchemaIntermediate + +export const v70Schema = { ...v69Schema } + +export const v71Schema = { + ...v70Schema, + appearanceSettings: { + ...v70Schema.appearanceSettings, + hapticsEnabled: true, + }, +} + +export const v72Schema = { + ...v71Schema, + behaviorHistory: { + ...v71Schema.behaviorHistory, + hasViewedWelcomeWalletCard: false, + hasUsedExplore: false, + }, +} + +export const v73Schema = { + ...v72Schema, + wallet: { + ...v72Schema.wallet, + settings: { + swapProtection: v72Schema.wallet.settings.swapProtection, + }, + }, + userSettings: { + hideSmallBalances: v72Schema.wallet.settings.hideSmallBalances, + hideSpamTokens: v72Schema.wallet.settings.hideSpamTokens, + }, +} + +export const v74Schema = { ...v73Schema } + +const v75SchemaIntermediate = { + ...v74Schema, + behaviorHistory: { + ...v74Schema.behaviorHistory, + hasViewedReviewScreen: undefined, + hasSubmittedHoldToSwap: undefined, + }, +} + +delete v75SchemaIntermediate.behaviorHistory.hasViewedReviewScreen +delete v75SchemaIntermediate.behaviorHistory.hasSubmittedHoldToSwap + +export const v75Schema = v75SchemaIntermediate + +export const v76Schema = { + ...v75Schema, + behaviorHistory: { + ...v75Schema.behaviorHistory, + createdOnboardingRedesignAccount: false, + }, +} + +export const v77Schema = { + ...v76Schema, + tokens: { + dismissedTokenWarnings: {}, + }, +} + +const v78SchemaIntermediate = { + ...v77Schema, + languageSettings: undefined, + userSettings: { + ...v77Schema.userSettings, + currentLanguage: v77Schema.languageSettings.currentLanguage, + }, +} +delete v78SchemaIntermediate.languageSettings +export const v78Schema = v78SchemaIntermediate + +const v79SchemaIntermediate = { + ...v78Schema, + fiatCurrencySettings: undefined, + userSettings: { + ...v78Schema.userSettings, + currentLanguage: v78Schema.fiatCurrencySettings.currentCurrency, + }, +} +delete v79SchemaIntermediate.fiatCurrencySettings +export const v79Schema = v79SchemaIntermediate + +export const v80Schema = { + ...v79Schema, + wallet: { + ...v79Schema.wallet, + settings: { + ...v79Schema.wallet.settings, + tokensOrderBy: RankingType.Volume, + }, + }, +} + +const v81SchemaIntermediate = { + ...v80Schema, + behaviorHistory: { + ...v80Schema.behaviorHistory, + createdOnboardingRedesignAccount: undefined, + }, +} +delete v81SchemaIntermediate.behaviorHistory.createdOnboardingRedesignAccount +export const v81Schema = v81SchemaIntermediate + +export const v82Schema = v81Schema + +export const v83Schema = { + ...v81Schema, + pushNotifications: { + generalUpdatesEnabled: true, + priceAlertsEnabled: true, + }, +} + +const v84SchemaIntermediate = { + ...v83Schema, + behaviorHistory: { + ...v83Schema.behaviorHistory, + hasViewedWelcomeWalletCard: undefined, + }, +} +delete v84SchemaIntermediate.behaviorHistory.hasViewedWelcomeWalletCard +export const v84Schema = v84SchemaIntermediate + +const v85SchemaIntermediate = { + ...v84Schema, + visibility: { + positions: {}, + tokens: v84Schema.favorites.tokensVisibility, + nfts: v84Schema.favorites.nftsVisibility, + }, + favorites: { + ...v84Schema.favorites, + tokensVisibility: undefined, + nftsVisibility: undefined, + }, +} +delete v85SchemaIntermediate.favorites.tokensVisibility +delete v85SchemaIntermediate.favorites.nftsVisibility +export const v85Schema = v85SchemaIntermediate + +export const v86Schema = { + ...v85Schema, + batchedTransactions: {}, +} + +export const v87Schema = v86Schema + +const v88SchemaIntermediate = { + ...v87Schema, + appearanceSettings: { + ...v87Schema.appearanceSettings, + hapticsEnabled: undefined, + }, + userSettings: { + ...v87Schema.userSettings, + // oxlint-disable-next-line typescript/no-unnecessary-condition + hapticsEnabled: v87Schema.appearanceSettings.hapticsEnabled ?? true, + }, +} +delete v88SchemaIntermediate.appearanceSettings.hapticsEnabled + +export const v88Schema = v88SchemaIntermediate + +export const v89Schema = { ...v88Schema } + +export const v90Schema = { ...v89Schema } + +export const v91Schema = { + ...v90Schema, + pushNotifications: { + generalUpdatesEnabled: v90Schema.pushNotifications.generalUpdatesEnabled, + }, +} + +const v92SchemaIntermediate = { + ...v91Schema, + cloudBackup: undefined, + wallet: { + ...v91Schema.wallet, + androidCloudBackupEmail: null, + }, +} + +delete v92SchemaIntermediate.cloudBackup + +export const v92Schema = v92SchemaIntermediate + +export const v93Schema = v92Schema + +export const v95Schema = { + ...v93Schema, + visibility: { + ...v93Schema.visibility, + activity: {}, + }, +} + +export const v96Schema = v95Schema + +const v97Schema = v96Schema + +// TODO: [MOB-201] use function with typed output when API reducers are removed from rootReducer +// export const getSchema = (): RootState => v0Schema +export const getSchema = (): typeof v97Schema => v97Schema diff --git a/apps/mobile/src/app/store.test.ts b/apps/mobile/src/app/store.test.ts new file mode 100644 index 00000000..d58a2be4 --- /dev/null +++ b/apps/mobile/src/app/store.test.ts @@ -0,0 +1,13 @@ +import { migrations } from 'src/app/migrations' +import { persistConfig } from 'src/app/store' + +describe('Redux persist config', () => { + it('has a version that is in sync with migrations', () => { + const migrationKeys = Object.keys(migrations) + .map((version) => parseInt(version, 10)) + .sort((a, b) => a - b) + + const lastMigrationKey = migrationKeys.pop() + expect(persistConfig.version).toEqual(lastMigrationKey) + }) +}) diff --git a/apps/mobile/src/app/store.ts b/apps/mobile/src/app/store.ts new file mode 100644 index 00000000..30398fd7 --- /dev/null +++ b/apps/mobile/src/app/store.ts @@ -0,0 +1,71 @@ +import type { Middleware, PreloadedState } from '@reduxjs/toolkit' +import { MMKV } from 'react-native-mmkv' +import { persistReducer, persistStore, Storage } from 'redux-persist' +import { MOBILE_STATE_VERSION, migrations } from 'src/app/migrations' +import { MobileState, mobilePersistedStateList, mobileReducer } from 'src/app/mobileReducer' +import { rootMobileSaga } from 'src/app/saga' +import { delegationListenerMiddleware } from 'uniswap/src/features/smartWallet/delegation/slice' +import { isNonTestDev } from 'utilities/src/environment/constants' +import { createDatadogReduxEnhancer } from 'utilities/src/logger/datadog/Datadog' +import { createStore } from 'wallet/src/state' +import { createMigrate } from 'wallet/src/state/createMigrate' +import { setReduxPersistor } from 'wallet/src/state/persistor' + +const storage = new MMKV() + +const reduxStorage: Storage = { + setItem: (key, value) => { + storage.set(key, value) + return Promise.resolve(true) + }, + getItem: (key) => { + const value = storage.getString(key) + return Promise.resolve(value) + }, + removeItem: (key) => { + storage.delete(key) + return Promise.resolve() + }, +} + +export const persistConfig = { + key: 'root', + storage: reduxStorage, + whitelist: mobilePersistedStateList, + version: MOBILE_STATE_VERSION, + migrate: createMigrate(migrations), +} + +export const persistedReducer = persistReducer(persistConfig, mobileReducer) + +const dataDogReduxEnhancer = createDatadogReduxEnhancer({ + shouldLogReduxState: (state: MobileState): boolean => { + // Do not log the state if a user has opted out of analytics. + return !!state.telemetry.allowAnalytics + }, +}) + +const enhancers = [dataDogReduxEnhancer] + +if (isNonTestDev) { + const reactotron = require('src/../ReactotronConfig').default + enhancers.push(reactotron.createEnhancer()) +} + +const middlewares: Middleware[] = [delegationListenerMiddleware.middleware] + +const setupStore = ( + preloadedState?: PreloadedState, + // oxlint-disable-next-line typescript/explicit-function-return-type +) => { + return createStore({ + reducer: persistedReducer, + preloadedState, + additionalSagas: [rootMobileSaga], + middlewareAfter: [...middlewares], + enhancers, + }) +} + +export const store = setupStore() +setReduxPersistor(persistStore(store)) diff --git a/apps/mobile/src/app/token-select.tsx b/apps/mobile/src/app/token-select.tsx new file mode 100644 index 00000000..e6f444b9 --- /dev/null +++ b/apps/mobile/src/app/token-select.tsx @@ -0,0 +1,109 @@ +import { View, Text, StyleSheet, TextInput, TouchableOpacity, FlatList } from 'react-native' +import { useRouter, useLocalSearchParams } from 'expo-router' +import { useState } from 'react' + +const TOKENS = [ + { symbol: 'LUX', name: 'Lux', balance: '0.00' }, + { symbol: 'LETH', name: 'Lux ETH', balance: '0.00' }, + { symbol: 'LBTC', name: 'Lux BTC', balance: '0.00' }, + { symbol: 'LUSD', name: 'Lux USD', balance: '0.00' }, + { symbol: 'ZOO', name: 'Zoo', balance: '0.00' }, +] + +export default function TokenSelectScreen() { + const router = useRouter() + const { type } = useLocalSearchParams() + const [search, setSearch] = useState('') + + const filteredTokens = TOKENS.filter( + (t) => + t.symbol.toLowerCase().includes(search.toLowerCase()) || + t.name.toLowerCase().includes(search.toLowerCase()) + ) + + const selectToken = (token: typeof TOKENS[0]) => { + // In a real app, you'd pass this back to the parent + router.back() + } + + return ( + + + + item.symbol} + renderItem={({ item }) => ( + selectToken(item)}> + + {item.symbol[0]} + + + {item.symbol} + {item.name} + + {item.balance} + + )} + /> + + ) +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#0f0f1a', + }, + searchInput: { + backgroundColor: '#1a1a2e', + margin: 16, + padding: 16, + borderRadius: 12, + color: '#fff', + fontSize: 16, + }, + tokenRow: { + flexDirection: 'row', + alignItems: 'center', + padding: 16, + borderBottomWidth: 1, + borderBottomColor: '#1a1a2e', + }, + tokenIcon: { + width: 40, + height: 40, + borderRadius: 20, + backgroundColor: '#374151', + justifyContent: 'center', + alignItems: 'center', + marginRight: 12, + }, + tokenIconText: { + color: '#fff', + fontSize: 18, + fontWeight: '600', + }, + tokenInfo: { + flex: 1, + }, + tokenSymbol: { + color: '#fff', + fontSize: 16, + fontWeight: '600', + }, + tokenName: { + color: '#9ca3af', + fontSize: 14, + }, + tokenBalance: { + color: '#9ca3af', + fontSize: 14, + }, +}) diff --git a/apps/mobile/src/assets/fonts/Basel-Grotesk-Book.otf b/apps/mobile/src/assets/fonts/Basel-Grotesk-Book.otf new file mode 100644 index 00000000..77dc33d7 Binary files /dev/null and b/apps/mobile/src/assets/fonts/Basel-Grotesk-Book.otf differ diff --git a/apps/mobile/src/assets/fonts/Basel-Grotesk-Medium.otf b/apps/mobile/src/assets/fonts/Basel-Grotesk-Medium.otf new file mode 100644 index 00000000..344235fe Binary files /dev/null and b/apps/mobile/src/assets/fonts/Basel-Grotesk-Medium.otf differ diff --git a/apps/mobile/src/assets/fonts/InputMono-Regular.ttf b/apps/mobile/src/assets/fonts/InputMono-Regular.ttf new file mode 100644 index 00000000..1da56040 Binary files /dev/null and b/apps/mobile/src/assets/fonts/InputMono-Regular.ttf differ diff --git a/apps/mobile/src/assets/unicons/Container/1.svg b/apps/mobile/src/assets/unicons/Container/1.svg new file mode 100644 index 00000000..bb7d1d62 --- /dev/null +++ b/apps/mobile/src/assets/unicons/Container/1.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/src/assets/unicons/Container/10.svg b/apps/mobile/src/assets/unicons/Container/10.svg new file mode 100644 index 00000000..e5462573 --- /dev/null +++ b/apps/mobile/src/assets/unicons/Container/10.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/src/assets/unicons/Container/11.svg b/apps/mobile/src/assets/unicons/Container/11.svg new file mode 100644 index 00000000..c308672c --- /dev/null +++ b/apps/mobile/src/assets/unicons/Container/11.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/src/assets/unicons/Container/12.svg b/apps/mobile/src/assets/unicons/Container/12.svg new file mode 100644 index 00000000..61b7274e --- /dev/null +++ b/apps/mobile/src/assets/unicons/Container/12.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/src/assets/unicons/Container/13.svg b/apps/mobile/src/assets/unicons/Container/13.svg new file mode 100644 index 00000000..732a0e66 --- /dev/null +++ b/apps/mobile/src/assets/unicons/Container/13.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/src/assets/unicons/Container/14.svg b/apps/mobile/src/assets/unicons/Container/14.svg new file mode 100644 index 00000000..6f0c95b1 --- /dev/null +++ b/apps/mobile/src/assets/unicons/Container/14.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/src/assets/unicons/Container/15.svg b/apps/mobile/src/assets/unicons/Container/15.svg new file mode 100644 index 00000000..c89101bb --- /dev/null +++ b/apps/mobile/src/assets/unicons/Container/15.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/src/assets/unicons/Container/16.svg b/apps/mobile/src/assets/unicons/Container/16.svg new file mode 100644 index 00000000..46001e22 --- /dev/null +++ b/apps/mobile/src/assets/unicons/Container/16.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/src/assets/unicons/Container/17.svg b/apps/mobile/src/assets/unicons/Container/17.svg new file mode 100644 index 00000000..ea8024d5 --- /dev/null +++ b/apps/mobile/src/assets/unicons/Container/17.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/src/assets/unicons/Container/18.svg b/apps/mobile/src/assets/unicons/Container/18.svg new file mode 100644 index 00000000..859b5d37 --- /dev/null +++ b/apps/mobile/src/assets/unicons/Container/18.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/src/assets/unicons/Container/19.svg b/apps/mobile/src/assets/unicons/Container/19.svg new file mode 100644 index 00000000..60e04f04 --- /dev/null +++ b/apps/mobile/src/assets/unicons/Container/19.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/src/assets/unicons/Container/2.svg b/apps/mobile/src/assets/unicons/Container/2.svg new file mode 100644 index 00000000..0f12f3f0 --- /dev/null +++ b/apps/mobile/src/assets/unicons/Container/2.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/src/assets/unicons/Container/20.svg b/apps/mobile/src/assets/unicons/Container/20.svg new file mode 100644 index 00000000..f3ba12b9 --- /dev/null +++ b/apps/mobile/src/assets/unicons/Container/20.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/src/assets/unicons/Container/21.svg b/apps/mobile/src/assets/unicons/Container/21.svg new file mode 100644 index 00000000..9ee424d4 --- /dev/null +++ b/apps/mobile/src/assets/unicons/Container/21.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/src/assets/unicons/Container/22.svg b/apps/mobile/src/assets/unicons/Container/22.svg new file mode 100644 index 00000000..32292b51 --- /dev/null +++ b/apps/mobile/src/assets/unicons/Container/22.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/src/assets/unicons/Container/23.svg b/apps/mobile/src/assets/unicons/Container/23.svg new file mode 100644 index 00000000..9d9a8155 --- /dev/null +++ b/apps/mobile/src/assets/unicons/Container/23.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/src/assets/unicons/Container/24.svg b/apps/mobile/src/assets/unicons/Container/24.svg new file mode 100644 index 00000000..cd5d3691 --- /dev/null +++ b/apps/mobile/src/assets/unicons/Container/24.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/src/assets/unicons/Container/25.svg b/apps/mobile/src/assets/unicons/Container/25.svg new file mode 100644 index 00000000..a37d9ff2 --- /dev/null +++ b/apps/mobile/src/assets/unicons/Container/25.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/src/assets/unicons/Container/26.svg b/apps/mobile/src/assets/unicons/Container/26.svg new file mode 100644 index 00000000..687f8af0 --- /dev/null +++ b/apps/mobile/src/assets/unicons/Container/26.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/src/assets/unicons/Container/27.svg b/apps/mobile/src/assets/unicons/Container/27.svg new file mode 100644 index 00000000..b3bbbcd9 --- /dev/null +++ b/apps/mobile/src/assets/unicons/Container/27.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/src/assets/unicons/Container/28.svg b/apps/mobile/src/assets/unicons/Container/28.svg new file mode 100644 index 00000000..284408d6 --- /dev/null +++ b/apps/mobile/src/assets/unicons/Container/28.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/src/assets/unicons/Container/29.svg b/apps/mobile/src/assets/unicons/Container/29.svg new file mode 100644 index 00000000..ad087241 --- /dev/null +++ b/apps/mobile/src/assets/unicons/Container/29.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/src/assets/unicons/Container/3.svg b/apps/mobile/src/assets/unicons/Container/3.svg new file mode 100644 index 00000000..be0711d6 --- /dev/null +++ b/apps/mobile/src/assets/unicons/Container/3.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/src/assets/unicons/Container/30.svg b/apps/mobile/src/assets/unicons/Container/30.svg new file mode 100644 index 00000000..5e08e0bb --- /dev/null +++ b/apps/mobile/src/assets/unicons/Container/30.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/src/assets/unicons/Container/31.svg b/apps/mobile/src/assets/unicons/Container/31.svg new file mode 100644 index 00000000..a1d83d67 --- /dev/null +++ b/apps/mobile/src/assets/unicons/Container/31.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/src/assets/unicons/Container/32.svg b/apps/mobile/src/assets/unicons/Container/32.svg new file mode 100644 index 00000000..5f0a0a1a --- /dev/null +++ b/apps/mobile/src/assets/unicons/Container/32.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/src/assets/unicons/Container/33.svg b/apps/mobile/src/assets/unicons/Container/33.svg new file mode 100644 index 00000000..76be5f2c --- /dev/null +++ b/apps/mobile/src/assets/unicons/Container/33.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/src/assets/unicons/Container/34.svg b/apps/mobile/src/assets/unicons/Container/34.svg new file mode 100644 index 00000000..5b986d2d --- /dev/null +++ b/apps/mobile/src/assets/unicons/Container/34.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/src/assets/unicons/Container/35.svg b/apps/mobile/src/assets/unicons/Container/35.svg new file mode 100644 index 00000000..1fec8d82 --- /dev/null +++ b/apps/mobile/src/assets/unicons/Container/35.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/src/assets/unicons/Container/36.svg b/apps/mobile/src/assets/unicons/Container/36.svg new file mode 100644 index 00000000..12e4307e --- /dev/null +++ b/apps/mobile/src/assets/unicons/Container/36.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/src/assets/unicons/Container/37.svg b/apps/mobile/src/assets/unicons/Container/37.svg new file mode 100644 index 00000000..4904d031 --- /dev/null +++ b/apps/mobile/src/assets/unicons/Container/37.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/src/assets/unicons/Container/4.svg b/apps/mobile/src/assets/unicons/Container/4.svg new file mode 100644 index 00000000..0f12f3f0 --- /dev/null +++ b/apps/mobile/src/assets/unicons/Container/4.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/src/assets/unicons/Container/5.svg b/apps/mobile/src/assets/unicons/Container/5.svg new file mode 100644 index 00000000..d1fcee23 --- /dev/null +++ b/apps/mobile/src/assets/unicons/Container/5.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/src/assets/unicons/Container/6.svg b/apps/mobile/src/assets/unicons/Container/6.svg new file mode 100644 index 00000000..d0d482c4 --- /dev/null +++ b/apps/mobile/src/assets/unicons/Container/6.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/src/assets/unicons/Container/7.svg b/apps/mobile/src/assets/unicons/Container/7.svg new file mode 100644 index 00000000..08588e98 --- /dev/null +++ b/apps/mobile/src/assets/unicons/Container/7.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/src/assets/unicons/Container/8.svg b/apps/mobile/src/assets/unicons/Container/8.svg new file mode 100644 index 00000000..0244d378 --- /dev/null +++ b/apps/mobile/src/assets/unicons/Container/8.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/src/assets/unicons/Container/9.svg b/apps/mobile/src/assets/unicons/Container/9.svg new file mode 100644 index 00000000..4253e89f --- /dev/null +++ b/apps/mobile/src/assets/unicons/Container/9.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/src/assets/unicons/Emblem/1.svg b/apps/mobile/src/assets/unicons/Emblem/1.svg new file mode 100644 index 00000000..a996d0a9 --- /dev/null +++ b/apps/mobile/src/assets/unicons/Emblem/1.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/src/assets/unicons/Emblem/10.svg b/apps/mobile/src/assets/unicons/Emblem/10.svg new file mode 100644 index 00000000..6a9247b3 --- /dev/null +++ b/apps/mobile/src/assets/unicons/Emblem/10.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/src/assets/unicons/Emblem/11.svg b/apps/mobile/src/assets/unicons/Emblem/11.svg new file mode 100644 index 00000000..1b8fc01b --- /dev/null +++ b/apps/mobile/src/assets/unicons/Emblem/11.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/src/assets/unicons/Emblem/12.svg b/apps/mobile/src/assets/unicons/Emblem/12.svg new file mode 100644 index 00000000..d33d8988 --- /dev/null +++ b/apps/mobile/src/assets/unicons/Emblem/12.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/src/assets/unicons/Emblem/13.svg b/apps/mobile/src/assets/unicons/Emblem/13.svg new file mode 100644 index 00000000..84966686 --- /dev/null +++ b/apps/mobile/src/assets/unicons/Emblem/13.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/src/assets/unicons/Emblem/14.svg b/apps/mobile/src/assets/unicons/Emblem/14.svg new file mode 100644 index 00000000..d706fce2 --- /dev/null +++ b/apps/mobile/src/assets/unicons/Emblem/14.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/src/assets/unicons/Emblem/15.svg b/apps/mobile/src/assets/unicons/Emblem/15.svg new file mode 100644 index 00000000..3945fe86 --- /dev/null +++ b/apps/mobile/src/assets/unicons/Emblem/15.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/mobile/src/assets/unicons/Emblem/16.svg b/apps/mobile/src/assets/unicons/Emblem/16.svg new file mode 100644 index 00000000..05bbcb68 --- /dev/null +++ b/apps/mobile/src/assets/unicons/Emblem/16.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/mobile/src/assets/unicons/Emblem/17.svg b/apps/mobile/src/assets/unicons/Emblem/17.svg new file mode 100644 index 00000000..1eb62d0b --- /dev/null +++ b/apps/mobile/src/assets/unicons/Emblem/17.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/src/assets/unicons/Emblem/18.svg b/apps/mobile/src/assets/unicons/Emblem/18.svg new file mode 100644 index 00000000..5439a157 --- /dev/null +++ b/apps/mobile/src/assets/unicons/Emblem/18.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/src/assets/unicons/Emblem/19.svg b/apps/mobile/src/assets/unicons/Emblem/19.svg new file mode 100644 index 00000000..30f949b0 --- /dev/null +++ b/apps/mobile/src/assets/unicons/Emblem/19.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/src/assets/unicons/Emblem/2.svg b/apps/mobile/src/assets/unicons/Emblem/2.svg new file mode 100644 index 00000000..ea30ad6d --- /dev/null +++ b/apps/mobile/src/assets/unicons/Emblem/2.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/src/assets/unicons/Emblem/20.svg b/apps/mobile/src/assets/unicons/Emblem/20.svg new file mode 100644 index 00000000..47ae7cd5 --- /dev/null +++ b/apps/mobile/src/assets/unicons/Emblem/20.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/src/assets/unicons/Emblem/21.svg b/apps/mobile/src/assets/unicons/Emblem/21.svg new file mode 100644 index 00000000..ebc25fc6 --- /dev/null +++ b/apps/mobile/src/assets/unicons/Emblem/21.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/src/assets/unicons/Emblem/22.svg b/apps/mobile/src/assets/unicons/Emblem/22.svg new file mode 100644 index 00000000..51da6d56 --- /dev/null +++ b/apps/mobile/src/assets/unicons/Emblem/22.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/src/assets/unicons/Emblem/23.svg b/apps/mobile/src/assets/unicons/Emblem/23.svg new file mode 100644 index 00000000..45547989 --- /dev/null +++ b/apps/mobile/src/assets/unicons/Emblem/23.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/src/assets/unicons/Emblem/24.svg b/apps/mobile/src/assets/unicons/Emblem/24.svg new file mode 100644 index 00000000..c6c115a2 --- /dev/null +++ b/apps/mobile/src/assets/unicons/Emblem/24.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/src/assets/unicons/Emblem/25.svg b/apps/mobile/src/assets/unicons/Emblem/25.svg new file mode 100644 index 00000000..c8d95d99 --- /dev/null +++ b/apps/mobile/src/assets/unicons/Emblem/25.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/src/assets/unicons/Emblem/26.svg b/apps/mobile/src/assets/unicons/Emblem/26.svg new file mode 100644 index 00000000..ff9e0f86 --- /dev/null +++ b/apps/mobile/src/assets/unicons/Emblem/26.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/src/assets/unicons/Emblem/27.svg b/apps/mobile/src/assets/unicons/Emblem/27.svg new file mode 100644 index 00000000..aa3871a7 --- /dev/null +++ b/apps/mobile/src/assets/unicons/Emblem/27.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/src/assets/unicons/Emblem/28.svg b/apps/mobile/src/assets/unicons/Emblem/28.svg new file mode 100644 index 00000000..d0816e10 --- /dev/null +++ b/apps/mobile/src/assets/unicons/Emblem/28.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/src/assets/unicons/Emblem/29.svg b/apps/mobile/src/assets/unicons/Emblem/29.svg new file mode 100644 index 00000000..33575139 --- /dev/null +++ b/apps/mobile/src/assets/unicons/Emblem/29.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/src/assets/unicons/Emblem/3.svg b/apps/mobile/src/assets/unicons/Emblem/3.svg new file mode 100644 index 00000000..f9472333 --- /dev/null +++ b/apps/mobile/src/assets/unicons/Emblem/3.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/src/assets/unicons/Emblem/30.svg b/apps/mobile/src/assets/unicons/Emblem/30.svg new file mode 100644 index 00000000..33eec608 --- /dev/null +++ b/apps/mobile/src/assets/unicons/Emblem/30.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/mobile/src/assets/unicons/Emblem/31.svg b/apps/mobile/src/assets/unicons/Emblem/31.svg new file mode 100644 index 00000000..f6bb3abb --- /dev/null +++ b/apps/mobile/src/assets/unicons/Emblem/31.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/src/assets/unicons/Emblem/32.svg b/apps/mobile/src/assets/unicons/Emblem/32.svg new file mode 100644 index 00000000..5d8ee19a --- /dev/null +++ b/apps/mobile/src/assets/unicons/Emblem/32.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/src/assets/unicons/Emblem/33.svg b/apps/mobile/src/assets/unicons/Emblem/33.svg new file mode 100644 index 00000000..facc996d --- /dev/null +++ b/apps/mobile/src/assets/unicons/Emblem/33.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/src/assets/unicons/Emblem/34.svg b/apps/mobile/src/assets/unicons/Emblem/34.svg new file mode 100644 index 00000000..9e576693 --- /dev/null +++ b/apps/mobile/src/assets/unicons/Emblem/34.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/mobile/src/assets/unicons/Emblem/35.svg b/apps/mobile/src/assets/unicons/Emblem/35.svg new file mode 100644 index 00000000..73a633ba --- /dev/null +++ b/apps/mobile/src/assets/unicons/Emblem/35.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/src/assets/unicons/Emblem/36.svg b/apps/mobile/src/assets/unicons/Emblem/36.svg new file mode 100644 index 00000000..eb7bb2f3 --- /dev/null +++ b/apps/mobile/src/assets/unicons/Emblem/36.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/src/assets/unicons/Emblem/37.svg b/apps/mobile/src/assets/unicons/Emblem/37.svg new file mode 100644 index 00000000..7622c440 --- /dev/null +++ b/apps/mobile/src/assets/unicons/Emblem/37.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/mobile/src/assets/unicons/Emblem/38.svg b/apps/mobile/src/assets/unicons/Emblem/38.svg new file mode 100644 index 00000000..6967df4d --- /dev/null +++ b/apps/mobile/src/assets/unicons/Emblem/38.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/src/assets/unicons/Emblem/39.svg b/apps/mobile/src/assets/unicons/Emblem/39.svg new file mode 100644 index 00000000..356b2571 --- /dev/null +++ b/apps/mobile/src/assets/unicons/Emblem/39.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/mobile/src/assets/unicons/Emblem/4.svg b/apps/mobile/src/assets/unicons/Emblem/4.svg new file mode 100644 index 00000000..53a1e1a7 --- /dev/null +++ b/apps/mobile/src/assets/unicons/Emblem/4.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/src/assets/unicons/Emblem/40.svg b/apps/mobile/src/assets/unicons/Emblem/40.svg new file mode 100644 index 00000000..111449d1 --- /dev/null +++ b/apps/mobile/src/assets/unicons/Emblem/40.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/src/assets/unicons/Emblem/41.svg b/apps/mobile/src/assets/unicons/Emblem/41.svg new file mode 100644 index 00000000..df1b7202 --- /dev/null +++ b/apps/mobile/src/assets/unicons/Emblem/41.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/src/assets/unicons/Emblem/42.svg b/apps/mobile/src/assets/unicons/Emblem/42.svg new file mode 100644 index 00000000..a0ebfc35 --- /dev/null +++ b/apps/mobile/src/assets/unicons/Emblem/42.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/src/assets/unicons/Emblem/43.svg b/apps/mobile/src/assets/unicons/Emblem/43.svg new file mode 100644 index 00000000..d6a07b72 --- /dev/null +++ b/apps/mobile/src/assets/unicons/Emblem/43.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/mobile/src/assets/unicons/Emblem/44.svg b/apps/mobile/src/assets/unicons/Emblem/44.svg new file mode 100644 index 00000000..8718905e --- /dev/null +++ b/apps/mobile/src/assets/unicons/Emblem/44.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/src/assets/unicons/Emblem/45.svg b/apps/mobile/src/assets/unicons/Emblem/45.svg new file mode 100644 index 00000000..0dba11f7 --- /dev/null +++ b/apps/mobile/src/assets/unicons/Emblem/45.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/mobile/src/assets/unicons/Emblem/46.svg b/apps/mobile/src/assets/unicons/Emblem/46.svg new file mode 100644 index 00000000..cadc31a6 --- /dev/null +++ b/apps/mobile/src/assets/unicons/Emblem/46.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/src/assets/unicons/Emblem/47.svg b/apps/mobile/src/assets/unicons/Emblem/47.svg new file mode 100644 index 00000000..c288b0f4 --- /dev/null +++ b/apps/mobile/src/assets/unicons/Emblem/47.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/src/assets/unicons/Emblem/48.svg b/apps/mobile/src/assets/unicons/Emblem/48.svg new file mode 100644 index 00000000..7835a139 --- /dev/null +++ b/apps/mobile/src/assets/unicons/Emblem/48.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/mobile/src/assets/unicons/Emblem/49.svg b/apps/mobile/src/assets/unicons/Emblem/49.svg new file mode 100644 index 00000000..90a7c08a --- /dev/null +++ b/apps/mobile/src/assets/unicons/Emblem/49.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/mobile/src/assets/unicons/Emblem/5.svg b/apps/mobile/src/assets/unicons/Emblem/5.svg new file mode 100644 index 00000000..89fa50be --- /dev/null +++ b/apps/mobile/src/assets/unicons/Emblem/5.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/src/assets/unicons/Emblem/50.svg b/apps/mobile/src/assets/unicons/Emblem/50.svg new file mode 100644 index 00000000..cc2c2987 --- /dev/null +++ b/apps/mobile/src/assets/unicons/Emblem/50.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/mobile/src/assets/unicons/Emblem/51.svg b/apps/mobile/src/assets/unicons/Emblem/51.svg new file mode 100644 index 00000000..612f6298 --- /dev/null +++ b/apps/mobile/src/assets/unicons/Emblem/51.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/apps/mobile/src/assets/unicons/Emblem/52.svg b/apps/mobile/src/assets/unicons/Emblem/52.svg new file mode 100644 index 00000000..46ae7141 --- /dev/null +++ b/apps/mobile/src/assets/unicons/Emblem/52.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/apps/mobile/src/assets/unicons/Emblem/53.svg b/apps/mobile/src/assets/unicons/Emblem/53.svg new file mode 100644 index 00000000..1af5478d --- /dev/null +++ b/apps/mobile/src/assets/unicons/Emblem/53.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/mobile/src/assets/unicons/Emblem/54.svg b/apps/mobile/src/assets/unicons/Emblem/54.svg new file mode 100644 index 00000000..a99eceb3 --- /dev/null +++ b/apps/mobile/src/assets/unicons/Emblem/54.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/mobile/src/assets/unicons/Emblem/55.svg b/apps/mobile/src/assets/unicons/Emblem/55.svg new file mode 100644 index 00000000..a7ac2d39 --- /dev/null +++ b/apps/mobile/src/assets/unicons/Emblem/55.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/apps/mobile/src/assets/unicons/Emblem/56.svg b/apps/mobile/src/assets/unicons/Emblem/56.svg new file mode 100644 index 00000000..6f51ad93 --- /dev/null +++ b/apps/mobile/src/assets/unicons/Emblem/56.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/src/assets/unicons/Emblem/57.svg b/apps/mobile/src/assets/unicons/Emblem/57.svg new file mode 100644 index 00000000..26fc9425 --- /dev/null +++ b/apps/mobile/src/assets/unicons/Emblem/57.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/apps/mobile/src/assets/unicons/Emblem/58.svg b/apps/mobile/src/assets/unicons/Emblem/58.svg new file mode 100644 index 00000000..19aec8c1 --- /dev/null +++ b/apps/mobile/src/assets/unicons/Emblem/58.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/mobile/src/assets/unicons/Emblem/59.svg b/apps/mobile/src/assets/unicons/Emblem/59.svg new file mode 100644 index 00000000..c2b734af --- /dev/null +++ b/apps/mobile/src/assets/unicons/Emblem/59.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/mobile/src/assets/unicons/Emblem/6.svg b/apps/mobile/src/assets/unicons/Emblem/6.svg new file mode 100644 index 00000000..1321f6c0 --- /dev/null +++ b/apps/mobile/src/assets/unicons/Emblem/6.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/src/assets/unicons/Emblem/60.svg b/apps/mobile/src/assets/unicons/Emblem/60.svg new file mode 100644 index 00000000..14a31a97 --- /dev/null +++ b/apps/mobile/src/assets/unicons/Emblem/60.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/src/assets/unicons/Emblem/61.svg b/apps/mobile/src/assets/unicons/Emblem/61.svg new file mode 100644 index 00000000..7c74f7cb --- /dev/null +++ b/apps/mobile/src/assets/unicons/Emblem/61.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/src/assets/unicons/Emblem/62.svg b/apps/mobile/src/assets/unicons/Emblem/62.svg new file mode 100644 index 00000000..d201632e --- /dev/null +++ b/apps/mobile/src/assets/unicons/Emblem/62.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/src/assets/unicons/Emblem/63.svg b/apps/mobile/src/assets/unicons/Emblem/63.svg new file mode 100644 index 00000000..501c53e0 --- /dev/null +++ b/apps/mobile/src/assets/unicons/Emblem/63.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/src/assets/unicons/Emblem/64.svg b/apps/mobile/src/assets/unicons/Emblem/64.svg new file mode 100644 index 00000000..50e32973 --- /dev/null +++ b/apps/mobile/src/assets/unicons/Emblem/64.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/mobile/src/assets/unicons/Emblem/65.svg b/apps/mobile/src/assets/unicons/Emblem/65.svg new file mode 100644 index 00000000..90ced294 --- /dev/null +++ b/apps/mobile/src/assets/unicons/Emblem/65.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/mobile/src/assets/unicons/Emblem/66.svg b/apps/mobile/src/assets/unicons/Emblem/66.svg new file mode 100644 index 00000000..3d7a4797 --- /dev/null +++ b/apps/mobile/src/assets/unicons/Emblem/66.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/src/assets/unicons/Emblem/67.svg b/apps/mobile/src/assets/unicons/Emblem/67.svg new file mode 100644 index 00000000..4d3a860a --- /dev/null +++ b/apps/mobile/src/assets/unicons/Emblem/67.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/mobile/src/assets/unicons/Emblem/68.svg b/apps/mobile/src/assets/unicons/Emblem/68.svg new file mode 100644 index 00000000..2f89f6d4 --- /dev/null +++ b/apps/mobile/src/assets/unicons/Emblem/68.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/mobile/src/assets/unicons/Emblem/69.svg b/apps/mobile/src/assets/unicons/Emblem/69.svg new file mode 100644 index 00000000..aa5f0885 --- /dev/null +++ b/apps/mobile/src/assets/unicons/Emblem/69.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/mobile/src/assets/unicons/Emblem/7.svg b/apps/mobile/src/assets/unicons/Emblem/7.svg new file mode 100644 index 00000000..4b64e401 --- /dev/null +++ b/apps/mobile/src/assets/unicons/Emblem/7.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/src/assets/unicons/Emblem/70.svg b/apps/mobile/src/assets/unicons/Emblem/70.svg new file mode 100644 index 00000000..de0d26b8 --- /dev/null +++ b/apps/mobile/src/assets/unicons/Emblem/70.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/src/assets/unicons/Emblem/71.svg b/apps/mobile/src/assets/unicons/Emblem/71.svg new file mode 100644 index 00000000..d5c7c0fd --- /dev/null +++ b/apps/mobile/src/assets/unicons/Emblem/71.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/src/assets/unicons/Emblem/72.svg b/apps/mobile/src/assets/unicons/Emblem/72.svg new file mode 100644 index 00000000..18ff3cf4 --- /dev/null +++ b/apps/mobile/src/assets/unicons/Emblem/72.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/src/assets/unicons/Emblem/73.svg b/apps/mobile/src/assets/unicons/Emblem/73.svg new file mode 100644 index 00000000..eca90a9f --- /dev/null +++ b/apps/mobile/src/assets/unicons/Emblem/73.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/src/assets/unicons/Emblem/74.svg b/apps/mobile/src/assets/unicons/Emblem/74.svg new file mode 100644 index 00000000..90f26e0a --- /dev/null +++ b/apps/mobile/src/assets/unicons/Emblem/74.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/src/assets/unicons/Emblem/75.svg b/apps/mobile/src/assets/unicons/Emblem/75.svg new file mode 100644 index 00000000..bdff434b --- /dev/null +++ b/apps/mobile/src/assets/unicons/Emblem/75.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/src/assets/unicons/Emblem/8.svg b/apps/mobile/src/assets/unicons/Emblem/8.svg new file mode 100644 index 00000000..e24b5c88 --- /dev/null +++ b/apps/mobile/src/assets/unicons/Emblem/8.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/src/assets/unicons/Emblem/9.svg b/apps/mobile/src/assets/unicons/Emblem/9.svg new file mode 100644 index 00000000..8493ce46 --- /dev/null +++ b/apps/mobile/src/assets/unicons/Emblem/9.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/src/components/PriceExplorer/AnimatedDecimalNumber.tsx b/apps/mobile/src/components/PriceExplorer/AnimatedDecimalNumber.tsx new file mode 100644 index 00000000..e05e256b --- /dev/null +++ b/apps/mobile/src/components/PriceExplorer/AnimatedDecimalNumber.tsx @@ -0,0 +1,97 @@ +import React, { memo, useMemo } from 'react' +import { useWindowDimensions } from 'react-native' +import { useAnimatedStyle, useDerivedValue } from 'react-native-reanimated' +import { ValueAndFormatted } from 'src/components/PriceExplorer/usePrice' +import { AnimatedText } from 'src/components/text/AnimatedText' +import { Flex, useSporeColors } from 'ui/src' +import { useDeviceDimensions } from 'ui/src/hooks/useDeviceDimensions' +import { fonts, TextVariantTokens } from 'ui/src/theme' +import { TestIDType } from 'uniswap/src/test/fixtures/testIDs' + +type AnimatedDecimalNumberProps = { + number: ValueAndFormatted + separator: string + variant: TextVariantTokens + wholePartColor?: string + decimalPartColor?: string + decimalThreshold?: number // below this value (not including) decimal part would have wholePartColor too + testID?: TestIDType + maxWidth?: number + maxCharPixelWidth?: number +} + +/** + * TODO(MOB-1948): AnimatePresence should be able to do this: + * + * Example: https://gist.github.com/natew/e773fa3bdc99f75a3b28f21db168a449 + * + */ + +// Utility component to display decimal numbers where the decimal part +// is dimmed using AnimatedText +export const AnimatedDecimalNumber = memo(function AnimatedDecimalNumber( + props: AnimatedDecimalNumberProps, +): JSX.Element { + const colors = useSporeColors() + const { fullWidth } = useDeviceDimensions() + const { fontScale } = useWindowDimensions() + + const { + number, + separator, + variant, + wholePartColor = colors.neutral1.val, + decimalPartColor = colors.neutral3.val, + decimalThreshold = 1, + testID, + maxWidth = fullWidth, + maxCharPixelWidth: maxCharPixelWidthProp, + } = props + + const wholePart = useDerivedValue(() => number.formatted.value.split(separator)[0] || '', [number, separator]) + const decimalPart = useDerivedValue( + () => separator + (number.formatted.value.split(separator)[1] || ''), + [number, separator], + ) + + const wholeStyle = useMemo(() => { + return { + color: wholePartColor, + } + }, [wholePartColor]) + + const decimalStyle = useAnimatedStyle(() => { + return { + color: number.value.value < decimalThreshold ? wholePartColor : decimalPartColor, + } + }, [number.value, decimalThreshold, wholePartColor, decimalPartColor]) + + const fontSize = fonts[variant].fontSize * fontScale + // Choose the arbitrary value that looks good for the font used + const maxCharPixelWidth = maxCharPixelWidthProp ?? (2 / 3) * fontSize + + const adjustedFontSize = useDerivedValue(() => { + const value = number.formatted.value + const approxWidth = value.length * maxCharPixelWidth + + if (approxWidth <= maxWidth) { + return fontSize + } + + const scale = Math.min(1, maxWidth / approxWidth) + return fontSize * scale + }) + + const animatedStyle = useAnimatedStyle(() => ({ + fontSize: adjustedFontSize.value, + })) + + return ( + + + {decimalPart.value !== separator && ( + + )} + + ) +}) diff --git a/apps/mobile/src/components/PriceExplorer/PriceExplorer.tsx b/apps/mobile/src/components/PriceExplorer/PriceExplorer.tsx new file mode 100644 index 00000000..170bb39e --- /dev/null +++ b/apps/mobile/src/components/PriceExplorer/PriceExplorer.tsx @@ -0,0 +1,291 @@ +import { GraphQLApi } from '@universe/api' +import React, { memo, PropsWithChildren, ReactElement, useCallback, useEffect, useMemo, useState } from 'react' +import { I18nManager } from 'react-native' +import { SharedValue, useDerivedValue } from 'react-native-reanimated' +import { LineChart, LineChartProvider } from 'react-native-wagmi-charts' +import { Loader } from 'src/components/loading/loaders' +import { CURSOR_INNER_SIZE, CURSOR_SIZE, TIME_RANGES } from 'src/components/PriceExplorer/constants' +import PriceExplorerAnimatedNumber from 'src/components/PriceExplorer/PriceExplorerAnimatedNumber' +import { PriceExplorerError } from 'src/components/PriceExplorer/PriceExplorerError' +import { DatetimeText, RelativeChangeText } from 'src/components/PriceExplorer/Text' +import { useChartDimensions } from 'src/components/PriceExplorer/useChartDimensions' +import { useLineChartPrice } from 'src/components/PriceExplorer/usePrice' +import { PriceNumberOfDigits, TokenSpotData, useTokenPriceHistory } from 'src/components/PriceExplorer/usePriceHistory' +import { useTokenDetailsContext } from 'src/components/TokenDetails/TokenDetailsContext' +import { useIsScreenNavigationReady } from 'src/utils/useIsScreenNavigationReady' +import { Flex, SegmentedControl, Text } from 'ui/src' +import { useLayoutAnimationOnChange } from 'ui/src/animations' +import GraphCurve from 'ui/src/assets/backgrounds/graph-curve.svg' +import { spacing } from 'ui/src/theme' +import { isLowVarianceRange } from 'uniswap/src/components/charts/utils' +import { useEnabledChains } from 'uniswap/src/features/chains/hooks/useEnabledChains' +import { useAppFiatCurrencyInfo } from 'uniswap/src/features/fiatCurrency/hooks' +import { useLocalizationContext } from 'uniswap/src/features/language/LocalizationContext' +import { useHapticFeedback } from 'uniswap/src/features/settings/useHapticFeedback/useHapticFeedback' +import { ElementName } from 'uniswap/src/features/telemetry/constants' +import Trace from 'uniswap/src/features/telemetry/Trace' +import { TestID } from 'uniswap/src/test/fixtures/testIDs' +import { logger } from 'utilities/src/logger/logger' +import { isAndroid } from 'utilities/src/platform' + +const DEFAULT_Y_PADDING = 20 +const LOW_VARIANCE_Y_PADDING = 100 + +type PriceTextProps = { + loading: boolean + relativeChange?: SharedValue + numberOfDigits: PriceNumberOfDigits + spotPrice?: SharedValue + startingPrice?: number + shouldTreatAsStablecoin?: boolean +} + +const PriceTextSection = memo(function PriceTextSection({ + loading, + numberOfDigits, + relativeChange, + spotPrice, + startingPrice, + shouldTreatAsStablecoin, +}: PriceTextProps): JSX.Element { + const price = useLineChartPrice(spotPrice) + const currency = useAppFiatCurrencyInfo() + + const [isAnimatedNumberReady, setIsAnimatedNumberReady] = useState(false) + const onAnimatedNumberReady = useCallback(() => setIsAnimatedNumberReady(true), []) + + return ( + // The `minHeight` is needed to avoid a layout shift on Android when hiding the skeleton. + + + + {/* + We want both the animated number skeleton and the relative change skeleton to hide at the exact same time. + When multiple skeletons hide in different order, it gives the feeling of things being slower than they actually are. + */} + + + + + ) +}) + +function TimeRangeTraceWrapper({ + children, + elementName, +}: PropsWithChildren<{ elementName: ElementName }>): ReactElement { + return ( + + {children} + + ) +} + +export const PriceExplorer = memo(function PriceExplorerInner(): JSX.Element { + const { isTestnetModeEnabled } = useEnabledChains() + const { chartHeight, chartWidth } = useChartDimensions() + + if (isTestnetModeEnabled) { + return + } + + return +}) + +const PriceExplorerContent = memo(function PriceExplorerContentInner(): JSX.Element { + const { currencyId, tokenColor, navigation } = useTokenDetailsContext() + const isScreenNavigationReady = useIsScreenNavigationReady({ navigation }) + + const { data, loading, error, refetch, setDuration, selectedDuration, numberOfDigits } = useTokenPriceHistory({ + currencyId, + initialDuration: GraphQLApi.HistoryDuration.Day, + skip: !isScreenNavigationReady, + }) + + // Log the number of points in the data + useEffect(() => { + if (data?.priceHistory) { + if (data.priceHistory.length < 10) { + logger.warn('PriceExplorer.tsx', 'PriceExplorerInner', 'Missing token details data points', { + currencyId, + duration: selectedDuration, + dataLength: data.priceHistory.length, + }) + } + logger.info('PriceExplorer.tsx', 'PriceExplorerInner', 'Token details data length', { + currencyId, + duration: selectedDuration, + dataLength: data.priceHistory.length, + }) + } + }, [data?.priceHistory, selectedDuration, currencyId]) + + const { hapticFeedback } = useHapticFeedback() + + const { convertFiatAmount } = useLocalizationContext() + const conversionRate = convertFiatAmount(1).amount + const shouldShowAnimatedDot = + selectedDuration === GraphQLApi.HistoryDuration.Day || selectedDuration === GraphQLApi.HistoryDuration.Hour + const additionalPadding = shouldShowAnimatedDot ? 40 : 0 + + const { lastPricePoint, convertedPriceHistory } = useMemo(() => { + const priceHistory = + data?.priceHistory?.map((point) => { + return { ...point, value: point.value * conversionRate } + }) ?? [] + + return { lastPricePoint: priceHistory.length - 1, convertedPriceHistory: priceHistory } + }, [data, conversionRate]) + + useLayoutAnimationOnChange(convertedPriceHistory.length) + + const convertedSpotValue = useDerivedValue(() => conversionRate * (data?.spot?.value.value ?? 0)) + const convertedSpot = useMemo((): TokenSpotData | undefined => { + return ( + data?.spot && { + ...data.spot, + value: convertedSpotValue, + } + ) + // oxlint-disable-next-line react/exhaustive-deps -- biome-parity: oxlint is stricter here + }, [data]) + + // Zoom out y-axis for low variance assets + const shouldZoomOut = useMemo(() => { + if (convertedPriceHistory.length === 0) { + return false + } + + const values = convertedPriceHistory.map((point) => point.value) + const min = Math.min(...values) + const max = Math.max(...values) + + return isLowVarianceRange({ min, max, duration: selectedDuration }) + }, [convertedPriceHistory, selectedDuration]) + + const chartYGutter = shouldZoomOut ? LOW_VARIANCE_Y_PADDING : DEFAULT_Y_PADDING + + const segmentedControlOptions = useMemo(() => { + return TIME_RANGES.map(([duration, label, elementName]) => ({ + value: duration, + wrapper: , + display: ( + + {label} + + ), + })) + }, []) + + if (!loading && !convertedSpot && selectedDuration === GraphQLApi.HistoryDuration.Day) { + return + } + + // Get the starting price for fiat delta calculation + const startingPrice = convertedPriceHistory[0]?.value + + return ( + + + + + + {convertedPriceHistory.length ? ( + + ) : ( + + + + )} + + + + + + + + ) +}) + +const PriceExplorerChart = memo(function PriceExplorerChart({ + tokenColor, + additionalPadding, + shouldShowAnimatedDot, + lastPricePoint, + yGutter, +}: { + tokenColor?: string + additionalPadding: number + shouldShowAnimatedDot: boolean + lastPricePoint: number + yGutter: number +}): JSX.Element { + const { chartHeight, chartWidth } = useChartDimensions() + const isRTL = I18nManager.isRTL + const { hapticFeedback } = useHapticFeedback() + + return ( + // TODO(MOB-2166): remove forced LTR direction + scaleX horizontal flip technique once react-native-wagmi-charts fixes this: https://github.com/coinjar/react-native-wagmi-charts/issues/136 + + + + {shouldShowAnimatedDot && ( + + )} + + + + + + ) +}) diff --git a/apps/mobile/src/components/PriceExplorer/PriceExplorerAnimatedNumber.tsx b/apps/mobile/src/components/PriceExplorer/PriceExplorerAnimatedNumber.tsx new file mode 100644 index 00000000..360dc785 --- /dev/null +++ b/apps/mobile/src/components/PriceExplorer/PriceExplorerAnimatedNumber.tsx @@ -0,0 +1,424 @@ +import { SCREEN_WIDTH } from '@gorhom/bottom-sheet' +import times from 'lodash/times' +import React, { useEffect, useState } from 'react' +import { StyleSheet, Text, View } from 'react-native' +import Animated, { + SharedValue, + useAnimatedReaction, + useAnimatedStyle, + useDerivedValue, + useSharedValue, + withSpring, + withTiming, +} from 'react-native-reanimated' +import { ValueAndFormattedWithAnimation } from 'src/components/PriceExplorer/usePrice' +import { PriceNumberOfDigits } from 'src/components/PriceExplorer/usePriceHistory' +import { TextLoaderWrapper, useSporeColors } from 'ui/src' +import { fonts } from 'ui/src/theme' +import { + ADDITIONAL_WIDTH_FOR_ANIMATIONS, + AnimatedCharStyles, + DIGIT_HEIGHT, + NUMBER_ARRAY, + NUMBER_WIDTH_ARRAY, +} from 'uniswap/src/components/AnimatedNumber/AnimatedNumber' +import { TopAndBottomGradient } from 'uniswap/src/components/AnimatedNumber/TopAndBottomGradient' +import { FiatCurrencyInfo } from 'uniswap/src/features/fiatOnRamp/types' +import { TestID } from 'uniswap/src/test/fixtures/testIDs' + +// if price per token has > 3 numbers before the decimal, start showing decimals in neutral3 +// otherwise, show entire price in neutral1 +const DEEMPHASIZED_DECIMALS_THRESHOLD = 3 + +function getEmphasizedNumberColor({ + index, + commaIndex, + emphasizedColor, + deemphasizedColor, +}: { + index: number + commaIndex: number + emphasizedColor: string + deemphasizedColor: string +}): string { + if (index >= commaIndex && commaIndex > DEEMPHASIZED_DECIMALS_THRESHOLD) { + return deemphasizedColor + } + return emphasizedColor +} + +const shouldUseSeparator = ({ + index, + commaIndex, + decimalPlaceIndex, +}: { + index: number + commaIndex: number + decimalPlaceIndex: number +}): boolean => { + 'worklet' + return (index - commaIndex) % 4 === 0 && index - commaIndex < 0 && index > commaIndex - decimalPlaceIndex +} + +const NumbersMain = ({ + color, + backgroundColor, + hidePlaceholder, +}: { + color: string + backgroundColor: string + hidePlaceholder(): void +}): JSX.Element | null => { + const [showNumbers, setShowNumbers] = useState(false) + const hideNumbers = useSharedValue(true) + + const animatedTextStyle = useAnimatedStyle(() => { + return { + opacity: hideNumbers.value ? 0 : 1, + } + }) + + useEffect(() => { + setTimeout(() => { + setShowNumbers(true) + }, 200) + }, []) + + const onLayout = (): void => { + hidePlaceholder() + hideNumbers.value = false + } + + if (showNumbers) { + return ( + + {NUMBER_ARRAY} + + ) + } + + return null +} + +const MemoizedNumbers = React.memo(NumbersMain) + +const RollNumber = ({ + chars, + index, + shouldAnimate, + decimalPlace, + hidePlaceholder, + commaIndex, + currency, +}: { + chars: SharedValue + index: number + shouldAnimate: SharedValue + decimalPlace: SharedValue + hidePlaceholder(): void + commaIndex: number + currency: FiatCurrencyInfo +}): JSX.Element => { + const colors = useSporeColors() + const numberColor = getEmphasizedNumberColor({ + index, + commaIndex, + emphasizedColor: colors.neutral1.val, + deemphasizedColor: colors.neutral3.val, + }) + + const animatedDigit = useDerivedValue(() => { + const char = chars.value[index - (commaIndex - decimalPlace.value)] + const number = char ? parseFloat(char) : undefined + return Number.isNaN(number) ? undefined : number + }, [chars, commaIndex, decimalPlace, index]) + + const animatedFontStyle = useAnimatedStyle(() => { + return { + color: numberColor, + } + }) + + const transformY = useDerivedValue(() => { + const endValue = animatedDigit.value !== undefined ? DIGIT_HEIGHT * -animatedDigit.value : 0 + + return shouldAnimate.value + ? withSpring(endValue, { + mass: 1, + damping: 29, + stiffness: 164, + overshootClamping: false, + restDisplacementThreshold: 0.01, + restSpeedThreshold: 2, + }) + : endValue + }, [animatedDigit, shouldAnimate]) + + const animatedWrapperStyle = useAnimatedStyle(() => { + const digitWidth = animatedDigit.value !== undefined ? (NUMBER_WIDTH_ARRAY[animatedDigit.value] ?? 0) : 0 + const rowWidth = digitWidth + ADDITIONAL_WIDTH_FOR_ANIMATIONS - 7 + + return { + transform: [ + { + translateY: transformY.value, + }, + ], + width: shouldAnimate.value ? withTiming(rowWidth) : rowWidth, + } + }) + + // need it in case the current value is eg $999.00 but maximum value in chart is more than $1,000.00 + // so it can hide the comma to avoid something like $,999.00 + const animatedWrapperSeparatorStyle = useAnimatedStyle(() => { + if (!shouldUseSeparator({ index, commaIndex, decimalPlaceIndex: decimalPlace.value })) { + return { + width: withTiming(0), + } + } + + const digitWidth = chars.value[index - (commaIndex - decimalPlace.value)] === currency.groupingSeparator ? 8 : 0 + + const rowWidth = Math.max(digitWidth, 0) + + return { + transform: [ + { + translateY: transformY.value, + }, + ], + width: shouldAnimate.value ? withTiming(rowWidth) : rowWidth, + } + }) + + if (index === commaIndex) { + return ( + + {currency.decimalSeparator} + + ) + } + + if ((index - commaIndex) % 4 === 0 && index - commaIndex < 0) { + return ( + + + {currency.groupingSeparator} + + + ) + } + + return ( + + + + ) +} + +const Numbers = ({ + price, + hidePlaceholder, + numberOfDigits, + currency, +}: { + price: ValueAndFormattedWithAnimation + hidePlaceholder(): void + numberOfDigits: PriceNumberOfDigits + currency: FiatCurrencyInfo +}): JSX.Element[] => { + const chars = useDerivedValue(() => { + return price.formatted.value + }, [price]) + + const decimalPlace = useDerivedValue(() => { + return price.formatted.value.indexOf(currency.decimalSeparator) + }, [currency.decimalSeparator, price.formatted]) + + const commaIndex = numberOfDigits.left + Math.floor((numberOfDigits.left - 1) / 3) + + return times(numberOfDigits.left + numberOfDigits.right + Math.floor((numberOfDigits.left - 1) / 3) + 1, (index) => ( + + + + )) +} + +function LoadingWrapper(): JSX.Element | null { + return ( + + + + ) +} + +const SCREEN_WIDTH_BUFFER = 50 + +function PriceExplorerAnimatedNumber({ + price, + numberOfDigits, + currency, + onAnimatedNumberReady, +}: { + price: ValueAndFormattedWithAnimation + numberOfDigits: PriceNumberOfDigits + currency: FiatCurrencyInfo + onAnimatedNumberReady: () => void +}): JSX.Element { + const colors = useSporeColors() + const hideShimmer = useSharedValue(false) + const scale = useSharedValue(1) + const offset = useSharedValue(0) + const animatedWrapperStyle = useAnimatedStyle(() => { + return { + opacity: price.value.value > 0 && hideShimmer.value ? 0 : 1, + position: 'absolute', + zIndex: 1000, + backgroundColor: colors.surface1.val, + } + }) + + const lessThanStyle = useAnimatedStyle(() => { + return { + width: price.formatted.value[0] === '<' ? withTiming(22) : withTiming(0), + } + }) + + useAnimatedReaction( + () => { + return Number( + [0, ...price.formatted.value.split('')].reduce((accumulator, currentValue) => { + if (NUMBER_WIDTH_ARRAY[Number(currentValue)]) { + return Number(accumulator) + Number(NUMBER_WIDTH_ARRAY[Number(currentValue)]) + } + return accumulator + }), + ) + }, + (priceWidth: number) => { + const newScale = (SCREEN_WIDTH - SCREEN_WIDTH_BUFFER) / priceWidth + + if (newScale < 1) { + const newOffset = (priceWidth - priceWidth * newScale) / 2 + scale.value = withTiming(newScale) + offset.value = withTiming(-newOffset) + } else if (scale.value < 1) { + scale.value = withTiming(1) + offset.value = withTiming(0) + } + }, + ) + + const hidePlaceholder = (): void => { + hideShimmer.value = true + onAnimatedNumberReady() + } + + const currencySymbol = ( + + {currency.fullSymbol} + + ) + + const lessThanSymbol = ( + + {'<'} + + ) + + const scaleWraper = useAnimatedStyle(() => { + return { + transform: [{ translateX: -SCREEN_WIDTH / 2 }, { scale: scale.value }, { translateX: SCREEN_WIDTH / 2 }], + } + }) + + return ( + + + + + + + {lessThanSymbol} + {currency.symbolAtFront && currencySymbol} + {Numbers({ price, hidePlaceholder, numberOfDigits, currency })} + {!currency.symbolAtFront && currencySymbol} + + + ) +} + +export default PriceExplorerAnimatedNumber + +const RowWrapper = StyleSheet.create({ + wrapperStyle: { + flexDirection: 'row', + }, +}) + +const Shimmer = StyleSheet.create({ + shimmerSize: { + height: DIGIT_HEIGHT, + width: 200, + }, +}) + +const AnimatedFontStyles = StyleSheet.create({ + fontStyle: { + fontFamily: fonts.heading2.family, + fontSize: fonts.heading2.fontSize, + fontWeight: fonts.heading2.fontWeight, + lineHeight: fonts.heading2.lineHeight, + }, +}) diff --git a/apps/mobile/src/components/PriceExplorer/PriceExplorerError.tsx b/apps/mobile/src/components/PriceExplorer/PriceExplorerError.tsx new file mode 100644 index 00000000..779f4657 --- /dev/null +++ b/apps/mobile/src/components/PriceExplorer/PriceExplorerError.tsx @@ -0,0 +1,41 @@ +import React, { ComponentProps } from 'react' +import { useTranslation } from 'react-i18next' +import { useChartDimensions } from 'src/components/PriceExplorer/useChartDimensions' +import { Flex, Text } from 'ui/src' +import { BaseCard } from 'uniswap/src/components/BaseCard/BaseCard' + +export function PriceExplorerError({ + showRetry, + onRetry, +}: Pick, 'onRetry'> & { + showRetry: boolean +}): JSX.Element { + const { t } = useTranslation() + const { chartHeight } = useChartDimensions() + + return ( + + + + { + '\u2013' // em dash + } + + + + + + + ) +} diff --git a/apps/mobile/src/components/PriceExplorer/Text.test.tsx b/apps/mobile/src/components/PriceExplorer/Text.test.tsx new file mode 100644 index 00000000..f9f6ced4 --- /dev/null +++ b/apps/mobile/src/components/PriceExplorer/Text.test.tsx @@ -0,0 +1,116 @@ +import React from 'react' +import * as charts from 'react-native-wagmi-charts' +import { DatetimeText, PriceText, RelativeChangeText } from 'src/components/PriceExplorer/Text' +import { render, within } from 'src/test/test-utils' +import { amounts } from 'uniswap/src/test/fixtures' + +jest.mock('react-native-wagmi-charts') +const mockedUseLineChartPrice = charts.useLineChartPrice as jest.Mock +const mockedUseLineChart = charts.useLineChart as jest.Mock +const mockedUseLineChartDatetime = charts.useLineChartDatetime as jest.Mock + +describe(PriceText, () => { + it('renders without error', () => { + mockedUseLineChartPrice.mockReturnValue({ value: '' }) + mockedUseLineChart.mockReturnValue({ data: [{ timestamp: 0, value: amounts.md().value }] }) + + const tree = render() + + expect(tree).toMatchSnapshot() + }) + + it('renders without error less than a dollar', () => { + mockedUseLineChartPrice.mockReturnValue({ value: '' }) + mockedUseLineChart.mockReturnValue({ data: [{ timestamp: 0, value: amounts.xs().value }] }) + + const tree = render() + + expect(tree).toMatchSnapshot() + }) + + it('renders loading state', () => { + mockedUseLineChartPrice.mockReturnValue({ value: '' }) + mockedUseLineChart.mockReturnValue({ data: [] }) + + const tree = render() + + expect(tree).toMatchSnapshot() + }) + + it('shows active price when scrubbing', async () => { + mockedUseLineChartPrice.mockReturnValue({ + value: { value: amounts.sm().value.toString() }, + }) + + const tree = render() + + const animatedText = await tree.findByTestId('price-text') + const wholePart = await within(animatedText).findByTestId('wholePart') + const decimalPart = await within(animatedText).findByTestId('decimalPart') + + expect(wholePart.props['text']).toBe(`$${amounts.sm().value}`) + expect(decimalPart.props['text']).toBe(`.00`) + }) +}) + +describe(RelativeChangeText, () => { + it('renders without error', () => { + mockedUseLineChart.mockReturnValue({ + isActive: { value: false }, + data: [{ value: 10 }, { value: 9 }], + currentIndex: { value: 1 }, + }) + + const tree = render() + + expect(tree).toMatchSnapshot() + }) + + it('renders loading state', () => { + mockedUseLineChart.mockReturnValue({ + isActive: { value: false }, + data: [{ value: 10 }, { value: 9 }], + currentIndex: { value: 1 }, + }) + + const tree = render() + + expect(tree).toMatchSnapshot() + }) + + it('shows active relative change when scrubbing', async () => { + mockedUseLineChart.mockReturnValue({ + isActive: { value: true }, + data: [{ value: 10 }, { value: 9 }], + currentIndex: { value: 1 }, + }) + + const tree = render() + + const text = await tree.findByTestId('relative-change-text') + expect(text.props['text']).toBe(`10.00%`) + }) +}) + +describe(DatetimeText, () => { + it('renders without error', () => { + mockedUseLineChartDatetime.mockReturnValue({ + value: { value: '123' }, + formatted: { value: 'Thursday, November 1st, 2023' }, + }) + const tree = render() + + expect(tree.toJSON()).toHaveStyle({ opacity: 1 }) + expect(tree).toMatchSnapshot() + }) + + it('renders loading state', () => { + mockedUseLineChartDatetime.mockReturnValue({ + value: { value: '123' }, + formatted: { value: 'Thursday, November 1st, 2023' }, + }) + const tree = render() + + expect(tree.toJSON()).toHaveStyle({ opacity: 0 }) + }) +}) diff --git a/apps/mobile/src/components/PriceExplorer/Text.tsx b/apps/mobile/src/components/PriceExplorer/Text.tsx new file mode 100644 index 00000000..b218fd80 --- /dev/null +++ b/apps/mobile/src/components/PriceExplorer/Text.tsx @@ -0,0 +1,174 @@ +import React, { useEffect } from 'react' +import Animated, { + cancelAnimation, + SharedValue, + useAnimatedStyle, + useDerivedValue, + useSharedValue, + withTiming, +} from 'react-native-reanimated' +import { useLineChart, useLineChartDatetime } from 'react-native-wagmi-charts' +import { AnimatedDecimalNumber } from 'src/components/PriceExplorer/AnimatedDecimalNumber' +import { useLineChartFiatDelta } from 'src/components/PriceExplorer/useFiatDelta' +import { useLineChartPrice, useLineChartRelativeChange } from 'src/components/PriceExplorer/usePrice' +import { AnimatedText } from 'src/components/text/AnimatedText' +import { numberToPercentWorklet } from 'src/utils/reanimated' +import { Flex, Text, useSporeColors } from 'ui/src' +import { AnimatedCaretChange } from 'ui/src/components/icons' +import { FiatCurrency } from 'uniswap/src/features/fiatCurrency/constants' +import { useAppFiatCurrency, useAppFiatCurrencyInfo } from 'uniswap/src/features/fiatCurrency/hooks' +import { useCurrentLocale } from 'uniswap/src/features/language/hooks' +import { TestID } from 'uniswap/src/test/fixtures/testIDs' +import { isAndroid } from 'utilities/src/platform' + +export function PriceText({ maxWidth }: { loading: boolean; maxWidth?: number }): JSX.Element { + const price = useLineChartPrice() + const colors = useSporeColors() + const currency = useAppFiatCurrency() + const { decimalSeparator, symbolAtFront } = useAppFiatCurrencyInfo() + + // TODO gary re-enabling this for USD/Euros only, replace with more scalable approach + const shouldFadePortfolioDecimals = + (currency === FiatCurrency.UnitedStatesDollar || currency === FiatCurrency.Euro) && symbolAtFront + + // TODO(MOB-2308): re-enable this when we have a better solution for handling the loading state + // if (loading) { + // return + // } + + return ( + + ) +} + +export function RelativeChangeText({ + loading, + spotRelativeChange, + startingPrice, + shouldTreatAsStablecoin = false, +}: { + loading: boolean + /** Price change for selected duration (used when not scrubbing chart) */ + spotRelativeChange?: SharedValue + startingPrice?: number + shouldTreatAsStablecoin?: boolean +}): JSX.Element { + const colors = useSporeColors() + const { isActive } = useLineChart() + + // Calculate relative change from chart data (used when scrubbing) + const calculatedRelativeChange = useLineChartRelativeChange() + + const fiatDelta = useLineChartFiatDelta({ + startingPrice, + shouldTreatAsStablecoin, + }) + + // Decide which source to use: API's 24hr when idle, chart's when scrubbing + // This ensures the color shows immediately with correct API data + const hasSpotData = !!spotRelativeChange + const shouldUseSpotData = useDerivedValue(() => !isActive.value && hasSpotData) + + const relativeChange = useDerivedValue(() => { + return shouldUseSpotData.value + ? (spotRelativeChange?.value ?? calculatedRelativeChange.value.value) + : calculatedRelativeChange.value.value + }) + + const relativeChangeFormatted = useDerivedValue(() => { + if (shouldUseSpotData.value) { + return spotRelativeChange?.value + ? numberToPercentWorklet(spotRelativeChange.value, { precision: 2, absolute: true }) + : calculatedRelativeChange.formatted.value + } + return calculatedRelativeChange.formatted.value + }) + + // Shared value for fade-in animation; always start hidden since + // the component always mounts with loading=true + const contentOpacity = useSharedValue(0) + + useEffect(() => { + if (!loading) { + contentOpacity.value = withTiming(1, { duration: 200 }) + } else { + cancelAnimation(contentOpacity) + contentOpacity.value = 0 + } + // oxlint-disable-next-line react/exhaustive-deps -- biome-parity: oxlint is stricter here + }, [loading]) + + const animatedContentStyle = useAnimatedStyle(() => ({ + opacity: contentOpacity.value, + })) + + const changeColor = useDerivedValue(() => { + // Round the range to 2 decimal places to check if is equal to 0 + const absRelativeChange = Math.round(Math.abs(relativeChange.value) * 100) + if (absRelativeChange === 0) { + return colors.neutral3.val + } + return relativeChange.value > 0 ? colors.statusSuccess.val : colors.statusCritical.val + }) + + const caretStyle = useAnimatedStyle(() => ({ + color: changeColor.value, + transform: [ + { rotate: relativeChange.value >= 0 ? '180deg' : '0deg' }, + // fix vertical centering + { translateY: relativeChange.value >= 0 ? -1 : 1 }, + ], + })) + + // Combine fiat delta and percentage in a derived value + const combinedText = useDerivedValue(() => { + const delta = fiatDelta.formatted.value + if (delta) { + return `${delta} (${relativeChangeFormatted.value})` + } + return relativeChangeFormatted.value + }) + + return ( + + {loading && ( + // We use `no-shimmer` here to speed up the first render and so that this skeleton renders + // at the exact same time as the animated number skeleton. + // TODO(WALL-5215): we can remove `no-shimmer` once we have a better Skeleton component. + + )} + {/* Must always mount this component to avoid stale values on initial render */} + + + + + + + + ) +} + +export function DatetimeText({ loading }: { loading: boolean }): JSX.Element { + const locale = useCurrentLocale() + // `datetime` when scrubbing the chart + const datetime = useLineChartDatetime({ locale }) + + return ( + + + + ) +} diff --git a/apps/mobile/src/components/PriceExplorer/TokenPriceHistory.graphql b/apps/mobile/src/components/PriceExplorer/TokenPriceHistory.graphql new file mode 100644 index 00000000..1692673a --- /dev/null +++ b/apps/mobile/src/components/PriceExplorer/TokenPriceHistory.graphql @@ -0,0 +1,43 @@ +query TokenPriceHistory( + $contract: ContractInput! + $duration: HistoryDuration = DAY + $maxHistoryLength: Int = 1000 +) { + tokenProjects(contracts: [$contract]) { + id + name + markets(currencies: [USD]) { + id + price { + value + } + pricePercentChange24h { + value + } + priceHistory(duration: $duration, maxLength: $maxHistoryLength) { + timestamp + value + } + } + tokens { + id + chain + address + symbol + decimals + market(currency: USD) { + id + price { + value + } + pricePercentChange24h: pricePercentChange(duration: DAY) { + value + } + priceHistory(duration: $duration, maxLength: $maxHistoryLength) { + timestamp + value + } + } + } + } +} diff --git a/apps/mobile/src/components/PriceExplorer/__snapshots__/Text.test.tsx.snap b/apps/mobile/src/components/PriceExplorer/__snapshots__/Text.test.tsx.snap new file mode 100644 index 00000000..2c9cdfe6 --- /dev/null +++ b/apps/mobile/src/components/PriceExplorer/__snapshots__/Text.test.tsx.snap @@ -0,0 +1,794 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DatetimeText renders without error 1`] = ` + + + +`; + +exports[`PriceText renders loading state 1`] = ` + + + +`; + +exports[`PriceText renders without error 1`] = ` + + + + +`; + +exports[`PriceText renders without error less than a dollar 1`] = ` + + + + +`; + +exports[`RelativeChangeText renders loading state 1`] = ` + + + + + + 00.00% + + + + + + + + + + + + + + + + +`; + +exports[`RelativeChangeText renders without error 1`] = ` + + + + + + + + + + + + +`; diff --git a/apps/mobile/src/components/PriceExplorer/constants.ts b/apps/mobile/src/components/PriceExplorer/constants.ts new file mode 100644 index 00000000..2833825f --- /dev/null +++ b/apps/mobile/src/components/PriceExplorer/constants.ts @@ -0,0 +1,19 @@ +import { GraphQLApi } from '@universe/api' +import { ElementName } from 'uniswap/src/features/telemetry/constants' +import i18n from 'uniswap/src/i18n' + +export const BUTTON_PADDING = 20 + +export const CURSOR_INNER_SIZE = 12 +export const CURSOR_SIZE = CURSOR_INNER_SIZE + 6 + +export const TIME_RANGES = [ + [GraphQLApi.HistoryDuration.Hour, i18n.t('token.priceExplorer.timeRangeLabel.hour'), ElementName.TimeFrame1H], + [GraphQLApi.HistoryDuration.Day, i18n.t('token.priceExplorer.timeRangeLabel.day'), ElementName.TimeFrame1D], + [GraphQLApi.HistoryDuration.Week, i18n.t('token.priceExplorer.timeRangeLabel.week'), ElementName.TimeFrame1W], + [GraphQLApi.HistoryDuration.Month, i18n.t('token.priceExplorer.timeRangeLabel.month'), ElementName.TimeFrame1M], + [GraphQLApi.HistoryDuration.Year, i18n.t('token.priceExplorer.timeRangeLabel.year'), ElementName.TimeFrame1Y], + [GraphQLApi.HistoryDuration.Max, i18n.t('common.all'), ElementName.TimeFrameAll], +] as const + +export const NUM_GRAPHS = TIME_RANGES.length diff --git a/apps/mobile/src/components/PriceExplorer/useChartDimensions.test.ts b/apps/mobile/src/components/PriceExplorer/useChartDimensions.test.ts new file mode 100644 index 00000000..91cb356d --- /dev/null +++ b/apps/mobile/src/components/PriceExplorer/useChartDimensions.test.ts @@ -0,0 +1,37 @@ +import { renderHook } from '@testing-library/react-native' +import { Dimensions } from 'react-native' +import { useChartDimensions } from 'src/components/PriceExplorer/useChartDimensions' +import { heightBreakpoints } from 'ui/src/theme' + +const sharedDimensions = { + height: 1000, + width: 1000, + scale: 1, + fontScale: 1, +} + +describe(useChartDimensions, () => { + it('returns small chart height for small screens', () => { + jest.spyOn(Dimensions, 'get').mockReturnValue({ ...sharedDimensions, height: heightBreakpoints.short - 1 }) + const { result } = renderHook(() => useChartDimensions()) + + expect(result.current).toEqual({ + chartHeight: 130, + chartWidth: 1000, + buttonWidth: expect.any(Number), + labelWidth: expect.any(Number), + }) + }) + + it('returns large chart height for large screens', () => { + jest.spyOn(Dimensions, 'get').mockReturnValue({ ...sharedDimensions, height: heightBreakpoints.short }) + const { result } = renderHook(() => useChartDimensions()) + + expect(result.current).toEqual({ + chartHeight: 215, + chartWidth: 1000, + buttonWidth: expect.any(Number), + labelWidth: expect.any(Number), + }) + }) +}) diff --git a/apps/mobile/src/components/PriceExplorer/useChartDimensions.ts b/apps/mobile/src/components/PriceExplorer/useChartDimensions.ts new file mode 100644 index 00000000..e08ee07c --- /dev/null +++ b/apps/mobile/src/components/PriceExplorer/useChartDimensions.ts @@ -0,0 +1,29 @@ +import { BUTTON_PADDING, NUM_GRAPHS } from 'src/components/PriceExplorer/constants' +import { useDeviceDimensions } from 'ui/src/hooks/useDeviceDimensions' +import { heightBreakpoints } from 'ui/src/theme' + +type ChartDimensions = { + chartHeight: number + chartWidth: number + buttonWidth: number + labelWidth: number +} + +// TODO (MOB-1387): account for height in a more dynamic way to ensure +// that "Your balance" section will always show above the fold +export function useChartDimensions(): ChartDimensions { + const { fullHeight, fullWidth } = useDeviceDimensions() + + const chartHeight = fullHeight < heightBreakpoints.short ? 130 : 215 + const chartWidth = fullWidth + + const buttonWidth = chartWidth / NUM_GRAPHS + const labelWidth = buttonWidth - BUTTON_PADDING * 2 + + return { + chartHeight, + chartWidth, + buttonWidth, + labelWidth, + } +} diff --git a/apps/mobile/src/components/PriceExplorer/useFiatDelta.tsx b/apps/mobile/src/components/PriceExplorer/useFiatDelta.tsx new file mode 100644 index 00000000..68098b73 --- /dev/null +++ b/apps/mobile/src/components/PriceExplorer/useFiatDelta.tsx @@ -0,0 +1,119 @@ +import { useCallback, useMemo } from 'react' +import { runOnJS, SharedValue, useAnimatedReaction, useDerivedValue, useSharedValue } from 'react-native-reanimated' +import { useLineChart } from 'react-native-wagmi-charts' +import { useFormatChartFiatDelta } from 'uniswap/src/features/fiatCurrency/hooks/useFormatChartFiatDelta' +import { useLocalizationContext } from 'uniswap/src/features/language/LocalizationContext' + +interface UseFiatDeltaParams { + startingPrice?: number + shouldTreatAsStablecoin?: boolean +} + +interface FiatDeltaResult { + formatted: SharedValue +} + +/** + * Hook to calculate and format fiat delta for price charts. + * Optimized to only calculate deltas on-demand during scrubbing, reducing memory usage. + */ +export function useLineChartFiatDelta({ + startingPrice, + shouldTreatAsStablecoin = false, +}: UseFiatDeltaParams): FiatDeltaResult { + const { currentIndex, data, isActive } = useLineChart() + const { conversionRate } = useLocalizationContext() + const { formatChartFiatDelta } = useFormatChartFiatDelta() + + // Shared value for the current scrubbing delta + const scrubbingDeltaSharedValue = useSharedValue('') + + // Pre-calculate only the last point's delta (for non-scrubbing state) + const lastPointDelta = useMemo(() => { + if (!startingPrice || !data || !conversionRate || data.length === 0) { + return '' + } + + const convertedStartPrice = startingPrice * conversionRate + const lastPoint = data[data.length - 1] + if (!lastPoint) { + return '' + } + const convertedEndPrice = lastPoint.value * conversionRate + + const delta = formatChartFiatDelta({ + startingPrice: convertedStartPrice, + endingPrice: convertedEndPrice, + isStablecoin: shouldTreatAsStablecoin, + }) + + return delta.formatted + }, [startingPrice, data, conversionRate, formatChartFiatDelta, shouldTreatAsStablecoin]) + + // Calculate delta for current scrubbing position + const calculateCurrentDelta = useMemo(() => { + return (index: number) => { + if (!startingPrice || !data || !conversionRate) { + return '' + } + + const currentPoint = data[index] + if (!currentPoint) { + return '' + } + + const convertedStartPrice = startingPrice * conversionRate + const convertedEndPrice = currentPoint.value * conversionRate + + const delta = formatChartFiatDelta({ + startingPrice: convertedStartPrice, + endingPrice: convertedEndPrice, + isStablecoin: shouldTreatAsStablecoin, + }) + + return delta.formatted + } + }, [startingPrice, data, conversionRate, formatChartFiatDelta, shouldTreatAsStablecoin]) + + // Callback for updating the scrubbing delta from the UI thread + const updateScrubbingDelta = useCallback( + (index: number) => { + scrubbingDeltaSharedValue.value = calculateCurrentDelta(index) + }, + // oxlint-disable-next-line react/exhaustive-deps -- biome-parity: oxlint is stricter here + [calculateCurrentDelta], + ) + + // Track current index changes with useAnimatedReaction + useAnimatedReaction( + () => { + return currentIndex.value + }, + (currentIndexValue) => { + if (data && data.length > 0) { + const safeIndex = Math.min(Math.max(0, Math.round(currentIndexValue)), data.length - 1) + runOnJS(updateScrubbingDelta)(safeIndex) + } + }, + [data, updateScrubbingDelta], + ) + + // Create a derived value that decides which delta to show + /* oxlint-disable react/exhaustive-deps -- isActive and scrubbingDeltaSharedValue are Reanimated shared values tracked automatically */ + const formatted = useDerivedValue(() => { + if (!data || data.length === 0) { + return '' + } + + // When scrubbing, use the current scrubbing delta + if (isActive.value) { + return scrubbingDeltaSharedValue.value + } + + // When not scrubbing, use the pre-calculated last point delta + return lastPointDelta + }, [lastPointDelta, data]) + /* oxlint-enable react/exhaustive-deps */ + + return { formatted } +} diff --git a/apps/mobile/src/components/PriceExplorer/usePrice.test.ts b/apps/mobile/src/components/PriceExplorer/usePrice.test.ts new file mode 100644 index 00000000..563ea30d --- /dev/null +++ b/apps/mobile/src/components/PriceExplorer/usePrice.test.ts @@ -0,0 +1,306 @@ +import { waitFor } from '@testing-library/react-native' +import { makeMutable } from 'react-native-reanimated' +import { + TLineChartData, + useLineChart, + useLineChartPrice as useRNWagmiChartLineChartPrice, +} from 'react-native-wagmi-charts' +import { act } from 'react-test-renderer' +import { useLineChartPrice, useLineChartRelativeChange } from 'src/components/PriceExplorer/usePrice' +import { renderHookWithProviders } from 'src/test/render' + +jest.mock('react-native-wagmi-charts') + +const cursorValue = makeMutable('') +const cursorFormattedValue = makeMutable('-') + +const currentIndex = makeMutable(0) +const isActive = makeMutable(false) + +const mockData = (args: { data?: TLineChartData; currentIndex?: number; isActive?: boolean } = {}): void => { + currentIndex.value = args.currentIndex ?? 0 + isActive.value = args.isActive ?? false + // react-native-wagmi-charts is mocked so we can mock the return + // of useLineChart + const mockedFunction = useLineChart as ReturnType + mockedFunction.mockReturnValue({ + data: args.data ?? [], + currentIndex, + isActive, + }) +} + +const mockCursorPrice = (value?: string): void => { + cursorValue.value = value ?? '' + cursorFormattedValue.value = value ? `$${value}` : '-' + + // react-native-wagmi-charts is mocked so we can mock the return + // of useLineChartPrice + const mockedFunction = useRNWagmiChartLineChartPrice as ReturnType + mockedFunction.mockReturnValue({ + value: cursorValue, + formatted: cursorFormattedValue, + }) +} + +describe(useLineChartPrice, () => { + beforeEach(() => { + const originalModule = jest.requireActual('react-native-wagmi-charts') + ;(useLineChart as ReturnType).mockImplementation(originalModule.useLineChart) + ;(useRNWagmiChartLineChartPrice as ReturnType).mockImplementation(originalModule.useLineChartPrice) + }) + + afterAll(() => { + jest.clearAllMocks() + }) + + it('returns correct initial values', () => { + const { result } = renderHookWithProviders(useLineChartPrice) + + expect(result.current).toEqual({ + value: expect.objectContaining({ value: 0 }), + formatted: expect.objectContaining({ value: '-' }), + shouldAnimate: expect.objectContaining({ value: true }), + }) + }) + + describe('when there is no active cursor price', () => { + beforeEach(() => { + // Mock data before all test to show that the currentSpot has higher + // priority than the last value from data + mockData({ + data: [ + { value: 1, timestamp: 1 }, + { value: 2, timestamp: 2 }, + ], + }) + }) + + it('returns last value from data if currentSpot is not provided', async () => { + const { result, rerender } = renderHookWithProviders(useLineChartPrice) + + expect(result.current).toEqual({ + value: expect.objectContaining({ value: 2 }), + formatted: expect.objectContaining({ value: '$2.00' }), + shouldAnimate: expect.objectContaining({ value: true }), + }) + + // Update data + mockData({ + data: [ + { value: 1, timestamp: 1 }, + { value: 2, timestamp: 2 }, + { value: 3, timestamp: 3 }, + ], + }) + // Re-render to trigger the update (normally the useLineChart hook + // would trigger re-render) + await act(() => rerender()) + + await waitFor(() => { + expect(result.current).toEqual({ + value: expect.objectContaining({ value: 3 }), + formatted: expect.objectContaining({ value: '$3.00' }), + shouldAnimate: expect.objectContaining({ value: true }), + }) + }) + }) + + it('returns currentSpot if it is provided', async () => { + const spotPrice = makeMutable(1) + const { result } = renderHookWithProviders(() => useLineChartPrice(spotPrice), { + initialProps: spotPrice, + }) + + expect(result.current).toEqual({ + value: expect.objectContaining({ value: 1 }), + formatted: expect.objectContaining({ value: '$1.00' }), + shouldAnimate: expect.objectContaining({ value: true }), + }) + + spotPrice.value = 2 + + await waitFor(() => { + expect(result.current).toEqual({ + value: expect.objectContaining({ value: 2 }), + formatted: expect.objectContaining({ value: '$2.00' }), + shouldAnimate: expect.objectContaining({ value: true }), + }) + }) + }) + }) + + describe('when there is an active cursor price', () => { + beforeEach(() => { + // Mock data before all test to show that the currentSpot has higher + // priority than the last value from data + mockData({ + data: [ + { value: 1, timestamp: 1 }, + { value: 2, timestamp: 2 }, + ], + }) + }) + + it('returns active cursor price even if currentSpot and data are provided', async () => { + mockCursorPrice('3') + const { result } = renderHookWithProviders(useLineChartPrice, { + initialProps: makeMutable(4), + }) + + expect(result.current).toEqual({ + value: expect.objectContaining({ value: 3 }), + formatted: expect.objectContaining({ value: '$3.00' }), + shouldAnimate: expect.objectContaining({ value: true }), + }) + }) + + it('updates returned active cursor price when it changes', async () => { + mockCursorPrice('1') + const { result } = renderHookWithProviders(useLineChartPrice, { + initialProps: makeMutable(4), + }) + + expect(result.current).toEqual( + expect.objectContaining({ + value: expect.objectContaining({ value: 1 }), + formatted: expect.objectContaining({ value: '$1.00' }), + }), + ) + + mockCursorPrice('2') // updates shared values + + await waitFor(() => { + expect(result.current).toEqual( + expect.objectContaining({ + value: expect.objectContaining({ value: 2 }), + formatted: expect.objectContaining({ value: '$2.00' }), + }), + ) + }) + }) + + it('sets shouldAnimate to false when cursor price changes', async () => { + mockCursorPrice() // uze mocked value and formatted value + const { result } = renderHookWithProviders(useLineChartPrice) + + // first update (previous value will be null as it's the first update after initial render) + mockCursorPrice('1') + + await waitFor(() => { + expect(result.current).toEqual( + expect.objectContaining({ + value: expect.objectContaining({ value: 1 }), + shouldAnimate: expect.objectContaining({ value: true }), + }), + ) + }) + + // second update (shouldAnimate should be false when the chart is + // scrubbed and the cursor price changes) + mockCursorPrice('2') + + await waitFor(() => { + expect(result.current).toEqual( + expect.objectContaining({ + value: expect.objectContaining({ value: 2 }), + shouldAnimate: expect.objectContaining({ value: false }), + }), + ) + }) + }) + }) +}) + +describe(useLineChartRelativeChange, () => { + const chartData1 = [ + { timestamp: 1, value: 1 }, + { timestamp: 2, value: 0.1 }, + { timestamp: 3, value: 10 }, + { timestamp: 4, value: 5 }, + ] + const chartData2 = [ + { timestamp: 1, value: 1 }, + { timestamp: 2, value: 0.1 }, + { timestamp: 3, value: 10 }, + { timestamp: 4, value: 20 }, + ] + + beforeAll(() => { + mockData() + }) + + it('returns correct initial values', () => { + const { result } = renderHookWithProviders(() => useLineChartRelativeChange()) + + expect(result.current).toEqual({ + value: expect.objectContaining({ value: 0 }), + formatted: expect.objectContaining({ value: '0.00%' }), + }) + }) + + describe('when spotRelativeChange is not provided', () => { + it('calculates relative change based on the open and close price values', () => { + mockData({ data: chartData1 }) + const { result } = renderHookWithProviders(() => useLineChartRelativeChange()) + + // 1 -> 5 (+400%) + expect(result.current).toEqual({ + value: expect.objectContaining({ value: 400 }), + formatted: expect.objectContaining({ value: '400.00%' }), + }) + }) + + it('updates the relative change when the currentIndex changes when active', async () => { + mockData({ data: chartData1 }) + const { result } = renderHookWithProviders(() => useLineChartRelativeChange()) + + // 1 -> 5 (+400%) + expect(result.current).toEqual( + expect.objectContaining({ + value: expect.objectContaining({ value: 400 }), + formatted: expect.objectContaining({ value: '400.00%' }), + }), + ) + + currentIndex.value = 2 + isActive.value = true + + // 1 -> 10 (+900%) + await waitFor(() => { + expect(result.current).toEqual( + expect.objectContaining({ + value: expect.objectContaining({ value: 900 }), + formatted: expect.objectContaining({ value: '900.00%' }), + }), + ) + }) + }) + + it('updates the relative change when the data changes', async () => { + mockData({ data: chartData1 }) + const { result, rerender } = renderHookWithProviders(() => useLineChartRelativeChange()) + + // 1 -> 5 (+400%) + expect(result.current).toEqual({ + value: expect.objectContaining({ value: 400 }), + formatted: expect.objectContaining({ value: '400.00%' }), + }) + + await act(() => { + mockData({ data: chartData2 }) + // Trigger rerender (it will be normally triggered when the data + // returned from the useLineChart hook changes) + rerender() + }) + + // 1 -> 20 (+1900%) + await waitFor(() => { + expect(result.current).toEqual({ + value: expect.objectContaining({ value: 1900 }), + formatted: expect.objectContaining({ value: '1900.00%' }), + }) + }) + }) + }) +}) diff --git a/apps/mobile/src/components/PriceExplorer/usePrice.tsx b/apps/mobile/src/components/PriceExplorer/usePrice.tsx new file mode 100644 index 00000000..5d1385ec --- /dev/null +++ b/apps/mobile/src/components/PriceExplorer/usePrice.tsx @@ -0,0 +1,109 @@ +import { useMemo } from 'react' +import { SharedValue, useAnimatedReaction, useDerivedValue, useSharedValue } from 'react-native-reanimated' +import { useLineChart, useLineChartPrice as useRNWagmiChartLineChartPrice } from 'react-native-wagmi-charts' +import { numberToLocaleStringWorklet, numberToPercentWorklet } from 'src/utils/reanimated' +import { useAppFiatCurrencyInfo } from 'uniswap/src/features/fiatCurrency/hooks' +import { useCurrentLocale } from 'uniswap/src/features/language/hooks' + +export type ValueAndFormatted = { + value: Readonly> + formatted: Readonly> +} + +export type ValueAndFormattedWithAnimation = ValueAndFormatted & { + shouldAnimate: Readonly> +} + +/** + * Wrapper around react-native-wagmi-chart#useLineChartPrice + * @returns latest price when not scrubbing and active price when scrubbing + */ +export function useLineChartPrice(currentSpot?: SharedValue): ValueAndFormattedWithAnimation { + const { value: activeCursorPrice } = useRNWagmiChartLineChartPrice({ + // do not round + precision: 18, + }) + const { data } = useLineChart() + const shouldAnimate = useSharedValue(true) + + useAnimatedReaction( + () => { + return activeCursorPrice.value + }, + (currentValue, previousValue) => { + if (previousValue && currentValue && shouldAnimate.value) { + shouldAnimate.value = false + } + }, + ) + const currencyInfo = useAppFiatCurrencyInfo() + const locale = useCurrentLocale() + + const price = useDerivedValue(() => { + if (activeCursorPrice.value) { + // active price when scrubbing the chart + return Number(activeCursorPrice.value) + } + + shouldAnimate.value = true + // show spot price when chart not scrubbing, or if not available, show the last price in the chart + return currentSpot?.value ?? data?.[data.length - 1]?.value ?? 0 + }) + const priceFormatted = useDerivedValue(() => { + const { symbol, code } = currencyInfo + return numberToLocaleStringWorklet({ + value: price.value, + locale, + options: { + style: 'currency', + currency: code, + }, + symbol, + }) + }) + + return useMemo( + () => ({ + value: price, + formatted: priceFormatted, + shouldAnimate, + }), + // oxlint-disable-next-line react/exhaustive-deps -- biome-parity: oxlint is stricter here + [], + ) +} + +/** + * @returns % change for the active history duration when not scrubbing and % + * change between active index and period start when scrubbing + */ +export function useLineChartRelativeChange(): ValueAndFormatted { + const { currentIndex, data, isActive } = useLineChart() + + const relativeChange = useDerivedValue(() => { + if (!data) { + return 0 + } + + // when scrubbing, compute relative change from open price + const openPrice = data[0]?.value + + // scrubbing: close price is active price + // not scrubbing: close price is period end price + const closePrice = isActive.value ? data[currentIndex.value]?.value : data[data.length - 1]?.value + + if (openPrice === undefined || closePrice === undefined || openPrice === 0) { + return 0 + } + + const change = ((closePrice - openPrice) / openPrice) * 100 + + return change + }) + + const relativeChangeFormatted = useDerivedValue(() => { + return numberToPercentWorklet(relativeChange.value, { precision: 2, absolute: true }) + }) + + return { value: relativeChange, formatted: relativeChangeFormatted } +} diff --git a/apps/mobile/src/components/PriceExplorer/usePriceHistory.test.ts b/apps/mobile/src/components/PriceExplorer/usePriceHistory.test.ts new file mode 100644 index 00000000..1937b53c --- /dev/null +++ b/apps/mobile/src/components/PriceExplorer/usePriceHistory.test.ts @@ -0,0 +1,418 @@ +import { waitFor } from '@testing-library/react-native' +import { GraphQLApi } from '@universe/api' +import { act } from 'react-test-renderer' +import { useTokenPriceHistory } from 'src/components/PriceExplorer/usePriceHistory' +import { renderHookWithProviders } from 'src/test/render' +import { USDC, USDC_ARBITRUM, USDC_BASE, USDC_OPTIMISM, USDC_POLYGON } from 'uniswap/src/constants/tokens' +import { + getLatestPrice, + priceHistory, + SAMPLE_CURRENCY_ID_1, + timestampedAmount, + token, + tokenMarket, + tokenProject, + tokenProjectMarket, + usdcTokenProject, +} from 'uniswap/src/test/fixtures' +import { queryResolvers } from 'uniswap/src/test/utils' + +const mockTokenProjectsQuery = (historyPrices: number[]) => (): GraphQLApi.TokenProject[] => { + const history = historyPrices.map((value) => timestampedAmount({ value })) + + return [ + tokenProject({ + markets: [ + tokenProjectMarket({ + priceHistory: history, + price: getLatestPrice(history), + }), + ], + }), + ] +} + +const formatPriceHistory = (history: GraphQLApi.TimestampedAmount[]): Omit[] => + history.map(({ timestamp, value }) => ({ value, timestamp: timestamp * 1000 })) + +/** + * Creates a USDC token project with matching priceHistory for both the aggregated market + * and the Ethereum token's market. This ensures the hook returns the expected data since + * it prefers per-chain price history over aggregated price history. + */ +const createUsdcTokenProjectWithMatchingPriceHistory = ( + history: (GraphQLApi.TimestampedAmount | undefined)[], +): GraphQLApi.TokenProject => ({ + ...usdcTokenProject({ priceHistory: history }), + tokens: [ + token({ sdkToken: USDC, market: tokenMarket({ priceHistory: history }) }), + token({ sdkToken: USDC_POLYGON }), + token({ sdkToken: USDC_ARBITRUM }), + token({ sdkToken: USDC_BASE, market: tokenMarket() }), + token({ sdkToken: USDC_OPTIMISM }), + ], +}) + +describe(useTokenPriceHistory, () => { + it('returns correct initial values', async () => { + const { result } = renderHookWithProviders(() => useTokenPriceHistory({ currencyId: SAMPLE_CURRENCY_ID_1 })) + + expect(result.current.loading).toBe(true) + expect(result.current.error).toBe(false) + expect(result.current.data).toEqual({ + priceHistory: undefined, + spot: undefined, + }) + expect(result.current.selectedDuration).toBe(GraphQLApi.HistoryDuration.Day) // default initial duration + expect(result.current.numberOfDigits).toEqual({ + left: 0, + right: 0, + }) + + await waitFor(() => { + expect(result.current.loading).toBe(false) + expect(result.current.error).toBe(false) + }) + }) + + it('returns on-chain spot price if off-chain spot price is not available', async () => { + const market = tokenMarket() + const { resolvers } = queryResolvers({ + tokenProjects: () => [ + usdcTokenProject({ + markets: undefined, + // Ensure token has the correct chain to match SAMPLE_CURRENCY_ID_1 (Ethereum) + tokens: [token({ market, chain: GraphQLApi.Chain.Ethereum })], + }), + ], + }) + const { result } = renderHookWithProviders(() => useTokenPriceHistory({ currencyId: SAMPLE_CURRENCY_ID_1 }), { + resolvers, + }) + + await waitFor(() => { + expect(result.current.loading).toBe(false) + expect(result.current.error).toBe(false) + }) + + await waitFor(() => { + expect(result.current.data?.spot).toEqual({ + // oxlint-disable-next-line typescript/no-unnecessary-condition + value: expect.objectContaining({ value: market.price?.value }), + // oxlint-disable-next-line typescript/no-unnecessary-condition + relativeChange: expect.objectContaining({ value: market.pricePercentChange?.value }), + }) + }) + }) + + it('handles gracefully when no token matches the currencyId chain', async () => { + const aggregatedMarket = tokenProjectMarket() + const { resolvers } = queryResolvers({ + tokenProjects: () => [ + usdcTokenProject({ + markets: [aggregatedMarket], + // Provide tokens for different chains, but none matching SAMPLE_CURRENCY_ID_1 (Ethereum) + tokens: [token({ chain: GraphQLApi.Chain.Polygon }), token({ chain: GraphQLApi.Chain.Arbitrum })], + }), + ], + }) + const { result } = renderHookWithProviders(() => useTokenPriceHistory({ currencyId: SAMPLE_CURRENCY_ID_1 }), { + resolvers, + }) + + await waitFor(() => { + expect(result.current.loading).toBe(false) + expect(result.current.error).toBe(false) + }) + + // Should fall back to aggregated market data when no chain-specific token is found + await waitFor(() => { + expect(result.current.data?.spot).toEqual({ + value: expect.objectContaining({ value: aggregatedMarket.price.value }), + relativeChange: expect.objectContaining({ value: aggregatedMarket.pricePercentChange24h.value }), + }) + }) + }) + + describe('correct number of digits', () => { + it('for max price greater than 1', async () => { + const { resolvers } = queryResolvers({ + tokenProjects: mockTokenProjectsQuery([0.00001, 1, 111_111_111.1111]), + }) + const { result } = renderHookWithProviders(() => useTokenPriceHistory({ currencyId: SAMPLE_CURRENCY_ID_1 }), { + resolvers, + }) + + await waitFor(() => { + expect(result.current.loading).toBe(false) + expect(result.current.error).toBe(false) + }) + + expect(result.current.numberOfDigits).toEqual({ + left: 9, + right: 2, + }) + }) + + it('for max price less than 1', async () => { + const { resolvers } = queryResolvers({ + tokenProjects: mockTokenProjectsQuery([0.001, 0.002]), + }) + const { result } = renderHookWithProviders(() => useTokenPriceHistory({ currencyId: SAMPLE_CURRENCY_ID_1 }), { + resolvers, + }) + + await waitFor(() => { + expect(result.current.loading).toBe(false) + expect(result.current.error).toBe(false) + }) + + expect(result.current.numberOfDigits).toEqual({ + left: 1, + right: 16, + }) + }) + + it('for max price equal to 1', async () => { + const { resolvers } = queryResolvers({ tokenProjects: mockTokenProjectsQuery([0.1, 1]) }) + const { result } = renderHookWithProviders(() => useTokenPriceHistory({ currencyId: SAMPLE_CURRENCY_ID_1 }), { + resolvers, + }) + + await waitFor(() => { + expect(result.current.loading).toBe(false) + expect(result.current.error).toBe(false) + }) + + expect(result.current.numberOfDigits).toEqual({ + left: 1, + right: 2, + }) + }) + }) + + describe('correct price history', () => { + it('properly formats price history entries', async () => { + const history = priceHistory() + const { resolvers } = queryResolvers({ + tokenProjects: () => [createUsdcTokenProjectWithMatchingPriceHistory(history)], + }) + const { result } = renderHookWithProviders(() => useTokenPriceHistory({ currencyId: SAMPLE_CURRENCY_ID_1 }), { + resolvers, + }) + + await waitFor(() => { + expect(result.current.loading).toBe(false) + expect(result.current.error).toBe(false) + }) + + expect(result.current.data?.priceHistory).toEqual(formatPriceHistory(history)) + }) + + it('filters out invalid price history entries', async () => { + const invalidHistory = [undefined, timestampedAmount({ value: 1 }), undefined, timestampedAmount({ value: 2 })] + const { resolvers } = queryResolvers({ + tokenProjects: () => [createUsdcTokenProjectWithMatchingPriceHistory(invalidHistory)], + }) + const { result } = renderHookWithProviders(() => useTokenPriceHistory({ currencyId: SAMPLE_CURRENCY_ID_1 }), { + resolvers, + }) + + await waitFor(() => { + expect(result.current.loading).toBe(false) + expect(result.current.error).toBe(false) + }) + + expect(result.current.data?.priceHistory).toEqual([ + { + timestamp: expect.any(Number), + value: 1, + }, + { + timestamp: expect.any(Number), + value: 2, + }, + ]) + }) + }) + + describe('different durations', () => { + const dayPriceHistory = priceHistory({ duration: GraphQLApi.HistoryDuration.Day }) + const weekPriceHistory = priceHistory({ duration: GraphQLApi.HistoryDuration.Week }) + const monthPriceHistory = priceHistory({ duration: GraphQLApi.HistoryDuration.Month }) + const yearPriceHistory = priceHistory({ duration: GraphQLApi.HistoryDuration.Year }) + + const dayTokenProject = createUsdcTokenProjectWithMatchingPriceHistory(dayPriceHistory) + const weekTokenProject = createUsdcTokenProjectWithMatchingPriceHistory(weekPriceHistory) + const monthTokenProject = createUsdcTokenProjectWithMatchingPriceHistory(monthPriceHistory) + const yearTokenProject = createUsdcTokenProjectWithMatchingPriceHistory(yearPriceHistory) + + const { resolvers } = queryResolvers({ + // oxlint-disable-next-line max-params + tokenProjects: (parent, args, context, info) => { + switch (info.variableValues['duration']) { + case GraphQLApi.HistoryDuration.Day: + return [dayTokenProject] + case GraphQLApi.HistoryDuration.Week: + return [weekTokenProject] + case GraphQLApi.HistoryDuration.Month: + return [monthTokenProject] + case GraphQLApi.HistoryDuration.Year: + return [yearTokenProject] + default: + return [dayTokenProject] + } + }, + }) + + describe('when duration is set to default value (day)', () => { + it('returns correct price history', async () => { + const { result } = renderHookWithProviders(() => useTokenPriceHistory({ currencyId: SAMPLE_CURRENCY_ID_1 }), { + resolvers, + }) + + await waitFor(() => { + expect(result.current).toEqual( + expect.objectContaining({ + data: { + priceHistory: formatPriceHistory(dayPriceHistory), + spot: expect.anything(), + }, + selectedDuration: GraphQLApi.HistoryDuration.Day, + }), + ) + }) + }) + + it('returns correct spot price', async () => { + const { result } = renderHookWithProviders(() => useTokenPriceHistory({ currencyId: SAMPLE_CURRENCY_ID_1 }), { + resolvers, + }) + + await waitFor(() => { + const ethereumToken = dayTokenProject.tokens.find((t) => t.chain === GraphQLApi.Chain.Ethereum) + expect(result.current.data?.spot).toEqual({ + value: expect.objectContaining({ value: ethereumToken?.market?.price?.value }), + relativeChange: expect.objectContaining({ + value: dayTokenProject.markets?.[0]?.pricePercentChange24h?.value, + }), + }) + }) + }) + }) + + describe('when duration is set to non-default value (year)', () => { + it('returns correct price history', async () => { + const { result } = renderHookWithProviders( + () => + useTokenPriceHistory({ + currencyId: SAMPLE_CURRENCY_ID_1, + initialDuration: GraphQLApi.HistoryDuration.Year, + }), + { resolvers }, + ) + + await waitFor(() => { + expect(result.current).toEqual( + expect.objectContaining({ + data: { + priceHistory: formatPriceHistory(yearPriceHistory), + spot: expect.anything(), + }, + selectedDuration: GraphQLApi.HistoryDuration.Year, + }), + ) + }) + }) + + it('returns correct spot price with calculated percentage change', async () => { + const { result } = renderHookWithProviders( + () => + useTokenPriceHistory({ + currencyId: SAMPLE_CURRENCY_ID_1, + initialDuration: GraphQLApi.HistoryDuration.Year, + }), + { resolvers }, + ) + await waitFor(() => { + const ethereumToken = yearTokenProject.tokens.find((t) => t.chain === GraphQLApi.Chain.Ethereum) + // For non-Day durations, relativeChange is calculated from price history + const openPrice = yearPriceHistory[0]?.value ?? 0 + const closePrice = yearPriceHistory[yearPriceHistory.length - 1]?.value ?? 0 + const calculatedChange = openPrice > 0 ? ((closePrice - openPrice) / openPrice) * 100 : 0 + + expect(result.current.data?.spot).toEqual({ + value: expect.objectContaining({ value: ethereumToken?.market?.price?.value }), + relativeChange: expect.objectContaining({ + value: calculatedChange, + }), + }) + }) + }) + }) + + describe('when duration is changed', () => { + it('returns new price history and spot price with correct percentage change calculation', async () => { + const { result } = renderHookWithProviders(() => useTokenPriceHistory({ currencyId: SAMPLE_CURRENCY_ID_1 }), { + resolvers, + }) + + await waitFor(() => { + const ethereumToken = dayTokenProject.tokens.find((t) => t.chain === GraphQLApi.Chain.Ethereum) + // For Day duration, should use API's 24hr value + expect(result.current.data).toEqual({ + priceHistory: formatPriceHistory(dayPriceHistory), + spot: { + value: expect.objectContaining({ value: ethereumToken?.market?.price?.value }), + relativeChange: expect.objectContaining({ + value: dayTokenProject.markets?.[0]?.pricePercentChange24h?.value, + }), + }, + }) + }) + + // Change duration + await act(() => { + result.current.setDuration(GraphQLApi.HistoryDuration.Week) + }) + + await waitFor(() => { + const ethereumToken = weekTokenProject.tokens.find((t) => t.chain === GraphQLApi.Chain.Ethereum) + // For Week duration, should calculate from price history + const openPrice = weekPriceHistory[0]?.value ?? 0 + const closePrice = weekPriceHistory[weekPriceHistory.length - 1]?.value ?? 0 + const calculatedChange = openPrice > 0 ? ((closePrice - openPrice) / openPrice) * 100 : 0 + + expect(result.current.data).toEqual({ + priceHistory: formatPriceHistory(weekPriceHistory), + spot: { + value: expect.objectContaining({ value: ethereumToken?.market?.price?.value }), + relativeChange: expect.objectContaining({ + value: calculatedChange, + }), + }, + }) + }) + }) + }) + + describe('error handling', () => { + it('returns error if query has no data and there is no loading state', async () => { + jest.spyOn(console, 'error').mockImplementation(() => undefined) + const { resolvers: errorResolvers } = queryResolvers({ + tokenProjects: () => { + throw new Error('error') + }, + }) + const { result } = renderHookWithProviders(() => useTokenPriceHistory({ currencyId: SAMPLE_CURRENCY_ID_1 }), { + resolvers: errorResolvers, + }) + + await waitFor(() => { + expect(result.current.loading).toBe(false) + expect(result.current.error).toBe(true) + }) + }) + }) + }) +}) diff --git a/apps/mobile/src/components/PriceExplorer/usePriceHistory.ts b/apps/mobile/src/components/PriceExplorer/usePriceHistory.ts new file mode 100644 index 00000000..2ac29e07 --- /dev/null +++ b/apps/mobile/src/components/PriceExplorer/usePriceHistory.ts @@ -0,0 +1,172 @@ +import { type GqlResult, GraphQLApi, isError, isNonPollingRequestInFlight } from '@universe/api' +import maxBy from 'lodash/maxBy' +import { type Dispatch, type SetStateAction, useCallback, useMemo, useRef, useState } from 'react' +import { type SharedValue, useDerivedValue } from 'react-native-reanimated' +import { type TLineChartData } from 'react-native-wagmi-charts' +import { PollingInterval } from 'uniswap/src/constants/misc' +import { UniverseChainId } from 'uniswap/src/features/chains/types' +import { toGraphQLChain } from 'uniswap/src/features/chains/utils' +import { currencyIdToContractInput } from 'uniswap/src/features/dataApi/utils/currencyIdToContractInput' +import { useLocalizationContext } from 'uniswap/src/features/language/LocalizationContext' +import { currencyIdToChain } from 'uniswap/src/utils/currencyId' + +export type TokenSpotData = { + value: SharedValue + relativeChange: SharedValue +} + +export type PriceNumberOfDigits = { + left: number + right: number +} + +/** + * @returns Token price history for requested duration + */ +// oxlint-disable-next-line complexity -- biome-parity: oxlint is stricter here +export function useTokenPriceHistory({ + currencyId, + initialDuration = GraphQLApi.HistoryDuration.Day, + skip = false, +}: { + currencyId: string + initialDuration?: GraphQLApi.HistoryDuration + skip?: boolean +}): Omit< + GqlResult<{ + priceHistory?: TLineChartData + spot?: TokenSpotData + }>, + 'error' +> & { + setDuration: Dispatch> + selectedDuration: GraphQLApi.HistoryDuration + error: boolean + numberOfDigits: PriceNumberOfDigits +} { + const lastPrice = useRef(undefined) + const lastNumberOfDigits = useRef({ + left: 0, + right: 0, + }) + const [duration, setDuration] = useState(initialDuration) + const { convertFiatAmount } = useLocalizationContext() + + const { + data: priceData, + refetch, + networkStatus, + } = GraphQLApi.useTokenPriceHistoryQuery({ + variables: { + contract: currencyIdToContractInput(currencyId), + duration, + }, + notifyOnNetworkStatusChange: true, + pollInterval: PollingInterval.Normal, + fetchPolicy: 'network-only', + skip, + }) + + // Data source strategy for multi-chain tokens: + // - Use PER-CHAIN data (token.market) for price and price history to show the correct chain-specific view + // - Fallback to AGGREGATED data (project.markets) when per-chain history is unavailable + // - Continue using aggregated 24hr change for consistency across platforms + // Note: TokenProjectMarket is aggregated across chains, TokenMarket is per-chain + const offChainData = priceData?.tokenProjects?.[0]?.markets?.[0] + + // We need to find the specific token for the chain we're viewing + const currentChain = toGraphQLChain(currencyIdToChain(currencyId) ?? UniverseChainId.Mainnet) + const currentChainToken = priceData?.tokenProjects?.[0]?.tokens.find((token) => token.chain === currentChain) + const onChainData = currentChainToken?.market + + // Use per-chain price to ensure correct price on each chain (e.g., USDC on Ethereum vs Polygon) + const price = onChainData?.price?.value ?? offChainData?.price?.value ?? lastPrice.current + lastPrice.current = price + + // Prefer per-chain price history so multi-chain tokens render the correct chart for the selected chain + const priceHistory = onChainData?.priceHistory ?? offChainData?.priceHistory + + const pricePercentChange24h = + offChainData?.pricePercentChange24h?.value ?? onChainData?.pricePercentChange24h?.value ?? 0 + + // Calculate percentage change from price history for the selected duration + const calculatedPriceChange = useMemo(() => { + if (!priceHistory || priceHistory.length === 0) { + return undefined + } + const openPrice = priceHistory[0]?.value + const closePrice = priceHistory[priceHistory.length - 1]?.value + if (openPrice === undefined || closePrice === undefined || openPrice === 0) { + return undefined + } + return ((closePrice - openPrice) / openPrice) * 100 + }, [priceHistory]) + + // Use API's 24hr change for 1d, calculated change for other durations + const priceChange = duration === GraphQLApi.HistoryDuration.Day ? pricePercentChange24h : calculatedPriceChange + + const spotValue = useDerivedValue(() => price ?? 0) + const spotRelativeChange = useDerivedValue(() => priceChange) + + // oxlint-disable-next-line react/exhaustive-deps -- ensure spot updates when price changes + const spot = useMemo( + () => + price !== undefined + ? { + value: spotValue, + relativeChange: spotRelativeChange, + } + : undefined, + // oxlint-disable-next-line react/exhaustive-deps -- biome-parity: oxlint is stricter here + [price, priceChange, spotValue, spotRelativeChange], + ) + + const formattedPriceHistory = useMemo(() => { + const formatted = priceHistory + ?.filter((x): x is GraphQLApi.TimestampedAmount => Boolean(x)) + .map((x) => ({ timestamp: x.timestamp * 1000, value: x.value })) + + return formatted + }, [priceHistory]) + + const data = useMemo( + () => ({ + priceHistory: formattedPriceHistory, + spot, + }), + [formattedPriceHistory, spot], + ) + + const numberOfDigits = useMemo(() => { + const maxPriceInHistory = maxBy(priceHistory, 'value')?.value + // If there is neither max price in history nor current price, return last number of digits + if (!maxPriceInHistory && price === undefined) { + return lastNumberOfDigits.current + } + + const maxPrice = Math.max(maxPriceInHistory || 0, price || 0) + const convertedMaxValue = convertFiatAmount(maxPrice).amount + + const newNumberOfDigits = { + left: String(convertedMaxValue).split('.')[0]?.length || 10, + right: Number(String(convertedMaxValue.toFixed(16)).split('.')[0]) > 0 ? 2 : 16, + } + lastNumberOfDigits.current = newNumberOfDigits + + return newNumberOfDigits + }, [convertFiatAmount, priceHistory, price]) + + const retry = useCallback(async () => { + await refetch({ contract: currencyIdToContractInput(currencyId) }) + }, [refetch, currencyId]) + + return { + data, + loading: skip || isNonPollingRequestInFlight(networkStatus), + error: !skip && isError(networkStatus, !!priceData), + refetch: retry, + setDuration, + selectedDuration: duration, + numberOfDigits, + } +} diff --git a/apps/mobile/src/components/QRCodeScanner/QRCodeScanner.tsx b/apps/mobile/src/components/QRCodeScanner/QRCodeScanner.tsx new file mode 100644 index 00000000..fb850d7f --- /dev/null +++ b/apps/mobile/src/components/QRCodeScanner/QRCodeScanner.tsx @@ -0,0 +1,330 @@ +import { BarcodeScanningResult, CameraView, CameraViewProps } from 'expo-camera' +import { memo, useCallback, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Alert, LayoutChangeEvent, LayoutRectangle, StyleSheet } from 'react-native' +import DeviceInfo from 'react-native-device-info' +import { launchImageLibrary } from 'react-native-image-picker' +import { FadeIn, FadeOut } from 'react-native-reanimated' +import { Defs, LinearGradient, Path, Rect, Stop, Svg } from 'react-native-svg' +import RNQRGenerator from 'rn-qr-generator' +import { useCameraPermissionQuery } from 'src/components/QRCodeScanner/hooks/useCameraPermissionQuery' +import { useRequestCameraPermissionOnMountEffect } from 'src/components/QRCodeScanner/hooks/useRequestCameraPermissionOnMountEffect' +import { Button, Flex, SpinningLoader, Text, ThemeName, useSporeColors } from 'ui/src' +import { CameraScan, Global, PhotoStacked } from 'ui/src/components/icons' +import { AnimatedFlex } from 'ui/src/components/layout/AnimatedFlex' +import { useDeviceDimensions } from 'ui/src/hooks/useDeviceDimensions' +import { useSporeColorsForTheme } from 'ui/src/hooks/useSporeColors' +import { iconSizes, spacing } from 'ui/src/theme' +import PasteButton from 'uniswap/src/components/buttons/PasteButton' +import { logger } from 'utilities/src/logger/logger' + +enum BarcodeType { + QR = 'qr', +} + +enum CameraType { + Front = 'front', + Back = 'back', +} + +type QRCodeScannerProps = { + onScanCode: (data: string) => void + shouldFreezeCamera: boolean + theme?: ThemeName +} +interface WCScannerProps extends QRCodeScannerProps { + numConnections: number + onPressConnections: () => void +} + +function isWalletConnect(props: QRCodeScannerProps | WCScannerProps): props is WCScannerProps { + return 'numConnections' in props +} + +const CAMERA_ASPECT_RATIO = 4 / 3 +const SCAN_ICON_RADIUS_RATIO = 0.1 +const SCAN_ICON_WIDTH_RATIO = 0.7 +const SCAN_ICON_MASK_OFFSET_RATIO = 0.02 // used for mask to match spacing in CameraScan SVG +const LOADER_SIZE = iconSizes.icon40 +// Adjusts the center point of the QR code scanner upward to prevent content overflow on devices with smaller screens +// Should be removed after rewriting to flex, having: https://github.com/Uniswap/universe/pull/4762 in mind +const BOTTOM_PADDING = 48 + +export function QRCodeScanner(props: QRCodeScannerProps | WCScannerProps): JSX.Element { + const { onScanCode, shouldFreezeCamera, theme } = props + const isWalletConnectModal = isWalletConnect(props) + + const { t } = useTranslation() + const colors = useSporeColorsForTheme(theme) + + const dimensions = useDeviceDimensions() + const permission = useCameraPermissionQuery() + const [isReadingImageFile, setIsReadingImageFile] = useState(false) + const [overlayLayout, setOverlayLayout] = useState() + const [infoLayout, setInfoLayout] = useState() + const [bottomLayout, setBottomLayout] = useState() + + const handleBarcodeScanned = useCallback( + (result: BarcodeScanningResult): void => { + if (shouldFreezeCamera) { + return + } + const data = result.data + onScanCode(data) + setIsReadingImageFile(false) + }, + [onScanCode, shouldFreezeCamera], + ) + + const onPickImageFilePress = useCallback(async (): Promise => { + if (isReadingImageFile) { + return + } + + setIsReadingImageFile(true) + + const response = await launchImageLibrary({ + mediaType: 'photo', + selectionLimit: 1, + }) + + const uri = response.assets?.[0]?.uri + + if (!uri) { + setIsReadingImageFile(false) + return + } + + // TODO (WALL-6014): Migrate to expo-camera once Android issue is fixed + try { + const results = await RNQRGenerator.detect({ uri }) + + if (results.values[0]) { + const data = results.values[0] + onScanCode(data) + } else { + Alert.alert(t('qrScanner.error.none')) + } + } catch (error) { + logger.error(`Cannot detect QR code in image: ${error}`, { + tags: { file: 'QRCodeScanner.tsx', function: 'onPickImageFilePress' }, + }) + Alert.alert(t('qrScanner.error.none')) + } finally { + setIsReadingImageFile(false) + } + }, [isReadingImageFile, onScanCode, t]) + + // always request permission on mount + useRequestCameraPermissionOnMountEffect() + + const overlayWidth = (overlayLayout?.height ?? 0) / CAMERA_ASPECT_RATIO + const cameraWidth = dimensions.fullWidth + const cameraHeight = CAMERA_ASPECT_RATIO * cameraWidth + const scannerSize = Math.min(overlayWidth, cameraWidth) * SCAN_ICON_WIDTH_RATIO + + const disableMicPrompt: CameraViewProps = { + mute: true, + mode: 'picture', + } + return ( + + + + {permission.data?.granted && !isReadingImageFile && ( + + )} + + + + setOverlayLayout(event.nativeEvent.layout)} + > + + setInfoLayout(event.nativeEvent.layout)} + > + + {t('qrScanner.title')} + + + {!shouldFreezeCamera ? ( + // camera isn't frozen (after seeing barcode) — show the camera scan icon (the four white corners) + + ) : ( + // camera has been frozen (has seen a barcode) — show the loading spinner and "Connecting..." or "Loading..." + + + + + + + + {isWalletConnectModal ? t('qrScanner.status.connecting') : t('qrScanner.status.loading')} + + + + )} + {DeviceInfo.isEmulatorSync() && !shouldFreezeCamera && ( + + + + This paste button will only show up in development mode + + + + + )} + setBottomLayout(event.nativeEvent.layout)} + > + + {isReadingImageFile ? ( + + ) : ( + + )} + + + {isWalletConnectModal && props.numConnections > 0 && ( + + )} + + + + + ) +} + +type GradientOverlayProps = { + shouldFreezeCamera: boolean + overlayWidth: number + scannerSize: number +} + +const GradientOverlay = memo(function GradientOverlay({ + shouldFreezeCamera, + overlayWidth, + scannerSize, +}: GradientOverlayProps): JSX.Element { + const colors = useSporeColors() + const dimensions = useDeviceDimensions() + const [size, setSize] = useState<{ width: number; height: number } | null>(null) + + const pathWithHole = useMemo(() => { + if (!size) { + return '' + } + const { width: W, height: H } = size + const iconMaskOffset = SCAN_ICON_MASK_OFFSET_RATIO * scannerSize + const paddingX = Math.max(0, (W - scannerSize) / 2) + iconMaskOffset + const paddingY = Math.max(0, (H - scannerSize) / 2) + iconMaskOffset + const r = scannerSize * SCAN_ICON_RADIUS_RATIO + const L = paddingX + const R = W - paddingX + const T = paddingY + const B = H - paddingY + return `M${L + r} ${T} ${R - r} ${T}C${R - r} ${T} ${R} ${T} ${R} ${T + r}L${R} ${B - r}C${R} ${ + B - r + } ${R} ${B} ${R - r} ${B}L${L + r} ${B}C${L + r} ${B} ${L} ${B} ${L} ${B - r}L${L} ${T + r} 0 ${ + T + r + } 0 ${H} ${W} ${H} ${W} 0 0 0 0 ${T + r} ${L} ${T + r}C${L} ${T + r} ${L} ${T} ${L + r} ${T}` + }, [size, scannerSize]) + + const onLayout = ({ + nativeEvent: { + layout: { width, height }, + }, + }: LayoutChangeEvent): void => { + setSize({ width, height }) + } + + const gradientOffset = (overlayWidth / dimensions.fullWidth - 1 + BOTTOM_PADDING / dimensions.fullHeight) / 2 + + return ( + + + + + + + + + + + + + {!shouldFreezeCamera ? ( + + ) : ( + + )} + {/* gradient from top of modal to top of QR code, of color DEP_background1 to transparent */} + + {/* gradient from bottom of modal to bottom of QR code, of color DEP_background1 to transparent */} + + + + ) +}) diff --git a/apps/mobile/src/components/QRCodeScanner/hooks/useCameraPermissionQuery.ts b/apps/mobile/src/components/QRCodeScanner/hooks/useCameraPermissionQuery.ts new file mode 100644 index 00000000..8287eb6f --- /dev/null +++ b/apps/mobile/src/components/QRCodeScanner/hooks/useCameraPermissionQuery.ts @@ -0,0 +1,17 @@ +import { queryOptions, UseQueryResult, useQuery } from '@tanstack/react-query' +import { Camera, PermissionResponse } from 'expo-camera' +import { ReactQueryCacheKey } from 'utilities/src/reactQuery/cache' + +export const cameraPermissionQuery = queryOptions({ + queryKey: [ReactQueryCacheKey.CameraPermission], + queryFn: async () => { + return await Camera.getCameraPermissionsAsync() + }, + refetchInterval: false, + refetchOnWindowFocus: true, + refetchOnMount: true, +}) + +export const useCameraPermissionQuery = (): UseQueryResult => { + return useQuery(cameraPermissionQuery) +} diff --git a/apps/mobile/src/components/QRCodeScanner/hooks/useRequestCameraPermissionMutation.ts b/apps/mobile/src/components/QRCodeScanner/hooks/useRequestCameraPermissionMutation.ts new file mode 100644 index 00000000..113eb2d3 --- /dev/null +++ b/apps/mobile/src/components/QRCodeScanner/hooks/useRequestCameraPermissionMutation.ts @@ -0,0 +1,42 @@ +import { UseMutationResult, useMutation, useQueryClient } from '@tanstack/react-query' +import { Camera, PermissionResponse, PermissionStatus } from 'expo-camera' +import { useTranslation } from 'react-i18next' +import { Alert } from 'react-native' +import { cameraPermissionQuery } from 'src/components/QRCodeScanner/hooks/useCameraPermissionQuery' +import { usePreventLock } from 'src/features/lockScreen/hooks/usePreventLock' +import { openSettings } from 'src/utils/linking' +import { logger } from 'utilities/src/logger/logger' + +const ERROR_PERMISSION_STATUSES = [PermissionStatus.UNDETERMINED, PermissionStatus.DENIED] + +export const useRequestCameraPermissionMutation = (): UseMutationResult => { + const { t } = useTranslation() + const { preventLock } = usePreventLock() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async () => { + // NB: we use preventLock to prevent accidental lock screen when requesting permission + // this happens on android when requesting any permissions :( + return await preventLock(Camera.requestCameraPermissionsAsync) + }, + + onError: (error) => { + logger.error(error, { + tags: { file: 'useRequestCameraPermissionMutation.ts', function: 'useRequestCameraPermissionMutation' }, + }) + }, + + onSuccess: (data) => { + if (ERROR_PERMISSION_STATUSES.includes(data.status)) { + Alert.alert(t('qrScanner.error.camera.title'), t('qrScanner.error.camera.message'), [ + { text: t('common.navigation.systemSettings'), onPress: openSettings }, + { text: t('common.button.notNow') }, + ]) + } + }, + onSettled: async () => { + await queryClient.invalidateQueries(cameraPermissionQuery) + }, + }) +} diff --git a/apps/mobile/src/components/QRCodeScanner/hooks/useRequestCameraPermissionOnMountEffect.ts b/apps/mobile/src/components/QRCodeScanner/hooks/useRequestCameraPermissionOnMountEffect.ts new file mode 100644 index 00000000..8e0ae479 --- /dev/null +++ b/apps/mobile/src/components/QRCodeScanner/hooks/useRequestCameraPermissionOnMountEffect.ts @@ -0,0 +1,18 @@ +import { useEffect } from 'react' +import { useCameraPermissionQuery } from 'src/components/QRCodeScanner/hooks/useCameraPermissionQuery' +import { useRequestCameraPermissionMutation } from 'src/components/QRCodeScanner/hooks/useRequestCameraPermissionMutation' + +export function useRequestCameraPermissionOnMountEffect(): void { + const { data: permission, isFetched } = useCameraPermissionQuery() + const { mutate, isPending, isError, isSuccess } = useRequestCameraPermissionMutation() + + const isGranted = permission?.granted + + useEffect((): void => { + // only request permission if we haven't already requested it and we haven't already been granted permission + const shouldRequestPermission = !isGranted && isFetched && !isPending && !isError && !isSuccess + if (shouldRequestPermission) { + mutate() + } + }, [isFetched, isGranted, isPending, mutate, isError, isSuccess]) +} diff --git a/apps/mobile/src/components/RecipientSelect/RecipientScanModal.tsx b/apps/mobile/src/components/RecipientSelect/RecipientScanModal.tsx new file mode 100644 index 00000000..df8134db --- /dev/null +++ b/apps/mobile/src/components/RecipientSelect/RecipientScanModal.tsx @@ -0,0 +1,109 @@ +import 'react-native-reanimated' +import React, { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Alert } from 'react-native' +import { QRCodeScanner } from 'src/components/QRCodeScanner/QRCodeScanner' +import { getSupportedURI, URIType } from 'src/components/Requests/ScanSheet/util' +import { Flex, Text, TouchableArea, useIsDarkMode } from 'ui/src' +import { QrCode, Scan } from 'ui/src/components/icons' +import { useSporeColorsForTheme } from 'ui/src/hooks/useSporeColors' +import { Modal } from 'uniswap/src/components/modals/Modal' +import { ScannerModalState } from 'uniswap/src/components/ReceiveQRCode/constants' +import { ReceiveQRCode } from 'uniswap/src/components/ReceiveQRCode/ReceiveQRCode' +import { ModalName } from 'uniswap/src/features/telemetry/constants' +import { TestID } from 'uniswap/src/test/fixtures/testIDs' +import { useActiveAccountAddress } from 'wallet/src/features/wallet/hooks' + +type Props = { + onClose: () => void + onSelectRecipient: (address: string) => void +} + +export function RecipientScanModal({ onSelectRecipient, onClose }: Props): JSX.Element { + const { t } = useTranslation() + const isDarkMode = useIsDarkMode() + + const activeAddress = useActiveAccountAddress() + const [currentScreenState, setCurrentScreenState] = useState(ScannerModalState.ScanQr) + const [shouldFreezeCamera, setShouldFreezeCamera] = useState(false) + + const isScanningQr = currentScreenState === ScannerModalState.ScanQr + + // We want to always show the QR Code Scanner in "dark mode" + const colors = useSporeColorsForTheme(isScanningQr ? 'dark' : undefined) + + const onScanCode = async (uri: string): Promise => { + if (shouldFreezeCamera) { + return + } + + setShouldFreezeCamera(true) + const supportedURI = await getSupportedURI(uri) + + if (supportedURI?.type === URIType.Address) { + onSelectRecipient(supportedURI.value) + onClose() + } else { + Alert.alert(t('qrScanner.recipient.error.title'), t('qrScanner.recipient.error.message'), [ + { + text: t('common.button.tryAgain'), + onPress: (): void => { + setShouldFreezeCamera(false) + }, + }, + ]) + } + } + + const onPressBottomToggle = (): void => { + if (currentScreenState === ScannerModalState.ScanQr) { + setCurrentScreenState(ScannerModalState.WalletQr) + } else { + setCurrentScreenState(ScannerModalState.ScanQr) + } + } + + return ( + + {currentScreenState === ScannerModalState.ScanQr && ( + + )} + {currentScreenState === ScannerModalState.WalletQr && activeAddress && } + + + + {currentScreenState === ScannerModalState.ScanQr ? ( + + ) : ( + + )} + + {currentScreenState === ScannerModalState.ScanQr + ? t('qrScanner.recipient.action.show') + : t('qrScanner.recipient.action.scan')} + + + + + + ) +} diff --git a/apps/mobile/src/components/RecipientSelect/RecipientSelect.tsx b/apps/mobile/src/components/RecipientSelect/RecipientSelect.tsx new file mode 100644 index 00000000..bbed8ea6 --- /dev/null +++ b/apps/mobile/src/components/RecipientSelect/RecipientSelect.tsx @@ -0,0 +1,157 @@ +import React, { memo, useCallback, useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { TextInput } from 'react-native' +import { KeyboardAvoidingView } from 'react-native-keyboard-controller' +import { RecipientScanModal } from 'src/components/RecipientSelect/RecipientScanModal' +import { Flex, flexStyles, Loader, Text, TouchableArea } from 'ui/src' +import { Scan, UserSearch } from 'ui/src/components/icons' +import { UniverseChainId } from 'uniswap/src/features/chains/types' +import { TestID } from 'uniswap/src/test/fixtures/testIDs' +import { dismissNativeKeyboard } from 'utilities/src/device/keyboard/dismissNativeKeyboard' +import { isIOS } from 'utilities/src/platform' +import { useFilteredRecipientSections } from 'wallet/src/components/RecipientSearch/hooks' +import { RecipientList } from 'wallet/src/components/RecipientSearch/RecipientList' +import { RecipientSelectSpeedBumps } from 'wallet/src/components/RecipientSearch/RecipientSelectSpeedBumps' +import { SearchBar } from 'wallet/src/features/search/SearchBar' + +interface RecipientSelectProps { + onSelectRecipient: (newRecipientAddress: string) => void + onHideRecipientSelector: () => void + recipient?: string + focusInput?: boolean + chainId?: UniverseChainId + renderedInModal?: boolean + hideBackButton?: boolean +} + +function QRScannerIconButton({ onPress }: { onPress: () => void }): JSX.Element { + return ( + + + + ) +} + +function RecipientSelectInner({ + onSelectRecipient, + onHideRecipientSelector, + recipient, + focusInput, + chainId, + renderedInModal, + hideBackButton, +}: RecipientSelectProps): JSX.Element { + const { t } = useTranslation() + const inputRef = useRef(null) + + const [pattern, setPattern] = useState('') + const [showQRScanner, setShowQRScanner] = useState(false) + const [checkSpeedBumps, setCheckSpeedBumps] = useState(false) + const [selectedRecipient, setSelectedRecipient] = useState(recipient) + const { sections, loading } = useFilteredRecipientSections(pattern) + + useEffect(() => { + if (focusInput) { + inputRef.current?.focus() + } else { + inputRef.current?.blur() + } + }, [focusInput]) + + const onPressQRScanner = useCallback(() => { + dismissNativeKeyboard() + setShowQRScanner(true) + }, []) + + const onCloseQRScanner = useCallback(() => { + setShowQRScanner(false) + }, []) + + const onSelect = useCallback((newRecipient: string) => { + setSelectedRecipient(newRecipient) + setCheckSpeedBumps(true) + }, []) + + const onSpeedBumpConfirm = useCallback(() => { + if (selectedRecipient) { + onSelectRecipient(selectedRecipient) + } + }, [onSelectRecipient, selectedRecipient]) + + return ( + <> + + + {!renderedInModal && ( + + + {t('send.recipient.header')} + + + )} + } + hideBackButton={hideBackButton} + placeholder={t('send.recipient.input.placeholder')} + value={pattern} + onBack={recipient ? onHideRecipientSelector : undefined} + onChangeText={setPattern} + /> + {loading ? ( + + ) : !pattern && sections.length === 0 ? ( + + + + + {t('send.recipientSelect.search.empty')} + + + + + {t('qrScanner.recipient.action.scan')} + + + + ) : !sections.length ? ( + + {t('send.recipient.results.empty')} + + {t('send.recipient.results.error')} + + + ) : ( + + )} + + + {showQRScanner && } + + + ) +} + +export const RecipientSelect = memo(RecipientSelectInner) diff --git a/apps/mobile/src/components/RecipientSelect/hooks.test.ts b/apps/mobile/src/components/RecipientSelect/hooks.test.ts new file mode 100644 index 00000000..e25585f6 --- /dev/null +++ b/apps/mobile/src/components/RecipientSelect/hooks.test.ts @@ -0,0 +1,416 @@ +import { PreloadedState } from '@reduxjs/toolkit' +import { waitFor } from '@testing-library/react-native' +import { toIncludeSameMembers } from 'jest-extended' +import { MobileState } from 'src/app/mobileReducer' +import { renderHookWithProviders } from 'src/test/render' +import { SearchableRecipient } from 'uniswap/src/features/address/types' +import { UniverseChainId } from 'uniswap/src/features/chains/types' +import { TransactionsState } from 'uniswap/src/features/transactions/slice' +import { TransactionStatus } from 'uniswap/src/features/transactions/types/transactionDetails' +import { + SAMPLE_SEED_ADDRESS_1, + SAMPLE_SEED_ADDRESS_2, + sendTokenTransactionInfo, + transactionDetails, +} from 'uniswap/src/test/fixtures' +import { useRecipients } from 'wallet/src/components/RecipientSearch/hooks' +import { SwapProtectionSetting } from 'wallet/src/features/wallet/slice' +import { signerMnemonicAccount } from 'wallet/src/test/fixtures' + +expect.extend({ toIncludeSameMembers }) + +const sendTxDetailsPending = transactionDetails({ + status: TransactionStatus.Pending, + typeInfo: sendTokenTransactionInfo(), + addedTime: 1487076708000, +}) +const sendTxDetailsConfirmed = transactionDetails({ + status: TransactionStatus.Success, + typeInfo: sendTokenTransactionInfo(), + addedTime: 1487076708000, +}) +const sendTxDetailsFailed = transactionDetails({ + status: TransactionStatus.Failed, + typeInfo: sendTokenTransactionInfo(), + addedTime: 1487076710000, +}) + +/** + * Tests interaction of mobile state with useRecipients hook + */ + +type PreloadedStateProps = { + watchedAddresses?: Address[] + hasInactiveAccounts?: boolean + transactions?: TransactionsState +} + +const getPreloadedState = (props?: PreloadedStateProps): PreloadedState => { + const { watchedAddresses = [], hasInactiveAccounts = false, transactions = {} } = props || {} + return { + favorites: { + watchedAddresses, + tokens: [], + }, + wallet: { + accounts: { + [activeAccount.address]: activeAccount, + ...(hasInactiveAccounts && { [inactiveAccount.address]: inactiveAccount }), + }, + activeAccountAddress: activeAccount.address, + settings: { + swapProtection: SwapProtectionSetting.On, + }, + androidCloudBackupEmail: null, + }, + transactions, + } +} + +const activeAccount = signerMnemonicAccount() +const inactiveAccount = signerMnemonicAccount() +const validatedAddressRecipient: SearchableRecipient = { + address: SAMPLE_SEED_ADDRESS_1, +} + +const watchedAddresses = [SAMPLE_SEED_ADDRESS_1, SAMPLE_SEED_ADDRESS_2] + +const searchSectionResult = { + title: 'Search results', + data: [validatedAddressRecipient], +} + +const recentRecipientsSectionResult = { + title: 'Recent', + data: [ + { + address: sendTxDetailsFailed.typeInfo.recipient, + name: '', + }, + { + address: sendTxDetailsConfirmed.typeInfo.recipient, + name: '', + }, + { + address: sendTxDetailsPending.typeInfo.recipient, + name: '', + }, + ], +} + +const recentRecipients = recentRecipientsSectionResult.data.map((recipient) => ({ + data: recipient, + key: recipient.address, +})) + +const inactiveWalletsSectionResult = { + title: 'Your wallets', + data: [inactiveAccount], +} + +const favoriteWalletsSectionResult = { + title: 'Favorite wallets', + data: [{ address: SAMPLE_SEED_ADDRESS_1 }, { address: SAMPLE_SEED_ADDRESS_2 }], +} + +describe(useRecipients, () => { + it('returns correct initial values', () => { + const { result } = renderHookWithProviders(useRecipients, { + preloadedState: getPreloadedState(), + initialProps: '', + }) + + expect(result.current).toEqual({ + sections: [], + searchableRecipientOptions: [], + debouncedPattern: '', + loading: false, + }) + }) + + describe('Validated address recipient', () => { + it('result does not contain Search Results section if there is no pattern', () => { + const { result } = renderHookWithProviders(useRecipients, { + preloadedState: getPreloadedState(), + initialProps: '', + }) + + expect(result.current).toEqual( + expect.objectContaining({ + sections: expect.not.arrayContaining([expect.objectContaining({ title: 'Search results' })]), + }), + ) + }) + + it('result contains Search Results section if there is a pattern', async () => { + const { result } = renderHookWithProviders(useRecipients, { + preloadedState: getPreloadedState(), + initialProps: SAMPLE_SEED_ADDRESS_1, + }) + + await waitFor(() => { + expect(result.current.sections).toEqual(expect.arrayContaining([searchSectionResult])) + }) + }) + + it('searchableRecipientOptions contains validatedAddressRecipient', async () => { + const { result } = renderHookWithProviders(useRecipients, { + preloadedState: getPreloadedState(), + initialProps: SAMPLE_SEED_ADDRESS_1, + }) + + expect(result.current.searchableRecipientOptions).toEqual( + expect.arrayContaining([ + { + data: expect.objectContaining({ address: SAMPLE_SEED_ADDRESS_1 }), + key: SAMPLE_SEED_ADDRESS_1, + }, + ]), + ) + }) + }) + + describe('Recent recipients', () => { + it('result does not contain Recent section if there are no recent recipients', () => { + const { result } = renderHookWithProviders(useRecipients, { + preloadedState: getPreloadedState(), + initialProps: '', + }) + + expect(result.current).toEqual( + expect.objectContaining({ + sections: expect.not.arrayContaining([expect.objectContaining({ title: 'Recent' })]), + }), + ) + }) + + it('result contains Recent section if there are recent recipients', () => { + const { result } = renderHookWithProviders(useRecipients, { + preloadedState: getPreloadedState({ + transactions: { + [activeAccount.address]: { + [sendTxDetailsPending.chainId]: [sendTxDetailsPending], + }, + }, + }), + initialProps: '', + }) + + expect(result.current).toEqual( + expect.objectContaining({ + sections: expect.arrayContaining([ + { + title: 'Recent', + data: [ + { + address: sendTxDetailsPending.typeInfo.recipient, + name: '', + }, + ], + }, + ]), + }), + ) + }) + + it('returns unique recipient addresses', () => { + const { result } = renderHookWithProviders(useRecipients, { + preloadedState: getPreloadedState({ + transactions: { + [activeAccount.address]: { + [UniverseChainId.Base as UniverseChainId]: [sendTxDetailsPending, sendTxDetailsConfirmed], + [UniverseChainId.Mainnet as UniverseChainId]: [sendTxDetailsConfirmed, sendTxDetailsFailed], + [UniverseChainId.Bnb as UniverseChainId]: [sendTxDetailsPending, sendTxDetailsConfirmed], + }, + }, + }), + initialProps: '', + }) + + const section = result.current.sections[0]! + expect(section.title).toEqual('Recent') + // This method doesn't check the order of the elements + expect(section.data).toIncludeSameMembers([ + { + address: sendTxDetailsPending.typeInfo.recipient, + name: '', + }, + { + address: sendTxDetailsConfirmed.typeInfo.recipient, + name: '', + }, + { + address: sendTxDetailsFailed.typeInfo.recipient, + name: '', + }, + ]) + }) + + it('sorts recipients by most recent transaction', () => { + const { result } = renderHookWithProviders(useRecipients, { + preloadedState: getPreloadedState({ + transactions: { + [activeAccount.address]: { + [sendTxDetailsPending.chainId]: [sendTxDetailsPending, sendTxDetailsFailed, sendTxDetailsConfirmed], + }, + }, + }), + initialProps: '', + }) + + expect(result.current).toEqual( + expect.objectContaining({ + sections: expect.arrayContaining([recentRecipientsSectionResult]), + }), + ) + }) + + it('searchableRecipientOptions contains recent recipients', () => { + const { result } = renderHookWithProviders(useRecipients, { + preloadedState: getPreloadedState({ + transactions: { + [activeAccount.address]: { + [UniverseChainId.Base as UniverseChainId]: [sendTxDetailsPending, sendTxDetailsConfirmed], + [UniverseChainId.Mainnet as UniverseChainId]: [sendTxDetailsConfirmed, sendTxDetailsFailed], + [UniverseChainId.Bnb as UniverseChainId]: [sendTxDetailsPending, sendTxDetailsConfirmed], + }, + }, + }), + initialProps: '', + }) + + expect(result.current.searchableRecipientOptions).toEqual(recentRecipients) + }) + }) + + describe('Inactive local accounts', () => { + it('result does not contain Your wallets section if there are no inactive accounts', () => { + const { result } = renderHookWithProviders(useRecipients, { + preloadedState: getPreloadedState(), + initialProps: '', + }) + + expect(result.current).toEqual( + expect.objectContaining({ + sections: expect.not.arrayContaining([expect.objectContaining({ title: 'Your wallets' })]), + }), + ) + }) + + it('result contains Your wallets section if there are inactive accounts', () => { + const { result } = renderHookWithProviders(useRecipients, { + preloadedState: getPreloadedState({ hasInactiveAccounts: true }), + initialProps: '', + }) + + expect(result.current).toEqual( + expect.objectContaining({ + sections: expect.arrayContaining([inactiveWalletsSectionResult]), + }), + ) + }) + + it('searchableRecipientOptions contains inactive accounts', () => { + const { result } = renderHookWithProviders(useRecipients, { + preloadedState: getPreloadedState({ hasInactiveAccounts: true }), + initialProps: '', + }) + + expect(result.current).toEqual( + expect.objectContaining({ + searchableRecipientOptions: [{ data: inactiveAccount, key: inactiveAccount.address }], + }), + ) + }) + }) + + describe('Watched wallets', () => { + it('result does not contain Favorite Wallets section if there are no watched wallets', () => { + const { result } = renderHookWithProviders(useRecipients, { + preloadedState: getPreloadedState(), + initialProps: '', + }) + + expect(result.current).toEqual( + expect.objectContaining({ + sections: expect.not.arrayContaining([expect.objectContaining({ title: 'Favorite wallets' })]), + }), + ) + }) + + it('result contains Favorite Wallets section if there are watched wallets', () => { + const { result } = renderHookWithProviders(useRecipients, { + preloadedState: getPreloadedState({ + watchedAddresses, + }), + initialProps: '', + }) + + expect(result.current).toEqual( + expect.objectContaining({ + sections: expect.arrayContaining([favoriteWalletsSectionResult]), + }), + ) + }) + }) + + describe('multiple sections', () => { + it('result contains all sections', async () => { + const { result } = renderHookWithProviders(useRecipients, { + preloadedState: getPreloadedState({ + watchedAddresses, + hasInactiveAccounts: true, + transactions: { + [activeAccount.address]: { + [UniverseChainId.Base as UniverseChainId]: [sendTxDetailsPending, sendTxDetailsConfirmed], + [UniverseChainId.Mainnet as UniverseChainId]: [sendTxDetailsConfirmed, sendTxDetailsFailed], + [UniverseChainId.Bnb as UniverseChainId]: [sendTxDetailsPending, sendTxDetailsConfirmed], + }, + }, + }), + initialProps: SAMPLE_SEED_ADDRESS_1, + }) + + await waitFor(() => { + expect(result.current).toEqual( + expect.objectContaining({ + sections: expect.arrayContaining([ + searchSectionResult, + recentRecipientsSectionResult, + inactiveWalletsSectionResult, + favoriteWalletsSectionResult, + ]), + }), + ) + }) + }) + + it('searchableRecipientOptions contains all unique recipients', async () => { + const { result } = renderHookWithProviders(useRecipients, { + preloadedState: getPreloadedState({ + watchedAddresses, + hasInactiveAccounts: true, + transactions: { + [activeAccount.address]: { + [UniverseChainId.Base as UniverseChainId]: [sendTxDetailsPending, sendTxDetailsConfirmed], + [UniverseChainId.Mainnet as UniverseChainId]: [sendTxDetailsConfirmed, sendTxDetailsFailed], + [UniverseChainId.Bnb as UniverseChainId]: [sendTxDetailsPending, sendTxDetailsConfirmed], + }, + }, + }), + initialProps: SAMPLE_SEED_ADDRESS_1, + }) + + await waitFor(() => { + expect(result.current.searchableRecipientOptions).toEqual([ + // Validated address recipient + { data: validatedAddressRecipient, key: validatedAddressRecipient.address }, + // Inactive local accounts + { data: inactiveAccount, key: inactiveAccount.address }, + // Recent recipients + ...recentRecipients, + ]) + }) + }) + }) +}) diff --git a/apps/mobile/src/components/RemoveWallet/AssociatedAccountsList.tsx b/apps/mobile/src/components/RemoveWallet/AssociatedAccountsList.tsx new file mode 100644 index 00000000..b26b3c6d --- /dev/null +++ b/apps/mobile/src/components/RemoveWallet/AssociatedAccountsList.tsx @@ -0,0 +1,108 @@ +import { GraphQLApi } from '@universe/api' +import React, { useMemo } from 'react' +import { StyleSheet } from 'react-native' +import { FlatList } from 'react-native-gesture-handler' +import { Flex, Text } from 'ui/src' +import { spacing } from 'ui/src/theme' +import { AddressDisplay } from 'uniswap/src/components/accounts/AddressDisplay' +import { useLocalizationContext } from 'uniswap/src/features/language/LocalizationContext' +import { NumberType } from 'utilities/src/format/types' +import { useAccountListData } from 'wallet/src/features/accounts/useAccountListData' +import { Account } from 'wallet/src/features/wallet/accounts/types' + +interface SortedAddressData { + address: string + balance: number +} + +type Portfolio = NonNullable[0]>> + +function AssociatedAccountsListInner({ accounts }: { accounts: Account[] }): JSX.Element { + const addresses = useMemo(() => accounts.map((account) => account.address), [accounts]) + const { data, loading } = useAccountListData({ + addresses, + notifyOnNetworkStatusChange: true, + }) + + const sortedAddressesByBalance = (data?.portfolios ?? []) + .filter((portfolio): portfolio is Portfolio => Boolean(portfolio)) + .map((portfolio) => ({ + address: portfolio.ownerAddress, + balance: portfolio.tokensTotalDenominatedValue?.value ?? 0, + })) + .sort((a, b) => b.balance - a.balance) + + const renderItem = ({ item, index }: { item: SortedAddressData; index: number }): JSX.Element => { + return ( + + ) + } + + return ( + + item.address} + renderItem={renderItem} + bounces={false} + contentContainerStyle={[styles.accounts, { paddingBottom: spacing.spacing12 }]} + keyboardShouldPersistTaps="handled" + /> + + ) +} + +export const AssociatedAccountsList = React.memo(AssociatedAccountsListInner) + +function AssociatedAccountRow({ + index, + address, + balance, + totalCount, + loading, +}: { + index: number + address: string + balance: number | undefined + totalCount: number + loading: boolean +}): JSX.Element { + const { convertFiatAmountFormatted } = useLocalizationContext() + const balanceFormatted = convertFiatAmountFormatted(balance, NumberType.PortfolioBalance) + + return ( + + + + + + {balanceFormatted} + + + ) +} + +const styles = StyleSheet.create({ + accounts: { + paddingVertical: spacing.spacing12, + }, +}) diff --git a/apps/mobile/src/components/RemoveWallet/RemoveLastMnemonicWalletFooter.tsx b/apps/mobile/src/components/RemoveWallet/RemoveLastMnemonicWalletFooter.tsx new file mode 100644 index 00000000..d8a320a0 --- /dev/null +++ b/apps/mobile/src/components/RemoveWallet/RemoveLastMnemonicWalletFooter.tsx @@ -0,0 +1,50 @@ +import React, { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Button, Flex, LabeledCheckbox, SpinningLoader, Text } from 'ui/src' +import { TestID } from 'uniswap/src/test/fixtures/testIDs' + +export function RemoveLastMnemonicWalletFooter({ + onPress, + inProgress, +}: { + onPress: () => void + inProgress: boolean +}): JSX.Element { + const { t } = useTranslation() + + const [checkBoxAccepted, setCheckBoxAccepted] = useState(false) + const onCheckPressed = (): void => setCheckBoxAccepted(!checkBoxAccepted) + + return ( + <> + + + + {t('account.wallet.remove.check')} + + + } + onCheckPressed={onCheckPressed} + /> + + + + + + ) +} diff --git a/apps/mobile/src/components/RemoveWallet/RemoveWalletContent.tsx b/apps/mobile/src/components/RemoveWallet/RemoveWalletContent.tsx new file mode 100644 index 00000000..7384607a --- /dev/null +++ b/apps/mobile/src/components/RemoveWallet/RemoveWalletContent.tsx @@ -0,0 +1,226 @@ +import { useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useDispatch } from 'react-redux' +import { navigate } from 'src/app/navigation/rootNavigation' +import { AssociatedAccountsList } from 'src/components/RemoveWallet/AssociatedAccountsList' +import { RemoveLastMnemonicWalletFooter } from 'src/components/RemoveWallet/RemoveLastMnemonicWalletFooter' +import { RemoveWalletStep, useModalContent } from 'src/components/RemoveWallet/useModalContent' +import { determineRemoveWalletConditions } from 'src/components/RemoveWallet/utils/determineRemoveWalletConditions' +import { navigateToOnboardingImportMethod } from 'src/components/RemoveWallet/utils/navigateToOnboardingImportMethod' +import { clearOnboardingTimestamp } from 'src/features/analytics/onboardingTimestamp' +import { useBiometricAppSettings } from 'src/features/biometrics/useBiometricAppSettings' +import { useBiometricPrompt } from 'src/features/biometricsSettings/hooks' +import { Button, Flex, Text, ThemeKeys, useSporeColors } from 'ui/src' +import { useDeviceDimensions } from 'ui/src/hooks/useDeviceDimensions' +import { spacing } from 'ui/src/theme' +import { ElementName, WalletEventName } from 'uniswap/src/features/telemetry/constants' +import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { useAppInsets } from 'uniswap/src/hooks/useAppInsets' +import { ImportType, OnboardingEntryPoint } from 'uniswap/src/types/onboarding' +import { MobileScreens, OnboardingScreens } from 'uniswap/src/types/screens/mobile' +import { logger } from 'utilities/src/logger/logger' +import { EditAccountAction, editAccountActions } from 'wallet/src/features/wallet/accounts/editAccountSaga' +import { useAccounts, useSignerAccounts } from 'wallet/src/features/wallet/hooks' +import { Keyring } from 'wallet/src/features/wallet/Keyring/Keyring' +import { setFinishedOnboarding } from 'wallet/src/features/wallet/slice' + +type RemoveWalletContentProps = { + address?: Address + replaceMnemonic?: boolean + onClose?: () => void +} + +export const RemoveWalletContent = ({ + address, + replaceMnemonic = false, + onClose, +}: RemoveWalletContentProps): JSX.Element | null => { + const { t } = useTranslation() + const colors = useSporeColors() + const dispatch = useDispatch() + const { fullHeight } = useDeviceDimensions() + const insets = useAppInsets() + + const signerAccounts = useSignerAccounts() + const accountsMap = useAccounts() + const { targetAccount, hasAccountsLeftAfterRemoval, accountsToRemove, shouldRemoveMnemonic } = + determineRemoveWalletConditions({ accountsMap, signerAccounts, targetAddress: address, replaceMnemonic }) + + const [inProgress, setInProgress] = useState(false) + const [currentStep, setCurrentStep] = useState( + shouldRemoveMnemonic ? RemoveWalletStep.Warning : RemoveWalletStep.Final, + ) + + const handleOnClose = useCallback((): void => { + if (onClose) { + onClose() + } + }, [onClose]) + + const onRemoveWallet = useCallback((): void => { + handleOnClose() + if (!hasAccountsLeftAfterRemoval) { + // user has no accounts left, so we bring onboarding back + clearOnboardingTimestamp() + dispatch(setFinishedOnboarding({ finishedOnboarding: false })) + navigateToOnboardingImportMethod() + } else if (replaceMnemonic) { + // there are account left and it's replacing, user has view-only accounts left + navigate(MobileScreens.OnboardingStack, { + screen: OnboardingScreens.ImportMethod, + params: { + importType: ImportType.NotYetSelected, + entryPoint: OnboardingEntryPoint.Sidebar, + }, + }) + } + + if (shouldRemoveMnemonic) { + if (signerAccounts[0]) { + Keyring.removeMnemonic(signerAccounts[0].mnemonicId) + .then(() => { + // Only remove accounts if mnemonic is successfully removed + dispatch( + editAccountActions.trigger({ + type: EditAccountAction.Remove, + accounts: accountsToRemove, + }), + ) + }) + .catch((error) => { + logger.error(error, { + tags: { file: 'RemoveWalletModal', function: 'Keyring.removeMnemonic' }, + }) + }) + } + } else { + dispatch( + editAccountActions.trigger({ + type: EditAccountAction.Remove, + accounts: accountsToRemove, + }), + ) + } + + sendAnalyticsEvent(WalletEventName.WalletRemoved, { + wallets_removed: accountsToRemove.map((a) => a.address), + }) + + setInProgress(false) + }, [ + signerAccounts, + dispatch, + replaceMnemonic, + hasAccountsLeftAfterRemoval, + handleOnClose, + accountsToRemove, + shouldRemoveMnemonic, + ]) + + const { trigger } = useBiometricPrompt( + () => { + onRemoveWallet() + }, + () => { + setInProgress(false) + }, + ) + + const { + requiredForAppAccess: biometricAuthRequiredForAppAccess, + requiredForTransactions: biometricAuthRequiredForTransactions, + } = useBiometricAppSettings() + + const onRemoveWalletPress = async (): Promise => { + if (biometricAuthRequiredForAppAccess || biometricAuthRequiredForTransactions) { + await trigger() + } else { + onRemoveWallet() + } + } + + const onPress = async (): Promise => { + // we want to call onRemoveWallet only once + if (inProgress) { + return + } + + switch (currentStep) { + case RemoveWalletStep.Warning: + setCurrentStep(RemoveWalletStep.Final) + break + case RemoveWalletStep.Final: + setInProgress(true) + await onRemoveWalletPress() + break + } + } + + const modalContent = useModalContent({ + account: targetAccount, + isReplacing: replaceMnemonic, + currentStep, + isRemovingRecoveryPhrase: shouldRemoveMnemonic, + associatedAccounts: signerAccounts, + }) + + if (!modalContent) { + return null + } + + const { title, description, Icon, iconColorLabel, actionButtonLabel, iconBackgroundColor } = modalContent + + const labelColor: ThemeKeys = iconColorLabel + const backgroundColor: ThemeKeys = iconBackgroundColor + + const maxContentHeight = fullHeight - insets.top - insets.bottom - spacing.spacing48 + + return ( + + + + + + + + {title} + + + {description} + + + + + {currentStep === RemoveWalletStep.Final && shouldRemoveMnemonic ? ( + <> + + + + ) : ( + + + + + + )} + + + ) +} diff --git a/apps/mobile/src/components/RemoveWallet/RemoveWalletModal.tsx b/apps/mobile/src/components/RemoveWallet/RemoveWalletModal.tsx new file mode 100644 index 00000000..d8a4d178 --- /dev/null +++ b/apps/mobile/src/components/RemoveWallet/RemoveWalletModal.tsx @@ -0,0 +1,18 @@ +import { AppStackScreenProp } from 'src/app/navigation/types' +import { useReactNavigationModal } from 'src/components/modals/useReactNavigationModal' +import { RemoveWalletContent } from 'src/components/RemoveWallet/RemoveWalletContent' +import { useSporeColors } from 'ui/src' +import { Modal } from 'uniswap/src/components/modals/Modal' +import { ModalName } from 'uniswap/src/features/telemetry/constants' + +export function RemoveWalletModal({ route }: AppStackScreenProp): JSX.Element | null { + const colors = useSporeColors() + const { onClose } = useReactNavigationModal() + const { address, replaceMnemonic } = route.params ?? {} + + return ( + + + + ) +} diff --git a/apps/mobile/src/components/RemoveWallet/RemoveWalletModalState.tsx b/apps/mobile/src/components/RemoveWallet/RemoveWalletModalState.tsx new file mode 100644 index 00000000..11f554bc --- /dev/null +++ b/apps/mobile/src/components/RemoveWallet/RemoveWalletModalState.tsx @@ -0,0 +1,4 @@ +export interface RemoveWalletModalState { + address?: Address + replaceMnemonic?: boolean +} diff --git a/apps/mobile/src/components/RemoveWallet/useModalContent.tsx b/apps/mobile/src/components/RemoveWallet/useModalContent.tsx new file mode 100644 index 00000000..dc3a321a --- /dev/null +++ b/apps/mobile/src/components/RemoveWallet/useModalContent.tsx @@ -0,0 +1,152 @@ +import React, { useMemo } from 'react' +import { Trans, useTranslation } from 'react-i18next' +import { GeneratedIcon, Text, ThemeKeys } from 'ui/src' +import { AlertTriangle, Trash, WalletFilled } from 'ui/src/components/icons' +import { AccountType } from 'uniswap/src/features/accounts/types' +import { getCloudProviderName } from 'uniswap/src/utils/cloud-backup/getCloudProviderName' +import { Account } from 'wallet/src/features/wallet/accounts/types' +import { useDisplayName } from 'wallet/src/features/wallet/hooks' + +export enum RemoveWalletStep { + Warning = 'warning', + Final = 'final', +} + +interface ModalContentParams { + account: Account | undefined + isReplacing: boolean + currentStep: string + isRemovingRecoveryPhrase: boolean + associatedAccounts: Account[] +} + +interface ModalContentResult { + title: React.ReactNode + description: React.ReactNode + Icon: GeneratedIcon + iconColorLabel: ThemeKeys + iconBackgroundColor: ThemeKeys + actionButtonLabel?: string +} + +export const useModalContent = ({ + account, + isReplacing, + currentStep, + isRemovingRecoveryPhrase, + associatedAccounts, +}: ModalContentParams): ModalContentResult | undefined => { + const { t } = useTranslation() + + const displayName = useDisplayName(account?.address, { includeUnitagSuffix: true }) + + return useMemo(() => { + // 1st speed bump when removing recovery phrase + if (isRemovingRecoveryPhrase && !isReplacing && currentStep === RemoveWalletStep.Warning) { + return { + title: ( + + }} + i18nKey="account.recoveryPhrase.remove.initial.title" + values={{ walletName: displayName?.name }} + /> + + ), + description: t('account.recoveryPhrase.remove.initial.description'), + Icon: Trash, + iconColorLabel: 'statusCritical', + iconBackgroundColor: 'statusCritical2', + actionButtonLabel: t('common.button.continue'), + } + } + + // 1st speed bump when replacing recovery phrase + if (isRemovingRecoveryPhrase && isReplacing && currentStep === RemoveWalletStep.Warning) { + return { + title: t('account.wallet.button.import'), + description: t('account.recoveryPhrase.remove.import.description'), + Icon: WalletFilled, + iconColorLabel: 'neutral2', + iconBackgroundColor: 'surface3', + actionButtonLabel: t('common.button.continue'), + } + } + + // 2nd and final speed bump when removing or replacing recovery phrase + if (isRemovingRecoveryPhrase && currentStep === RemoveWalletStep.Final) { + return { + title: ( + + }} + i18nKey="account.recoveryPhrase.remove.final.title" + /> + + ), + description: ( + , + }} + i18nKey="account.recoveryPhrase.remove.final.description" + values={{ cloudProviderName: getCloudProviderName() }} + /> + ), + Icon: AlertTriangle, + iconColorLabel: 'statusCritical', + iconBackgroundColor: 'statusCritical2', + } + } + + // removing mnemonic account + if (account?.type === AccountType.SignerMnemonic && currentStep === RemoveWalletStep.Final) { + const associatedAccountNames = associatedAccounts + .filter((aa): aa is Account => aa.address !== account.address) + .map((aa) => aa.name ?? '') + + return { + title: ( + + , + }} + i18nKey="account.recoveryPhrase.remove.initial.title" + values={{ walletName: displayName?.name }} + /> + + ), + description: t('account.recoveryPhrase.remove.mnemonic.description', { walletNames: associatedAccountNames }), + Icon: Trash, + iconColorLabel: 'statusCritical', + iconBackgroundColor: 'statusCritical2', + actionButtonLabel: t('common.button.remove'), + } + } + + // removing view-only account + if (account?.type === AccountType.Readonly && currentStep === RemoveWalletStep.Final) { + return { + title: ( + + , + }} + i18nKey="account.recoveryPhrase.remove.initial.title" + values={{ walletName: displayName?.name }} + /> + + ), + description: t('account.wallet.remove.viewOnly'), + Icon: Trash, + iconColorLabel: 'neutral2', + iconBackgroundColor: 'surface3', + actionButtonLabel: t('common.button.remove'), + } + } + + return undefined + }, [account, associatedAccounts, currentStep, displayName, isRemovingRecoveryPhrase, isReplacing, t]) +} diff --git a/apps/mobile/src/components/RemoveWallet/utils/determineRemoveWalletConditions.test.tsx b/apps/mobile/src/components/RemoveWallet/utils/determineRemoveWalletConditions.test.tsx new file mode 100644 index 00000000..778cb252 --- /dev/null +++ b/apps/mobile/src/components/RemoveWallet/utils/determineRemoveWalletConditions.test.tsx @@ -0,0 +1,153 @@ +import { determineRemoveWalletConditions } from 'src/components/RemoveWallet/utils/determineRemoveWalletConditions' +import { AccountType } from 'uniswap/src/features/accounts/types' +import { Account, SignerMnemonicAccount } from 'wallet/src/features/wallet/accounts/types' + +describe('determineRemoveWalletConditions', () => { + // Mock data setup + const mockMnemonicId = 'test-mnemonic-id' + const address1 = '0x1111111111111111111111111111111111111111' + const address2 = '0x2222222222222222222222222222222222222222' + const address3 = '0x3333333333333333333333333333333333333333' + + const signerAccount1: SignerMnemonicAccount = { + type: AccountType.SignerMnemonic, + address: address1, + name: 'Signer Account 1', + mnemonicId: mockMnemonicId, + timeImportedMs: 1000, + derivationIndex: 0, + pushNotificationsEnabled: true, + } + + const signerAccount2: SignerMnemonicAccount = { + type: AccountType.SignerMnemonic, + address: address2, + name: 'Signer Account 2', + mnemonicId: mockMnemonicId, + timeImportedMs: 2000, + derivationIndex: 1, + pushNotificationsEnabled: true, + } + + const viewOnlyAccount: Account = { + type: AccountType.Readonly, + address: address3, + name: 'View Only Account', + timeImportedMs: 3000, + pushNotificationsEnabled: true, + } + + test('when removing signer account with other signer accounts remaining, mnemonic should not be removed', () => { + const targetAccountInput = signerAccount1 + const accountsMap = { + [targetAccountInput.address]: targetAccountInput, + [signerAccount2.address]: signerAccount2, + [viewOnlyAccount.address]: viewOnlyAccount, + } + const signerAccounts = [signerAccount1, signerAccount2] + + const { targetAccount, hasAccountsLeftAfterRemoval, accountsToRemove, shouldRemoveMnemonic } = + determineRemoveWalletConditions({ accountsMap, signerAccounts, targetAddress: targetAccountInput.address }) + + expect(targetAccount).toBe(targetAccount) + expect(hasAccountsLeftAfterRemoval).toBe(true) + expect(accountsToRemove).toEqual([targetAccount]) + expect(shouldRemoveMnemonic).toBe(false) + }) + + test('when removing the last signer account, mnemonic should be removed', () => { + const targetAccountInput = signerAccount1 + const accountsMap = { + [targetAccountInput.address]: targetAccountInput, + [viewOnlyAccount.address]: viewOnlyAccount, + } + const signerAccounts = [targetAccountInput] + + const { targetAccount, hasAccountsLeftAfterRemoval, accountsToRemove, shouldRemoveMnemonic } = + determineRemoveWalletConditions({ accountsMap, signerAccounts, targetAddress: targetAccountInput.address }) + + expect(targetAccount).toBe(targetAccountInput) + expect(hasAccountsLeftAfterRemoval).toBe(true) + expect(accountsToRemove).toEqual([targetAccountInput]) + expect(shouldRemoveMnemonic).toBe(true) + }) + + test('when replacing mnemonic, all signer accounts should be removed even if there are no remaining accounts', () => { + const accountsMap = { + [address1]: signerAccount1, + [address2]: signerAccount2, + } + const signerAccounts = [signerAccount1, signerAccount2] + const { targetAccount, hasAccountsLeftAfterRemoval, accountsToRemove, shouldRemoveMnemonic } = + determineRemoveWalletConditions({ accountsMap, signerAccounts, replaceMnemonic: true }) + + expect(targetAccount).toBeUndefined() + expect(hasAccountsLeftAfterRemoval).toBe(false) + expect(accountsToRemove).toEqual([signerAccount1, signerAccount2]) + expect(shouldRemoveMnemonic).toBe(true) + }) + + test('when replacing mnemonic, all signer accounts should be removed, but view only accounts should remain', () => { + const accountsMap = { + [address1]: signerAccount1, + [address2]: signerAccount2, + [address3]: viewOnlyAccount, + } + const signerAccounts = [signerAccount1, signerAccount2] + const { targetAccount, hasAccountsLeftAfterRemoval, accountsToRemove, shouldRemoveMnemonic } = + determineRemoveWalletConditions({ accountsMap, signerAccounts, replaceMnemonic: true }) + + expect(targetAccount).toBeUndefined() + expect(hasAccountsLeftAfterRemoval).toBe(true) + expect(accountsToRemove).toEqual([signerAccount1, signerAccount2]) + expect(shouldRemoveMnemonic).toBe(true) + }) + + test('when removing a view-only account and there is 1 signer account remaining, mnemonic should not be removed', () => { + const targetAccountInput = viewOnlyAccount + const accountsMap = { + [address1]: signerAccount1, + [targetAccountInput.address]: targetAccountInput, + } + const signerAccounts = [signerAccount1] + + const { targetAccount, hasAccountsLeftAfterRemoval, accountsToRemove, shouldRemoveMnemonic } = + determineRemoveWalletConditions({ accountsMap, signerAccounts, targetAddress: targetAccountInput.address }) + + expect(targetAccount).toBe(targetAccountInput) + expect(hasAccountsLeftAfterRemoval).toBe(true) + expect(accountsToRemove).toEqual([targetAccountInput]) + expect(shouldRemoveMnemonic).toBe(false) + }) + + test('when last account removed is view only account, mnemonic doesnt need to be removed and should not be removed', () => { + const targetAccountInput = viewOnlyAccount + const accountsMap = { + [targetAccountInput.address]: targetAccountInput, + } + + const { targetAccount, hasAccountsLeftAfterRemoval, accountsToRemove, shouldRemoveMnemonic } = + determineRemoveWalletConditions({ accountsMap, signerAccounts: [], targetAddress: targetAccountInput.address }) + + expect(targetAccount).toBe(targetAccount) + expect(hasAccountsLeftAfterRemoval).toBe(false) + expect(accountsToRemove).toEqual([targetAccount]) + expect(shouldRemoveMnemonic).toBe(false) + }) + + test('when all accounts will be removed, mnemonic should be removed', () => { + const targetAccountInput = signerAccount1 + const accountsMap = { + [targetAccountInput.address]: targetAccountInput, + } + const signerAccounts = [targetAccountInput] + + const { targetAccount, hasAccountsLeftAfterRemoval, accountsToRemove, shouldRemoveMnemonic } = + determineRemoveWalletConditions({ accountsMap, signerAccounts, targetAddress: targetAccountInput.address }) + + expect(targetAccount).toBe(targetAccountInput) + expect(hasAccountsLeftAfterRemoval).toBe(false) + expect(accountsToRemove).toEqual([targetAccountInput]) + expect(shouldRemoveMnemonic).toBe(true) + }) +}) diff --git a/apps/mobile/src/components/RemoveWallet/utils/determineRemoveWalletConditions.tsx b/apps/mobile/src/components/RemoveWallet/utils/determineRemoveWalletConditions.tsx new file mode 100644 index 00000000..1cd033c0 --- /dev/null +++ b/apps/mobile/src/components/RemoveWallet/utils/determineRemoveWalletConditions.tsx @@ -0,0 +1,78 @@ +import { Platform } from 'uniswap/src/features/platforms/types/Platform' +import { areAddressesEqual } from 'uniswap/src/utils/addresses' +import { Account, SignerMnemonicAccount } from 'wallet/src/features/wallet/accounts/types' + +type RemoveWalletConditions = { + /** + * The target account to remove. Will be undefined when replacing mnemonic as no single account is being targeted. + */ + targetAccount?: Account + /** + * Remove mnemonic if it's replacing or there is only one signer mnemonic account left. + * If account to remove is view only, we shouldn't remove mnemonic + */ + shouldRemoveMnemonic: boolean + hasAccountsLeftAfterRemoval: boolean + accountsToRemove: Account[] +} + +/** + * Helper function used to determine the account(s) to remove, whether the mnemonic should be removed, and if accounts remain after removal. + * + * @param allAccounts - All accounts in the wallet + * @param signerAccounts - Signer mnemonic accounts in the wallet + * @param targetAddress - Targeted address of the account to remove + * @param replaceMnemonic - Whether the mnemonic is being replaced + * @returns + */ +export function determineRemoveWalletConditions({ + accountsMap, + signerAccounts, + targetAddress, + replaceMnemonic = false, +}: { + accountsMap: Record + signerAccounts: SignerMnemonicAccount[] + targetAddress?: Address + replaceMnemonic?: boolean +}): RemoveWalletConditions { + // When replacing the mnemonic, remove all signer accounts + if (replaceMnemonic) { + const hasViewOnlyAccounts = Object.keys(accountsMap).length > signerAccounts.length + return { + accountsToRemove: signerAccounts, + hasAccountsLeftAfterRemoval: hasViewOnlyAccounts, + shouldRemoveMnemonic: true, + } + } + + // If there's no target address, we shouldn't remove any accounts + if (!targetAddress) { + return { + accountsToRemove: [], + hasAccountsLeftAfterRemoval: true, + shouldRemoveMnemonic: false, + } + } + + // Remove the specifically targeted account + const targetAccount = accountsMap[targetAddress] + const isTargetSignerAccount = signerAccounts.some((acc) => + // TODO(WALL-7065): Update to support solana + areAddressesEqual({ + addressInput1: { address: targetAddress, platform: Platform.EVM }, + addressInput2: { address: acc.address, platform: Platform.EVM }, + }), + ) + + const accountsToRemove = targetAccount ? [targetAccount] : [] + const hasAccountsLeftAfterRemoval = Object.keys(accountsMap).length > accountsToRemove.length + const targetAccountIsLastSignerAccount = isTargetSignerAccount && signerAccounts.length === 1 + + return { + accountsToRemove, + targetAccount, + hasAccountsLeftAfterRemoval, + shouldRemoveMnemonic: targetAccountIsLastSignerAccount, + } +} diff --git a/apps/mobile/src/components/RemoveWallet/utils/navigateToOnboardingImportMethod.ts b/apps/mobile/src/components/RemoveWallet/utils/navigateToOnboardingImportMethod.ts new file mode 100644 index 00000000..df0cb621 --- /dev/null +++ b/apps/mobile/src/components/RemoveWallet/utils/navigateToOnboardingImportMethod.ts @@ -0,0 +1,38 @@ +import { CommonActions } from '@react-navigation/core' +import { dispatchNavigationAction } from 'src/app/navigation/rootNavigation' +import { ImportType, OnboardingEntryPoint } from 'uniswap/src/types/onboarding' +import { MobileScreens, OnboardingScreens } from 'uniswap/src/types/screens/mobile' + +// This fast-forwards user to the same app state as if +// they have pressed "Get Started" on Landing and should now see import method view +export function navigateToOnboardingImportMethod(): void { + dispatchNavigationAction( + CommonActions.reset({ + index: 0, + routes: [ + { + name: MobileScreens.OnboardingStack, + state: { + index: 1, + routes: [ + { + name: OnboardingScreens.Landing, + params: { + entryPoint: OnboardingEntryPoint.FreshInstallOrReplace, + importType: ImportType.NotYetSelected, + }, + }, + { + name: OnboardingScreens.ImportMethod, + params: { + entryPoint: OnboardingEntryPoint.FreshInstallOrReplace, + importType: ImportType.NotYetSelected, + }, + }, + ], + }, + }, + ], + }), + ) +} diff --git a/apps/mobile/src/components/Requests/ConnectedDapps/ConnectedDappsList.tsx b/apps/mobile/src/components/Requests/ConnectedDapps/ConnectedDappsList.tsx new file mode 100644 index 00000000..651d0213 --- /dev/null +++ b/apps/mobile/src/components/Requests/ConnectedDapps/ConnectedDappsList.tsx @@ -0,0 +1,172 @@ +import { getSdkError, INTERNAL_ERRORS } from '@walletconnect/utils' +import React, { useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { FlatList, StyleSheet } from 'react-native' +import { useDispatch } from 'react-redux' +import { BackButton } from 'src/components/buttons/BackButton' +import { DappConnectionItem } from 'src/components/Requests/ConnectedDapps/DappConnectionItem' +import { openModal } from 'src/features/modals/modalSlice' +import { wcWeb3Wallet } from 'src/features/walletConnect/walletConnectClient' +import { + removePendingSession, + removeSession, + WalletConnectSession, +} from 'src/features/walletConnect/walletConnectSlice' +import { Flex, Text, TouchableArea } from 'ui/src' +import { Scan } from 'ui/src/components/icons' +import { useDeviceDimensions } from 'ui/src/hooks/useDeviceDimensions' +import { spacing } from 'ui/src/theme' +import { ScannerModalState } from 'uniswap/src/components/ReceiveQRCode/constants' +import { pushNotification } from 'uniswap/src/features/notifications/slice/slice' +import { AppNotificationType } from 'uniswap/src/features/notifications/slice/types' +import { ModalName } from 'uniswap/src/features/telemetry/constants' +import { WalletConnectEvent } from 'uniswap/src/types/walletConnect' +import { logger } from 'utilities/src/logger/logger' +import { ONE_SECOND_MS } from 'utilities/src/time/time' +import { DappEllipsisDropdown } from 'wallet/src/components/settings/DappEllipsisDropdown/DappEllipsisDropdown' +import { useActiveAccountWithThrow } from 'wallet/src/features/wallet/hooks' + +type ConnectedDappsProps = { + sessions: WalletConnectSession[] + backButton?: JSX.Element + selectedAddress?: string +} + +export function ConnectedDappsList({ backButton, sessions, selectedAddress }: ConnectedDappsProps): JSX.Element { + const dispatch = useDispatch() + const { t } = useTranslation() + const { fullHeight } = useDeviceDimensions() + const [isEditing, setIsEditing] = useState(false) + const { address } = useActiveAccountWithThrow() + + const onPressScan = useCallback(() => { + // in case we received a pending session from a previous scan after closing modal + dispatch(removePendingSession()) + dispatch(openModal({ name: ModalName.WalletConnectScan, initialState: ScannerModalState.ScanQr })) + }, [dispatch]) + + const disconnectSession = useCallback( + async (session: WalletConnectSession, isNotification = true) => { + try { + dispatch(removeSession({ sessionId: session.id })) + try { + await wcWeb3Wallet.disconnectSession({ + topic: session.id, + reason: getSdkError('USER_DISCONNECTED'), + }) + } catch (error: unknown) { + const isAcceptableError = + error instanceof Error && error.message.startsWith(INTERNAL_ERRORS.NO_MATCHING_KEY.message) + + if (!isAcceptableError) { + // caught by logging catch block + throw error + } + } + + if (isNotification) { + dispatch( + pushNotification({ + type: AppNotificationType.WalletConnect, + address, + dappName: session.dappRequestInfo.name, + event: WalletConnectEvent.Disconnected, + imageUrl: session.dappRequestInfo.icon, + hideDelay: 3 * ONE_SECOND_MS, + }), + ) + } + } catch (error) { + logger.error(error, { tags: { file: 'DappConnectionItem', function: 'onDisconnect' } }) + } + }, + [address, dispatch], + ) + + return ( + <> + + + {backButton ?? } + + + + {t('walletConnect.dapps.manage.title')} + + + + {sessions.length > 0 ? ( + { + try { + await Promise.all( + sessions.map(async (session) => { + await disconnectSession(session, false) + }), + ) + } catch (error) { + logger.error(error, { tags: { file: 'ConnectedDappsList', function: 'removeAllDappConnections' } }) + } + + dispatch( + pushNotification({ + type: AppNotificationType.Success, + title: t('notification.walletConnect.disconnected'), + hideDelay: 3 * ONE_SECOND_MS, + }), + ) + }} + /> + ) : ( + address === selectedAddress && ( + + + + ) + )} + + + + {sessions.length > 0 ? ( + item.id} + numColumns={2} + renderItem={({ item }): JSX.Element => ( + + )} + /> + ) : ( + + + {t('walletConnect.dapps.manage.empty.title')} + + + {t('walletConnect.dapps.empty.description')} + + + )} + + ) +} + +const ColumnStyle = StyleSheet.create({ + base: { + justifyContent: 'space-between', + }, +}) diff --git a/apps/mobile/src/components/Requests/ConnectedDapps/DappConnectionItem.tsx b/apps/mobile/src/components/Requests/ConnectedDapps/DappConnectionItem.tsx new file mode 100644 index 00000000..4654329a --- /dev/null +++ b/apps/mobile/src/components/Requests/ConnectedDapps/DappConnectionItem.tsx @@ -0,0 +1,94 @@ +import 'react-native-reanimated' +import React from 'react' +import { useTranslation } from 'react-i18next' +import { NativeSyntheticEvent, StyleSheet } from 'react-native' +import ContextMenu, { ContextMenuOnPressNativeEvent } from 'react-native-context-menu-view' +import { FadeIn, FadeOut } from 'react-native-reanimated' +import { WalletConnectSession } from 'src/features/walletConnect/walletConnectSlice' +import { AnimatedTouchableArea, Flex, Text } from 'ui/src' +import { iconSizes, spacing } from 'ui/src/theme' +import { noop } from 'utilities/src/react/noop' +import { DappHeaderIcon } from 'wallet/src/components/dappRequests/DappHeaderIcon' + +export function DappConnectionItem({ + session, + isEditing, + handleDisconnect, +}: { + session: WalletConnectSession + isEditing: boolean + handleDisconnect: (session: WalletConnectSession) => Promise +}): JSX.Element { + const { t } = useTranslation() + const { dappRequestInfo } = session + + const menuActions = [{ title: t('common.button.disconnect'), systemIcon: 'trash', destructive: true }] + + const onPress = async (e: NativeSyntheticEvent): Promise => { + if (e.nativeEvent.index === 0) { + await handleDisconnect(session) + } + } + + const onDisconnectSession = async (): Promise => { + await handleDisconnect(session) + } + + return ( + + + + {isEditing ? ( + + + + ) : ( + + )} + + + + + {dappRequestInfo.name || dappRequestInfo.url} + + + {dappRequestInfo.url} + + + + + ) +} + +const styles = StyleSheet.create({ + container: { + width: '48%', + }, +}) diff --git a/apps/mobile/src/components/Requests/ModalWithOverlay/ModalWithOverlay.tsx b/apps/mobile/src/components/Requests/ModalWithOverlay/ModalWithOverlay.tsx new file mode 100644 index 00000000..a4ecdfcf --- /dev/null +++ b/apps/mobile/src/components/Requests/ModalWithOverlay/ModalWithOverlay.tsx @@ -0,0 +1,230 @@ +import { BottomSheetFooter, BottomSheetScrollView, useBottomSheetInternal } from '@gorhom/bottom-sheet' +import { FeatureFlags, useFeatureFlag } from '@universe/gating' +import { PropsWithChildren, useCallback, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { + LayoutChangeEvent, + MeasureLayoutOnSuccessCallback, + NativeScrollEvent, + NativeSyntheticEvent, + ScrollView, + StyleProp, + View, + ViewStyle, +} from 'react-native' +import { AnimatedStyle, useDerivedValue } from 'react-native-reanimated' +import { ScrollDownOverlay } from 'src/components/Requests/ModalWithOverlay/ScrollDownOverlay' +import { Button, ButtonProps, Flex } from 'ui/src' +import { spacing } from 'ui/src/theme' +import { Modal } from 'uniswap/src/components/modals/Modal' +import { ModalProps } from 'uniswap/src/components/modals/ModalProps' +import { useAppInsets } from 'uniswap/src/hooks/useAppInsets' +import { TestID } from 'uniswap/src/test/fixtures/testIDs' + +const MEASURE_LAYOUT_TIMEOUT = 100 + +export type ModalWithOverlayProps = PropsWithChildren< + ModalProps & { + confirmationButtonText?: string + cancelButtonText?: string + scrollDownButtonText?: string + onReject: () => void + onConfirm?: () => void + disableConfirm?: boolean + confirmationLoading?: boolean + contentContainerStyle?: StyleProp>> + cancelButtonProps?: ButtonProps + confirmationButtonProps?: ButtonProps + } +> + +const isCloseToBottom = ({ layoutMeasurement, contentOffset, contentSize }: NativeScrollEvent): boolean => { + return layoutMeasurement.height + contentOffset.y >= contentSize.height - spacing.spacing24 +} + +export function ModalWithOverlay({ + children, + confirmationButtonText, + cancelButtonText, + scrollDownButtonText, + onReject, + onConfirm, + disableConfirm, + confirmationLoading, + contentContainerStyle, + cancelButtonProps, + confirmationButtonProps, + ...bottomSheetModalProps +}: ModalWithOverlayProps): JSX.Element { + const scrollViewRef = useRef(null) + const contentViewRef = useRef(null) + const measureLayoutTimeoutRef = useRef(undefined) + + const startedScrollingRef = useRef(false) + const [showOverlay, setShowOverlay] = useState(false) + const [confirmationEnabled, setConfirmationEnabled] = useState(false) + + const handleScroll = useCallback( + ({ nativeEvent }: NativeSyntheticEvent) => { + startedScrollingRef.current = true + if (showOverlay) { + setShowOverlay(false) + } + if (isCloseToBottom(nativeEvent)) { + setConfirmationEnabled(true) + } + }, + [showOverlay], + ) + + const handleScrollDown = useCallback(() => { + scrollViewRef.current?.scrollToEnd() + }, []) + + const measureContent = useCallback((parentHeight: number) => { + // oxlint-disable-next-line max-params + const onSuccess: MeasureLayoutOnSuccessCallback = (x, y, w, h) => { + if (h > parentHeight) { + setShowOverlay(!startedScrollingRef.current) + } else { + setConfirmationEnabled(true) + } + } + + const contentNode = contentViewRef.current + + if (contentNode) { + contentNode.measure(onSuccess) + } else { + setConfirmationEnabled(true) + } + }, []) + + const handleScrollViewLayout = useCallback( + (e: LayoutChangeEvent) => { + const parentHeight = e.nativeEvent.layout.height + if (measureLayoutTimeoutRef.current) { + clearTimeout(measureLayoutTimeoutRef.current) + } + // BottomSheetScrollView calls onLayout multiple times with different + // height values. In order to make a correct measurement, we have to + // ignore all measurements except the last one, thus we add the timeout + // to cancel measurements when onLayout is called within a small interval + measureLayoutTimeoutRef.current = setTimeout(() => { + measureContent(parentHeight) + }, MEASURE_LAYOUT_TIMEOUT) + }, + [measureContent], + ) + + const eip5792MethodsEnabled = useFeatureFlag(FeatureFlags.Eip5792Methods) + + return ( + + + {children} + + + + + ) +} + +type ModalFooterProps = { + confirmationEnabled: boolean + confirmationLoading?: boolean + showScrollDownOverlay: boolean + cancelButtonText?: string + confirmationButtonText?: string + scrollDownButtonText?: string + cancelButtonProps?: ButtonProps + confirmationButtonProps?: ButtonProps + onScrollDownPress: () => void + onReject: () => void + onConfirm?: () => void +} + +function ModalFooter({ + confirmationEnabled, + confirmationLoading, + showScrollDownOverlay, + scrollDownButtonText, + cancelButtonText, + confirmationButtonText, + cancelButtonProps, + confirmationButtonProps, + onScrollDownPress, + onReject, + onConfirm, +}: ModalFooterProps): JSX.Element { + const { t } = useTranslation() + const insets = useAppInsets() + const { animatedPosition, animatedHandleHeight, animatedFooterHeight, animatedContainerHeight } = + useBottomSheetInternal() + + // Calculate position of the modal footer to ensure it stays at the bottom of the screen + // when the modal content is scrolled + const animatedFooterPosition = useDerivedValue( + () => + Math.max(0, animatedContainerHeight.value - animatedPosition.value) - + animatedFooterHeight.value - + animatedHandleHeight.value, + ) + + return ( + + {showScrollDownOverlay && ( + + )} + + + + + {confirmationButtonText && ( + + )} + + + ) +} diff --git a/apps/mobile/src/components/Requests/ModalWithOverlay/ScrollDownOverlay.tsx b/apps/mobile/src/components/Requests/ModalWithOverlay/ScrollDownOverlay.tsx new file mode 100644 index 00000000..ea3b8e11 --- /dev/null +++ b/apps/mobile/src/components/Requests/ModalWithOverlay/ScrollDownOverlay.tsx @@ -0,0 +1,53 @@ +import { useTranslation } from 'react-i18next' +import { StyleSheet } from 'react-native' +import { FadeInDown, FadeOut } from 'react-native-reanimated' +import Svg, { Defs, LinearGradient, Rect, Stop } from 'react-native-svg' +import { Flex, Text, TouchableArea, useSporeColors } from 'ui/src' +import { ArrowDown } from 'ui/src/components/icons' +import { AnimatedFlex } from 'ui/src/components/layout/AnimatedFlex' +import { useDeviceDimensions } from 'ui/src/hooks/useDeviceDimensions' + +type ScrollDownOverlayProps = { + scrollDownButonText?: string + onScrollDownPress: () => void +} + +export function ScrollDownOverlay({ onScrollDownPress, scrollDownButonText }: ScrollDownOverlayProps): JSX.Element { + const { t } = useTranslation() + const { fullHeight, fullWidth } = useDeviceDimensions() + const colors = useSporeColors() + + return ( + + + + + + + + + + + + + + + + {scrollDownButonText ?? t('common.button.scrollDown')} + + + + + ) +} diff --git a/apps/mobile/src/components/Requests/RequestModal/ActionCannotBeCompletedContent.tsx b/apps/mobile/src/components/Requests/RequestModal/ActionCannotBeCompletedContent.tsx new file mode 100644 index 00000000..f99d0898 --- /dev/null +++ b/apps/mobile/src/components/Requests/RequestModal/ActionCannotBeCompletedContent.tsx @@ -0,0 +1,93 @@ +import { useBottomSheetInternal } from '@gorhom/bottom-sheet' +import { useTranslation } from 'react-i18next' +import { TouchableOpacity } from 'react-native' +import Animated, { useAnimatedStyle } from 'react-native-reanimated' +import { ModalWithOverlay } from 'src/components/Requests/ModalWithOverlay/ModalWithOverlay' +import { ClientDetails } from 'src/components/Requests/RequestModal/ClientDetails' +import { WalletConnectSigningRequest } from 'src/features/walletConnect/walletConnectSlice' +import { Flex, Text } from 'ui/src' +import { AlertTriangleFilled } from 'ui/src/components/icons' +import { spacing } from 'ui/src/theme' +import { uniswapUrls } from 'uniswap/src/constants/urls' +import { ModalName } from 'uniswap/src/features/telemetry/constants' +import { openUri } from 'uniswap/src/utils/linking' +import { AddressFooter } from 'wallet/src/features/transactions/TransactionRequest/AddressFooter' + +export function ActionCannotBeCompletedContent({ + request, + onReject, +}: { + request: WalletConnectSigningRequest + onReject: () => void +}): JSX.Element { + const handleLearnMore = async (): Promise => { + await openUri({ uri: uniswapUrls.helpArticleUrls.mismatchedImports }) + } + + return ( + + + + ) +} + +function ActionCannotBeCompletedModalContent({ + request, + onLearnMore, +}: { + request: WalletConnectSigningRequest + onLearnMore: () => Promise +}): JSX.Element { + const { t } = useTranslation() + const { animatedFooterHeight } = useBottomSheetInternal() + const bottomSpacerStyle = useAnimatedStyle(() => ({ + height: animatedFooterHeight.value - spacing.spacing12, + })) + + return ( + + + + + + + + + + + + {t('dapp.request.actionCannotBeCompleted.title')} + + + {t('dapp.request.actionCannotBeCompleted.description')} + + + + {t('common.button.learn')} + + + + + + + + + ) +} diff --git a/apps/mobile/src/components/Requests/RequestModal/ClientDetails.tsx b/apps/mobile/src/components/Requests/RequestModal/ClientDetails.tsx new file mode 100644 index 00000000..2fb263be --- /dev/null +++ b/apps/mobile/src/components/Requests/RequestModal/ClientDetails.tsx @@ -0,0 +1,28 @@ +import React from 'react' +import { HeaderText } from 'src/components/Requests/RequestModal/HeaderText' +import { WalletConnectSigningRequest } from 'src/features/walletConnect/walletConnectSlice' +import { useCurrencyInfo } from 'uniswap/src/features/tokens/useCurrencyInfo' +import { DappHeaderIcon } from 'wallet/src/components/dappRequests/DappHeaderIcon' +import { DappRequestHeader } from 'wallet/src/components/dappRequests/DappRequestHeader' + +export interface PermitInfo { + currencyId: string + amount: number | undefined +} + +export function ClientDetails({ + request, + permitInfo, +}: { + request: WalletConnectSigningRequest + permitInfo?: PermitInfo +}): JSX.Element { + const { dappRequestInfo } = request + const permitCurrencyInfo = useCurrencyInfo(permitInfo?.currencyId) + const headerIcon = + const title = ( + + ) + + return +} diff --git a/apps/mobile/src/components/Requests/RequestModal/HeaderText.tsx b/apps/mobile/src/components/Requests/RequestModal/HeaderText.tsx new file mode 100644 index 00000000..5fe9b474 --- /dev/null +++ b/apps/mobile/src/components/Requests/RequestModal/HeaderText.tsx @@ -0,0 +1,74 @@ +import { Currency } from '@uniswap/sdk-core' +import React from 'react' +import { Trans } from 'react-i18next' +import { WalletConnectSigningRequest } from 'src/features/walletConnect/walletConnectSlice' +import { Text } from 'ui/src' +import { EthMethod, WalletConnectEthMethod } from 'uniswap/src/features/dappRequests/types' +import { getCurrencyAmount, ValueType } from 'uniswap/src/features/tokens/getCurrencyAmount' +import { UwULinkMethod } from 'uniswap/src/types/walletConnect' + +export function HeaderText({ + request, + permitAmount, + permitCurrency, +}: { + request: WalletConnectSigningRequest + permitAmount?: number + permitCurrency?: Currency | null +}): JSX.Element { + const { dappRequestInfo, type: method } = request + + if (permitCurrency) { + const readablePermitAmount = getCurrencyAmount({ + value: permitAmount?.toString(), + valueType: ValueType.Raw, + currency: permitCurrency, + })?.toExact() + + return readablePermitAmount ? ( + + }} + i18nKey="qrScanner.request.withAmount" + values={{ + dappName: dappRequestInfo.name, + currencySymbol: permitCurrency.symbol, + amount: readablePermitAmount, + }} + /> + + ) : ( + + }} + i18nKey="qrScanner.request.withoutAmount" + values={{ + dappName: dappRequestInfo.name, + currencySymbol: permitCurrency.symbol, + }} + /> + + ) + } + + const getReadableMethodName = ( + ethMethod: WalletConnectEthMethod | UwULinkMethod, + dappNameOrUrl: string, + ): JSX.Element => { + switch (ethMethod) { + case EthMethod.PersonalSign: + case EthMethod.EthSign: + case EthMethod.SignTypedData: + return + case EthMethod.EthSendTransaction: + case UwULinkMethod.Erc20Send: + return + } + + return + } + + return {getReadableMethodName(method, dappRequestInfo.name || dappRequestInfo.url)} +} diff --git a/apps/mobile/src/components/Requests/RequestModal/KidSuperCheckinModal.tsx b/apps/mobile/src/components/Requests/RequestModal/KidSuperCheckinModal.tsx new file mode 100644 index 00000000..508a5540 --- /dev/null +++ b/apps/mobile/src/components/Requests/RequestModal/KidSuperCheckinModal.tsx @@ -0,0 +1,82 @@ +import { useBottomSheetInternal } from '@gorhom/bottom-sheet' +import { useTranslation } from 'react-i18next' +import Animated, { useAnimatedStyle } from 'react-native-reanimated' +import { ModalWithOverlay } from 'src/components/Requests/ModalWithOverlay/ModalWithOverlay' +import { RequestDetailsContent } from 'src/components/Requests/RequestModal/RequestDetails' +import { useUwuLinkContractAllowlist } from 'src/components/Requests/Uwulink/utils' +import { type SignRequest } from 'src/features/walletConnect/walletConnectSlice' +import { Flex, UniversalImage, useIsDarkMode } from 'ui/src' +import { UniversalImageResizeMode } from 'ui/src/components/UniversalImage/types' +import { spacing } from 'ui/src/theme' +import { ModalName } from 'uniswap/src/features/telemetry/constants' + +type Props = { + onClose: () => void + onConfirm: () => void + onReject: () => void + request: SignRequest +} + +export function KidSuperCheckinModal({ onClose, onConfirm, onReject, request }: Props): JSX.Element { + const { t } = useTranslation() + + return ( + + + + ) +} + +function useUniswapCafeLogo(): string | undefined { + const isDarkMode = useIsDarkMode() + const uwuLinkContractAllowlist = useUwuLinkContractAllowlist() + const logos = uwuLinkContractAllowlist.tokenRecipients.find((recipient) => recipient.name === 'Uniswap Cafe')?.logo + + if (!logos) { + return undefined + } + + return isDarkMode ? logos.dark : logos.light +} + +function KidSuperCheckinModalContent({ request }: { request: SignRequest }): JSX.Element { + const { animatedFooterHeight } = useBottomSheetInternal() + const bottomSpacerStyle = useAnimatedStyle(() => ({ + height: animatedFooterHeight.value, + })) + + const logo = useUniswapCafeLogo() + + return ( + + + {logo && ( + + )} + + + + + + + ) +} diff --git a/apps/mobile/src/components/Requests/RequestModal/RequestDetails.tsx b/apps/mobile/src/components/Requests/RequestModal/RequestDetails.tsx new file mode 100644 index 00000000..be12f966 --- /dev/null +++ b/apps/mobile/src/components/Requests/RequestModal/RequestDetails.tsx @@ -0,0 +1,179 @@ +import { BigNumber } from '@ethersproject/bignumber' +import React, { PropsWithChildren } from 'react' +import { useTranslation } from 'react-i18next' +import { + isPersonalSignRequest, + isTransactionRequest, + SignRequest, + WalletConnectSigningRequest, +} from 'src/features/walletConnect/walletConnectSlice' +import { Flex, Text } from 'ui/src' +import { ContentRow } from 'uniswap/src/components/transactions/requests/ContentRow' +import { UniverseChainId } from 'uniswap/src/features/chains/types' +import { EthMethod } from 'uniswap/src/features/dappRequests/types' +import { Platform } from 'uniswap/src/features/platforms/types/Platform' +import { EthTransaction } from 'uniswap/src/types/walletConnect' +import { getValidAddress } from 'uniswap/src/utils/addresses' +import { logger } from 'utilities/src/logger/logger' +import { AddressButton } from 'wallet/src/components/buttons/AddressButton' +import { + SpendingDetails, + SpendingEthDetails, +} from 'wallet/src/features/transactions/TransactionRequest/SpendingDetails' +import { useNoYoloParser } from 'wallet/src/utils/useNoYoloParser' +import { useTransactionCurrencies } from 'wallet/src/utils/useTransactionCurrencies' + +const MAX_TYPED_DATA_PARSE_DEPTH = 3 + +const getStrMessage = (request: WalletConnectSigningRequest): string => { + if (isPersonalSignRequest(request)) { + return request.message || request.rawMessage + } + + return '' +} + +type KeyValueRowProps = { + objKey: string +} & PropsWithChildren + +const KeyValueRow = ({ objKey, children }: KeyValueRowProps): JSX.Element => { + return ( + + + {objKey} + + + {children} + + + ) +} + +// recursively parses typed data objects and adds margin to left +const getParsedObjectDisplay = ({ + chainId, + obj, + depth = 0, +}: { + chainId: number + // oxlint-disable-next-line typescript/no-explicit-any -- Function handles arbitrary JSON data structure + obj: any + depth?: number +}): JSX.Element => { + if (depth === MAX_TYPED_DATA_PARSE_DEPTH + 1) { + return ... + } + + if (Array.isArray(obj) || obj === null || obj === undefined || typeof obj !== 'object') { + return {Array.isArray(obj) ? JSON.stringify(obj) : String(obj)} + } + + return ( + + {Object.keys(obj).map((objKey) => { + const childValue = obj[objKey] + + // Special case for address strings + // TODO(WALL-7065): Handle SVM address validation as well + if ( + typeof childValue === 'string' && + getValidAddress({ address: childValue, platform: Platform.EVM, withEVMChecksum: true }) + ) { + return ( + + + + + + ) + } + + return ( + + {getParsedObjectDisplay({ chainId, obj: childValue, depth: depth + 1 })} + + ) + })} + + ) +} + +function TransactionDetails({ + chainId, + transaction, +}: { + chainId: UniverseChainId + transaction: EthTransaction +}): JSX.Element { + const { t } = useTranslation() + const { parsedTransactionData, isLoading } = useNoYoloParser(transaction, chainId) + const { to, value } = transaction + + const transactionCurrencies = useTransactionCurrencies({ chainId, to, parsedTransactionData }) + + return ( + + {value && !BigNumber.from(value).eq(0) ? : null} + {transactionCurrencies.map((currencyInfo, i) => ( + + ))} + {to ? ( + + + + ) : null} + + + + {parsedTransactionData ? parsedTransactionData.name : t('common.text.unknown')} + + + + + ) +} + +interface RequestDetailsContentProps { + request: WalletConnectSigningRequest +} + +function isSignTypedDataRequest(request: WalletConnectSigningRequest): request is SignRequest { + return request.type === EthMethod.SignTypedData || request.type === EthMethod.SignTypedDataV4 +} + +export function RequestDetailsContent({ request }: RequestDetailsContentProps): JSX.Element { + const { t } = useTranslation() + + if (isSignTypedDataRequest(request)) { + try { + const data = JSON.parse(request.rawMessage) + return getParsedObjectDisplay({ chainId: request.chainId, obj: data.message }) + } catch (error) { + logger.error(error, { tags: { file: 'RequestDetails', function: 'RequestDetailsContent' } }) + return + } + } + + if (isTransactionRequest(request)) { + return + } + + const message = getStrMessage(request) + return ( + + {message || t('qrScanner.request.message.unavailable')} + + ) +} diff --git a/apps/mobile/src/components/Requests/RequestModal/UwULinkErc20SendModal.tsx b/apps/mobile/src/components/Requests/RequestModal/UwULinkErc20SendModal.tsx new file mode 100644 index 00000000..dc28171a --- /dev/null +++ b/apps/mobile/src/components/Requests/RequestModal/UwULinkErc20SendModal.tsx @@ -0,0 +1,166 @@ +import { useBottomSheetInternal } from '@gorhom/bottom-sheet' +import { type GasFeeResult } from '@universe/api' +import { formatUnits } from 'ethers/lib/utils' +import { useTranslation } from 'react-i18next' +import Animated, { useAnimatedStyle } from 'react-native-reanimated' +import { ModalWithOverlay } from 'src/components/Requests/ModalWithOverlay/ModalWithOverlay' +import { type UwuLinkErc20Request } from 'src/features/walletConnect/walletConnectSlice' +import { Flex, SpinningLoader, Text, UniversalImage, useIsDarkMode } from 'ui/src' +import { UniversalImageResizeMode } from 'ui/src/components/UniversalImage/types' +import { iconSizes, spacing } from 'ui/src/theme' +import { TokenLogo } from 'uniswap/src/components/CurrencyLogo/TokenLogo' +import { NetworkFee } from 'uniswap/src/components/gas/NetworkFee' +import { nativeOnChain } from 'uniswap/src/constants/tokens' +import { getChainLabel } from 'uniswap/src/features/chains/utils' +import { type CurrencyInfo } from 'uniswap/src/features/dataApi/types' +import { useLocalizationContext } from 'uniswap/src/features/language/LocalizationContext' +import { useOnChainCurrencyBalance } from 'uniswap/src/features/portfolio/api' +import { ModalName } from 'uniswap/src/features/telemetry/constants' +import { useCurrencyInfo } from 'uniswap/src/features/tokens/useCurrencyInfo' +import { buildCurrencyId } from 'uniswap/src/utils/currencyId' +import { NumberType } from 'utilities/src/format/types' +import { useActiveAccountAddressWithThrow } from 'wallet/src/features/wallet/hooks' + +type Props = { + onClose: () => void + onConfirm: () => void + onReject: () => void + request: UwuLinkErc20Request + hasSufficientGasFunds: boolean + confirmEnabled: boolean + gasFee: GasFeeResult +} + +export function UwULinkErc20SendModal({ + gasFee, + onClose, + onConfirm, + onReject, + request, + confirmEnabled, + hasSufficientGasFunds, +}: Props): JSX.Element { + const { t } = useTranslation() + const activeAccountAddress = useActiveAccountAddressWithThrow() + // TODO: wallet should determine if the currency is stablecoin + const { chainId, tokenAddress, amount } = request + const currencyInfo = useCurrencyInfo(buildCurrencyId(chainId, tokenAddress)) + const { balance } = useOnChainCurrencyBalance(currencyInfo?.currency, activeAccountAddress) + + const hasSufficientTokenFunds = !balance?.lessThan(amount) + + return ( + + + + ) +} + +function UwULinkErc20SendModalContent({ + gasFee, + request, + loading, + currencyInfo, + hasSufficientGasFunds, + hasSufficientTokenFunds, +}: { + gasFee: GasFeeResult + request: UwuLinkErc20Request + loading: boolean + hasSufficientGasFunds: boolean + hasSufficientTokenFunds: boolean + currencyInfo: Maybe +}): JSX.Element { + const { t } = useTranslation() + const isDarkMode = useIsDarkMode() + const { animatedFooterHeight } = useBottomSheetInternal() + const bottomSpacerStyle = useAnimatedStyle(() => ({ + height: animatedFooterHeight.value, + })) + const { convertFiatAmountFormatted } = useLocalizationContext() + + const { chainId, isStablecoin } = request + const nativeCurrency = nativeOnChain(chainId) + + if (loading || !currencyInfo) { + return ( + + + + + ) + } + + const { + logoUrl, + currency: { name, symbol, decimals }, + } = currencyInfo + + const recipientLogoUrl = isDarkMode ? request.recipient.logo?.dark : request.recipient.logo?.light + + const formattedTokenAmount = isStablecoin + ? convertFiatAmountFormatted(formatUnits(request.amount, decimals), NumberType.FiatStandard) + : formatUnits(request.amount, decimals) + + return ( + + {recipientLogoUrl ? ( + + ) : ( + {request.recipient.name} + )} + + {!hasSufficientTokenFunds && ( + + {t('uwulink.error.insufficientTokens', { + tokenSymbol: symbol ?? '', + chain: getChainLabel(chainId), + })} + + )} + + {formattedTokenAmount} + + + + + {formatUnits(request.amount, decimals)} {symbol} + + + + + + + {!hasSufficientGasFunds && ( + + {t('walletConnect.request.error.insufficientFunds', { + currencySymbol: nativeCurrency.symbol ?? '', + })} + + )} + + + ) +} diff --git a/apps/mobile/src/components/Requests/RequestModal/WalletConnectRequestModal.tsx b/apps/mobile/src/components/Requests/RequestModal/WalletConnectRequestModal.tsx new file mode 100644 index 00000000..e9ab0e65 --- /dev/null +++ b/apps/mobile/src/components/Requests/RequestModal/WalletConnectRequestModal.tsx @@ -0,0 +1,336 @@ +import { useNetInfo } from '@react-native-community/netinfo' +import { FeatureFlags, useFeatureFlag } from '@universe/gating' +import { getSdkError } from '@walletconnect/utils' +import { providers } from 'ethers' +import React, { useMemo, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useDispatch, useSelector } from 'react-redux' +import { ModalWithOverlay } from 'src/components/Requests/ModalWithOverlay/ModalWithOverlay' +import { ActionCannotBeCompletedContent } from 'src/components/Requests/RequestModal/ActionCannotBeCompletedContent' +import { useHasSufficientFunds } from 'src/components/Requests/RequestModal/hooks' +import { KidSuperCheckinModal } from 'src/components/Requests/RequestModal/KidSuperCheckinModal' +import { UwULinkErc20SendModal } from 'src/components/Requests/RequestModal/UwULinkErc20SendModal' +import { + getDoesMethodCostGas, + WalletConnectRequestModalContent, +} from 'src/components/Requests/RequestModal/WalletConnectRequestModalContent' +import { useBiometricAppSettings } from 'src/features/biometrics/useBiometricAppSettings' +import { useBiometricPrompt } from 'src/features/biometricsSettings/hooks' +import { selectDidOpenFromDeepLink } from 'src/features/walletConnect/selectors' +import { signWcRequestActions } from 'src/features/walletConnect/signWcRequestSaga' +import { returnToPreviousApp } from 'src/features/walletConnect/WalletConnect' +import { wcWeb3Wallet } from 'src/features/walletConnect/walletConnectClient' +import { + isBatchedTransactionRequest, + isTransactionRequest, + setDidOpenFromDeepLink, + WalletConnectSigningRequest, +} from 'src/features/walletConnect/walletConnectSlice' +import { spacing } from 'ui/src/theme' +import { EthMethod } from 'uniswap/src/features/dappRequests/types' +import { isSelfCallWithData, isSignTypedDataRequest } from 'uniswap/src/features/dappRequests/utils' +import { useTransactionGasFee } from 'uniswap/src/features/gas/hooks' +import { Platform } from 'uniswap/src/features/platforms/types/Platform' +import { useHasAccountMismatchCallback } from 'uniswap/src/features/smartWallet/mismatch/hooks' +import { MobileEventName, ModalName } from 'uniswap/src/features/telemetry/constants' +import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { DappRequestType, UwULinkMethod, WCEventType, WCRequestOutcome } from 'uniswap/src/types/walletConnect' +import { areAddressesEqual } from 'uniswap/src/utils/addresses' +import { useBooleanState } from 'utilities/src/react/useBooleanState' +import { TransactionRiskLevel } from 'wallet/src/features/dappRequests/types' +import { shouldDisableConfirm } from 'wallet/src/features/dappRequests/utils/riskUtils' +import { formatExternalTxnWithGasEstimates } from 'wallet/src/features/gas/formatExternalTxnWithGasEstimates' +import { useLiveAccountDelegationDetails } from 'wallet/src/features/smartWallet/hooks/useLiveAccountDelegationDetails' +import { useHasSmartWalletConsent, useSignerAccounts } from 'wallet/src/features/wallet/hooks' + +interface Props { + onClose: () => void + request: WalletConnectSigningRequest +} + +const VALID_REQUEST_TYPES = [ + EthMethod.PersonalSign, + EthMethod.SignTypedData, + EthMethod.SignTypedDataV4, + EthMethod.EthSign, + EthMethod.EthSendTransaction, + UwULinkMethod.Erc20Send, + EthMethod.WalletSendCalls, +] + +// oxlint-disable-next-line complexity -- biome-parity: oxlint is stricter here +export function WalletConnectRequestModal({ onClose, request }: Props): JSX.Element | null { + const { t } = useTranslation() + const netInfo = useNetInfo() + const didOpenFromDeepLink = useSelector(selectDidOpenFromDeepLink) + const chainId = request.chainId + // Initialize with null to indicate scan hasn't completed yet + const [riskLevel, setRiskLevel] = useState(null) + const { value: confirmedRisk, setValue: setConfirmedRisk } = useBooleanState(false) + + const enablePermitMismatchUx = useFeatureFlag(FeatureFlags.EnablePermitMismatchUX) + const enableEip5792Methods = useFeatureFlag(FeatureFlags.Eip5792Methods) + const hasSmartWalletConsent = useHasSmartWalletConsent() + + const tx: providers.TransactionRequest | undefined = useMemo(() => { + if (isTransactionRequest(request)) { + return { ...request.transaction, chainId } + } + if (isBatchedTransactionRequest(request)) { + return { ...request.encodedTransaction, chainId } + } + return undefined + }, [chainId, request]) + + const signerAccounts = useSignerAccounts() + const signerAccount = signerAccounts.find((account) => + // TODO(WALL-7065): Update to support solana + areAddressesEqual({ + addressInput1: { address: account.address, platform: Platform.EVM }, + addressInput2: { address: request.account, platform: Platform.EVM }, + }), + ) + const delegationData = useLiveAccountDelegationDetails({ + address: request.account, + chainId, + }) + // Check if this is a self-transaction (to === from) with data + // This is required for delegation to occur + // Note: chainId is required for correct address comparison + const isSelfTransaction = useMemo( + () => + isSelfCallWithData({ + from: request.account, + to: tx?.to, + data: tx?.data ? String(tx.data) : undefined, + chainId, + }), + [request.account, tx?.to, tx?.data, chainId], + ) + const shouldDelegate = Boolean( + delegationData?.needsDelegation && enableEip5792Methods && hasSmartWalletConsent && isSelfTransaction, + ) + const smartContractDelegationAddress = shouldDelegate + ? delegationData?.contractAddress // latest Uniswap delegation address + : delegationData?.currentDelegationAddress + const gasFee = useTransactionGasFee({ + tx, + ...(smartContractDelegationAddress && { smartContractDelegationAddress }), + }) + + const hasSufficientFunds = useHasSufficientFunds({ + account: request.account, + chainId, + gasFee, + value: tx?.value?.toString(), + }) + + const getHasMismatch = useHasAccountMismatchCallback() + const hasMismatch = getHasMismatch(chainId) + // When link mode is active we can sign messages through universal links on device + const suppressOfflineWarning = request.isLinkModeSupported + + const checkConfirmEnabled = (): boolean => { + if (!netInfo.isInternetReachable && !suppressOfflineWarning) { + return false + } + + if (!signerAccount) { + return false + } + + if (shouldDisableConfirm({ riskLevel, confirmedRisk })) { + return false + } + + if (getDoesMethodCostGas(request)) { + return !!(tx && hasSufficientFunds && gasFee.value && !gasFee.error && !gasFee.isLoading) + } + + if (isTransactionRequest(request)) { + return !!tx + } + + return true + } + + const confirmEnabled = checkConfirmEnabled() + const dispatch = useDispatch() + /** + * TODO: [MOB-239] implement this behavior in a less janky way. Ideally if we can distinguish between `onClose` being called programmatically and `onClose` as a results of a user dismissing the modal then we can determine what this value should be without this class variable. + * Indicates that the modal can reject the request when the modal happens. This will be false when the modal closes as a result of the user explicitly confirming or rejecting a request and true otherwise. + */ + const rejectOnCloseRef = useRef(true) + + const onReject = async (): Promise => { + if (request.dappRequestInfo.requestType === DappRequestType.WalletConnectSessionRequest) { + await wcWeb3Wallet.respondSessionRequest({ + topic: request.sessionId, + response: { + id: Number(request.internalId), + jsonrpc: '2.0', + error: getSdkError('USER_REJECTED'), + }, + }) + } + + rejectOnCloseRef.current = false + + sendAnalyticsEvent(MobileEventName.WalletConnectSheetCompleted, { + request_type: isTransactionRequest(request) ? WCEventType.TransactionRequest : WCEventType.SignRequest, + eth_method: request.type, + dapp_url: request.dappRequestInfo.url, + dapp_name: request.dappRequestInfo.name, + wc_version: '2', + chain_id: chainId, + outcome: WCRequestOutcome.Reject, + }) + + onClose() + if (didOpenFromDeepLink) { + await returnToPreviousApp() + setDidOpenFromDeepLink(false) + } + } + + const onConfirm = async (): Promise => { + if (!confirmEnabled || !signerAccount) { + return + } + + if ( + request.type === EthMethod.EthSendTransaction || + request.type === UwULinkMethod.Erc20Send || + request.type === EthMethod.WalletSendCalls + ) { + if (!tx) { + return + } + const txnWithFormattedGasEstimates = formatExternalTxnWithGasEstimates({ + transaction: tx, + gasFeeResult: gasFee, + }) + + dispatch( + signWcRequestActions.trigger({ + sessionId: request.sessionId, + requestInternalId: request.internalId, + method: request.type === EthMethod.WalletSendCalls ? EthMethod.WalletSendCalls : EthMethod.EthSendTransaction, + transaction: txnWithFormattedGasEstimates, + account: signerAccount, + dappRequestInfo: request.dappRequestInfo, + chainId, + request, + }), + ) + } else { + dispatch( + signWcRequestActions.trigger({ + sessionId: request.sessionId, + requestInternalId: request.internalId, + // this is EthSignMessage type + method: request.type, + message: request.message || request.rawMessage, + account: signerAccount, + dappRequestInfo: request.dappRequestInfo, + chainId, + }), + ) + } + + rejectOnCloseRef.current = false + + sendAnalyticsEvent(MobileEventName.WalletConnectSheetCompleted, { + request_type: isTransactionRequest(request) ? WCEventType.TransactionRequest : WCEventType.SignRequest, + eth_method: request.type, + dapp_url: request.dappRequestInfo.url, + dapp_name: request.dappRequestInfo.name, + wc_version: '2', + chain_id: chainId, + outcome: WCRequestOutcome.Confirm, + }) + + onClose() + if (didOpenFromDeepLink) { + await returnToPreviousApp() + setDidOpenFromDeepLink(false) + } + } + + const { trigger: actionButtonTrigger } = useBiometricPrompt(onConfirm) + const { requiredForTransactions } = useBiometricAppSettings() + + const onConfirmPress = async (): Promise => { + if (requiredForTransactions) { + await actionButtonTrigger() + } else { + await onConfirm() + } + } + + if (!VALID_REQUEST_TYPES.includes(request.type)) { + return null + } + + const handleClose = async (): Promise => { + if (rejectOnCloseRef.current) { + await onReject() + } else { + onClose() + } + } + + if (request.type === UwULinkMethod.Erc20Send) { + return ( + + ) + } + + if (enablePermitMismatchUx && hasMismatch && isSignTypedDataRequest(request)) { + return + } + + // KidSuper Uniswap Cafe check-in screen + if (request.type === EthMethod.PersonalSign && request.dappRequestInfo.name === 'Uniswap Cafe') { + return ( + + ) + } + + return ( + + + + ) +} diff --git a/apps/mobile/src/components/Requests/RequestModal/WalletConnectRequestModalContent.tsx b/apps/mobile/src/components/Requests/RequestModal/WalletConnectRequestModalContent.tsx new file mode 100644 index 00000000..05b9a1ab --- /dev/null +++ b/apps/mobile/src/components/Requests/RequestModal/WalletConnectRequestModalContent.tsx @@ -0,0 +1,300 @@ +import { useBottomSheetInternal } from '@gorhom/bottom-sheet' +import { useNetInfo } from '@react-native-community/netinfo' +import { GasFeeResult } from '@universe/api' +import { useTranslation } from 'react-i18next' +import Animated, { useAnimatedStyle } from 'react-native-reanimated' +import { ClientDetails, PermitInfo } from 'src/components/Requests/RequestModal/ClientDetails' +import { + isBatchedTransactionRequest, + isTransactionRequest, + WalletConnectSigningRequest, +} from 'src/features/walletConnect/walletConnectSlice' +import { Flex, Text } from 'ui/src' +import { AlertTriangleFilled } from 'ui/src/components/icons' +import { BaseCard } from 'uniswap/src/components/BaseCard/BaseCard' +import { getChainInfo } from 'uniswap/src/features/chains/chainInfo' +import { EthMethod } from 'uniswap/src/features/dappRequests/types' +import { hasGasEstimationFailed } from 'uniswap/src/features/gas/utils' +import { isPrimaryTypePermit, UwULinkMethod } from 'uniswap/src/types/walletConnect' +import { buildCurrencyId } from 'uniswap/src/utils/currencyId' +import { logger } from 'utilities/src/logger/logger' +import { MAX_HIDDEN_CALLS_BY_DEFAULT } from 'wallet/src/components/BatchedTransactions/BatchedTransactionDetails' +import { DappPersonalSignContent } from 'wallet/src/components/dappRequests/DappPersonalSignContent' +import { DappSendCallsScanningContent } from 'wallet/src/components/dappRequests/DappSendCallsScanningContent' +import { DappSignTypedDataContent } from 'wallet/src/components/dappRequests/DappSignTypedDataContent' +import { DappTransactionScanningContent } from 'wallet/src/components/dappRequests/DappTransactionScanningContent' +import { WarningBox } from 'wallet/src/components/WarningBox/WarningBox' +import { TransactionRiskLevel } from 'wallet/src/features/dappRequests/types' + +const isPotentiallyUnsafe = (request: WalletConnectSigningRequest): boolean => request.type !== EthMethod.PersonalSign + +export const getDoesMethodCostGas = (request: WalletConnectSigningRequest): boolean => + request.type === EthMethod.EthSendTransaction || request.type === EthMethod.WalletSendCalls + +/** If the request is a permit then parse the relevant information otherwise return undefined. */ +const getPermitInfo = (request: WalletConnectSigningRequest): PermitInfo | undefined => { + if (request.type !== EthMethod.SignTypedDataV4) { + return undefined + } + + try { + const message = JSON.parse(request.rawMessage) + if (!isPrimaryTypePermit(message)) { + return undefined + } + + const { domain, message: permitPayload } = message + const currencyId = buildCurrencyId(domain.chainId, domain.verifyingContract) + const amount = permitPayload.value + + return { currencyId, amount } + } catch (error) { + logger.error(error, { + tags: { file: 'WalletConnectRequestModal', function: 'getPermitInfo' }, + }) + return undefined + } +} + +type WalletConnectRequestModalContentProps = { + gasFee: GasFeeResult + hasSufficientFunds: boolean + request: WalletConnectSigningRequest + showSmartWalletActivation?: boolean + confirmedRisk: boolean + onConfirmRisk: (confirmed: boolean) => void + onRiskLevelChange: (riskLevel: TransactionRiskLevel) => void +} + +export function WalletConnectRequestModalContent({ + request, + hasSufficientFunds, + gasFee, + showSmartWalletActivation, + confirmedRisk, + onConfirmRisk, + onRiskLevelChange, +}: WalletConnectRequestModalContentProps): JSX.Element { + const chainId = request.chainId + const permitInfo = getPermitInfo(request) + const nativeCurrency = getChainInfo(chainId).nativeCurrency + + const { animatedFooterHeight } = useBottomSheetInternal() + + const netInfo = useNetInfo() + + const bottomSpacerStyle = useAnimatedStyle(() => ({ + height: animatedFooterHeight.value, + })) + + // If link mode is supported, we can sign messages through universal links on device + const suppressOfflineWarning = request.isLinkModeSupported + + return ( + <> + + + + + + + + + + + + ) +} + +function RequestWarnings({ + request, + hasSufficientFunds, + isNetworkReachable, + suppressOfflineWarning, + nativeCurrencySymbol, + gasFee, +}: { + request: WalletConnectSigningRequest + hasSufficientFunds: boolean + isNetworkReachable: boolean + suppressOfflineWarning: boolean + nativeCurrencySymbol: string + gasFee: GasFeeResult +}): JSX.Element { + const { t } = useTranslation() + + // Check if gas estimation failed (has error or no value after loading) + const isTransactionRequestType = getDoesMethodCostGas(request) + const gasEstimationFailed = hasGasEstimationFailed(isTransactionRequestType, gasFee) + + return ( + <> + {gasEstimationFailed && ( + + + {t('dapp.request.error.gasEstimation')} + + + )} + + {!hasSufficientFunds && !gasEstimationFailed && ( + + + {t('walletConnect.request.error.insufficientFunds', { + currencySymbol: nativeCurrencySymbol, + })} + + + )} + + {!isNetworkReachable && !suppressOfflineWarning ? ( + } + textColor="$statusWarning" + title={t('walletConnect.request.error.network')} + /> + ) : ( + + )} + + ) +} + +function WarningSection({ + request, + showUnsafeWarning, +}: { + request: WalletConnectSigningRequest + showUnsafeWarning: boolean +}): JSX.Element | null { + const { t } = useTranslation() + + if (!showUnsafeWarning) { + return null + } + + if (isBatchedTransactionRequest(request)) { + if (request.calls.length <= 1) { + return null + } + const level = request.calls.length >= MAX_HIDDEN_CALLS_BY_DEFAULT ? 'critical' : 'warning' + return + } + + // TODO: Refactor to explicitly warn users only about signing requests instead of all non-transaction requests + if (!isTransactionRequest(request)) { + return + } + + return null +} + +/** Helper component to render appropriate scanning content based on request type */ +function ScanningContent({ + request, + chainId, + gasFee, + showSmartWalletActivation, + confirmedRisk, + onConfirmRisk, + onRiskLevelChange, +}: { + request: WalletConnectSigningRequest + chainId: number + gasFee: GasFeeResult + showSmartWalletActivation?: boolean + confirmedRisk: boolean + onConfirmRisk: (confirmed: boolean) => void + onRiskLevelChange: (riskLevel: TransactionRiskLevel) => void +}): JSX.Element { + switch (request.type) { + case EthMethod.EthSendTransaction: + case UwULinkMethod.Erc20Send: + return ( + + ) + + case EthMethod.PersonalSign: + case EthMethod.EthSign: + return ( + + ) + + case EthMethod.WalletSendCalls: + return ( + + ) + + case EthMethod.SignTypedData: + case EthMethod.SignTypedDataV4: + return ( + + ) + } +} diff --git a/apps/mobile/src/components/Requests/RequestModal/hooks.ts b/apps/mobile/src/components/Requests/RequestModal/hooks.ts new file mode 100644 index 00000000..84f2a3cd --- /dev/null +++ b/apps/mobile/src/components/Requests/RequestModal/hooks.ts @@ -0,0 +1,45 @@ +import { GasFeeResult } from '@universe/api' +import { useMemo } from 'react' +import { useEnabledChains } from 'uniswap/src/features/chains/hooks/useEnabledChains' +import { UniverseChainId } from 'uniswap/src/features/chains/types' +import { useChainGasToken } from 'uniswap/src/features/gas/hooks/useChainGasToken' +import { hasSufficientGasBalance } from 'uniswap/src/features/gas/utils' +import { getCurrencyAmount, ValueType } from 'uniswap/src/features/tokens/getCurrencyAmount' + +export function useHasSufficientFunds({ + account, + chainId, + gasFee, + value, +}: { + account?: string + chainId?: UniverseChainId + gasFee: GasFeeResult + value?: string +}): boolean { + const { defaultChainId } = useEnabledChains() + const effectiveChainId = chainId ?? defaultChainId + const { gasToken, gasBalance } = useChainGasToken({ chainId: effectiveChainId, accountAddress: account }) + + const hasSufficientFunds = useMemo(() => { + // The `value` represents the transaction's native value field. On standard chains this + // is the native currency; on Tempo this maps to pathUSD (the gas token). We always + // include it as gasTokenTransactionAmount so the sufficiency check accounts for both + // the send amount and the gas fee drawing from the same balance. + const transactionAmount = + getCurrencyAmount({ + value, + valueType: ValueType.Raw, + currency: gasToken, + }) ?? undefined + + return hasSufficientGasBalance({ + chainId: effectiveChainId, + gasBalance, + gasFee: gasFee.value, + gasTokenTransactionAmount: transactionAmount, + }) + }, [value, gasToken, gasFee.value, gasBalance, effectiveChainId]) + + return hasSufficientFunds +} diff --git a/apps/mobile/src/components/Requests/ScanSheet/PendingConnectionModal.tsx b/apps/mobile/src/components/Requests/ScanSheet/PendingConnectionModal.tsx new file mode 100644 index 00000000..6f64a031 --- /dev/null +++ b/apps/mobile/src/components/Requests/ScanSheet/PendingConnectionModal.tsx @@ -0,0 +1,280 @@ +import { useBottomSheetInternal } from '@gorhom/bottom-sheet' +import { FeatureFlags, useFeatureFlag } from '@universe/gating' +import { getSdkError } from '@walletconnect/utils' +import React, { useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import Animated, { useAnimatedStyle } from 'react-native-reanimated' +import { useDispatch, useSelector } from 'react-redux' +import { ModalWithOverlay, ModalWithOverlayProps } from 'src/components/Requests/ModalWithOverlay/ModalWithOverlay' +import { selectDidOpenFromDeepLink } from 'src/features/walletConnect/selectors' +import { convertCapabilitiesToScopedProperties, getSessionNamespaces } from 'src/features/walletConnect/utils' +import { returnToPreviousApp } from 'src/features/walletConnect/WalletConnect' +import { wcWeb3Wallet } from 'src/features/walletConnect/walletConnectClient' +import { + addSession, + removePendingSession, + setDidOpenFromDeepLink, + WalletConnectPendingSession, +} from 'src/features/walletConnect/walletConnectSlice' +import { Flex } from 'ui/src' +import { AccountType } from 'uniswap/src/features/accounts/types' +import { pushNotification } from 'uniswap/src/features/notifications/slice/slice' +import { AppNotificationType } from 'uniswap/src/features/notifications/slice/types' +import { MobileEventName, ModalName } from 'uniswap/src/features/telemetry/constants' +import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { DappRequestType, WalletConnectEvent, WCEventType, WCRequestOutcome } from 'uniswap/src/types/walletConnect' +import { useEvent } from 'utilities/src/react/hooks' +import { ONE_SECOND_MS } from 'utilities/src/time/time' +import { DappConnectionContent } from 'wallet/src/components/dappRequests/DappConnectionContent' +import { DappRequestHeader } from 'wallet/src/components/dappRequests/DappRequestHeader' +import { getCapabilitiesCore } from 'wallet/src/features/batchedTransactions/utils' +import { useBlockaidVerification } from 'wallet/src/features/dappRequests/hooks/useBlockaidVerification' +import { useDappConnectionConfirmation } from 'wallet/src/features/dappRequests/hooks/useDappConnectionConfirmation' +import { DappConnectionInfo, DappVerificationStatus } from 'wallet/src/features/dappRequests/types' +import { mergeVerificationStatuses } from 'wallet/src/features/dappRequests/verification' +import { + useActiveAccountWithThrow, + useHasSmartWalletConsent, + useSignerAccounts, +} from 'wallet/src/features/wallet/hooks' + +type Props = { + pendingSession: WalletConnectPendingSession + onClose: () => void +} + +export const PendingConnectionModal = ({ pendingSession, onClose }: Props): JSX.Element => { + const { t } = useTranslation() + + const dispatch = useDispatch() + const activeAccount = useActiveAccountWithThrow() + const activeAddress = activeAccount.address + const isViewOnly = activeAccount.type === AccountType.Readonly + + const didOpenFromDeepLink = useSelector(selectDidOpenFromDeepLink) + const hasSmartWalletConsent = useHasSmartWalletConsent() + const eip5792MethodsEnabled = useFeatureFlag(FeatureFlags.Eip5792Methods) + + const [isConnecting, setIsConnecting] = useState(false) + + // Merge WalletConnect verification with Blockaid verification + const { verificationStatus: blockaidStatus } = useBlockaidVerification(pendingSession.dappRequestInfo.url) + const finalVerificationStatus = mergeVerificationStatuses(pendingSession.verifyStatus, blockaidStatus) + + const { confirmedWarning, setConfirmedWarning, disableConfirm } = useDappConnectionConfirmation({ + verificationStatus: finalVerificationStatus, + isViewOnly, + isLoading: isConnecting, + }) + + const signerAccounts = useSignerAccounts() + const defaultSelectedAccountAddresses = useMemo(() => { + return signerAccounts.map((account) => account.address) + }, [signerAccounts]) + + const [selectedAccountAddresses, setSelectedAccountAddresses] = useState(defaultSelectedAccountAddresses) + + // Sort the active account to the front of the list in the UI + const orderedAllAccountAddresses = useMemo(() => { + return [...signerAccounts.map((account) => account.address)].sort((a, b) => { + if (a === activeAddress) { + return -1 + } + if (b === activeAddress) { + return 1 + } + return 0 + }) + }, [signerAccounts, activeAddress]) + + // Sort the active account to the front of the list, so that when we construct namespaces, the active account is first, + // so that it appears as the active connection on the dapp + const orderedSelectedAccountAddresses = useMemo(() => { + return [...selectedAccountAddresses].sort((a, b) => { + return a === activeAddress ? -1 : b === activeAddress ? 1 : 0 + }) + }, [selectedAccountAddresses, activeAddress]) + + const onPressSettleConnection = useEvent(async (approved: boolean) => { + // Prevent multiple concurrent connection attempts + if (isConnecting) { + return + } + + setIsConnecting(true) + + try { + sendAnalyticsEvent(MobileEventName.WalletConnectSheetCompleted, { + request_type: WCEventType.SessionPending, + dapp_url: pendingSession.dappRequestInfo.url, + dapp_name: pendingSession.dappRequestInfo.name, + wc_version: '2', + connection_chain_ids: pendingSession.chains, + outcome: approved ? WCRequestOutcome.Confirm : WCRequestOutcome.Reject, + }) + + // Handle WC 2.0 session request + if (approved) { + const namespaces = getSessionNamespaces(orderedSelectedAccountAddresses, pendingSession.proposalNamespaces) + const capabilities = await getCapabilitiesCore({ + address: activeAddress, + chainIds: pendingSession.chains, + hasSmartWalletConsent: hasSmartWalletConsent ?? false, + }) + + const scopedProperties = convertCapabilitiesToScopedProperties(capabilities) + + const session = await wcWeb3Wallet.approveSession({ + id: Number(pendingSession.id), + namespaces, + ...(eip5792MethodsEnabled ? { scopedProperties } : {}), + }) + + dispatch( + addSession({ + wcSession: { + id: session.topic, + dappRequestInfo: { + name: session.peer.metadata.name, + url: session.peer.metadata.url, + icon: session.peer.metadata.icons[0] ?? null, + requestType: DappRequestType.WalletConnectSessionRequest, + }, + chains: pendingSession.chains, + namespaces, + activeAccount: activeAddress, + ...(eip5792MethodsEnabled ? { capabilities } : {}), + }, + }), + ) + + dispatch( + pushNotification({ + type: AppNotificationType.WalletConnect, + address: activeAddress, + event: WalletConnectEvent.Connected, + dappName: session.peer.metadata.name, + imageUrl: session.peer.metadata.icons[0] ?? null, + hideDelay: 3 * ONE_SECOND_MS, + }), + ) + } else { + await wcWeb3Wallet.rejectSession({ + id: Number(pendingSession.id), + reason: getSdkError('USER_REJECTED'), + }) + dispatch(removePendingSession()) + } + + onClose() + if (didOpenFromDeepLink) { + await returnToPreviousApp() + setDidOpenFromDeepLink(false) + } + } catch { + setIsConnecting(false) + } finally { + setIsConnecting(false) + } + }) + + const isThreat = finalVerificationStatus === DappVerificationStatus.Threat + const isThreatProps: Partial = isThreat + ? { + cancelButtonText: t('walletConnect.pending.button.reject'), + cancelButtonProps: { + backgroundColor: '$statusCritical', + emphasis: 'primary', + }, + confirmationButtonProps: { + variant: 'default', + emphasis: 'tertiary', + backgroundColor: '$surface3', + }, + } + : {} + + return ( + <> + => onPressSettleConnection(true)} + onReject={(): Promise => onPressSettleConnection(false)} + {...isThreatProps} + > + + + + ) +} + +type PendingConnectionModalContentProps = { + allAccountAddresses: string[] + selectedAccountAddresses: string[] + setSelectedAccountAddresses: (addresses: string[]) => void + pendingSession: WalletConnectPendingSession + verifyStatus: DappVerificationStatus + isViewOnly: boolean + onConfirmWarning: (confirmed: boolean) => void + confirmedWarning: boolean +} + +function PendingConnectionModalContent({ + allAccountAddresses, + selectedAccountAddresses, + setSelectedAccountAddresses, + pendingSession, + verifyStatus, + isViewOnly, + onConfirmWarning, + confirmedWarning, +}: PendingConnectionModalContentProps): JSX.Element { + const { t } = useTranslation() + const { animatedFooterHeight } = useBottomSheetInternal() + + const bottomSpacerStyle = useAnimatedStyle(() => ({ + height: animatedFooterHeight.value, + })) + + const dappInfo: DappConnectionInfo = { + name: pendingSession.dappRequestInfo.name, + url: pendingSession.dappRequestInfo.url, + icon: pendingSession.dappRequestInfo.icon, + } + + return ( + <> + + + + } + onConfirmWarning={onConfirmWarning} + /> + + ) +} diff --git a/apps/mobile/src/components/Requests/ScanSheet/WalletConnectModal.tsx b/apps/mobile/src/components/Requests/ScanSheet/WalletConnectModal.tsx new file mode 100644 index 00000000..434db992 --- /dev/null +++ b/apps/mobile/src/components/Requests/ScanSheet/WalletConnectModal.tsx @@ -0,0 +1,312 @@ +import 'react-native-reanimated' +import { FeatureFlags, useFeatureFlag } from '@universe/gating' +import React, { useCallback, useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Alert } from 'react-native' +import { useDispatch } from 'react-redux' +import { useEagerExternalProfileRootNavigation } from 'src/app/navigation/hooks' +import { BackButtonView } from 'src/components/layout/BackButtonView' +import { QRCodeScanner } from 'src/components/QRCodeScanner/QRCodeScanner' +import { ConnectedDappsList } from 'src/components/Requests/ConnectedDapps/ConnectedDappsList' +import { getSupportedURI, URIType } from 'src/components/Requests/ScanSheet/util' +import { + getFormattedUwuLinkTxnRequest, + isAllowedUwuLinkRequest, + useUwuLinkContractAllowlist, +} from 'src/components/Requests/Uwulink/utils' +import { openDeepLink } from 'src/features/deepLinking/handleDeepLinkSaga' +import { useWalletConnect } from 'src/features/walletConnect/useWalletConnect' +import { pairWithWalletConnectURI } from 'src/features/walletConnect/utils' +import { addRequest } from 'src/features/walletConnect/walletConnectSlice' +import { Flex, Text, TouchableArea, useIsDarkMode } from 'ui/src' +import { QrCode, Scan } from 'ui/src/components/icons' +import { useSporeColorsForTheme } from 'ui/src/hooks/useSporeColors' +import { Modal } from 'uniswap/src/components/modals/Modal' +import { ScannerModalState } from 'uniswap/src/components/ReceiveQRCode/constants' +import { ReceiveQRCode } from 'uniswap/src/components/ReceiveQRCode/ReceiveQRCode' +import { AccountType } from 'uniswap/src/features/accounts/types' +import { ElementName, ModalName } from 'uniswap/src/features/telemetry/constants' +import Trace from 'uniswap/src/features/telemetry/Trace' +import { TestID } from 'uniswap/src/test/fixtures/testIDs' +import { UwULinkRequest } from 'uniswap/src/types/walletConnect' +import { isBetaEnv, isDevEnv } from 'utilities/src/environment/env' +import { logger } from 'utilities/src/logger/logger' +import { useContractManager, useProviderManager } from 'wallet/src/features/wallet/context' +import { useActiveAccount } from 'wallet/src/features/wallet/hooks' + +type Props = { + initialScreenState?: ScannerModalState + onClose: () => void +} + +export function WalletConnectModal({ + initialScreenState = ScannerModalState.ScanQr, + onClose, +}: Props): JSX.Element | null { + const { t } = useTranslation() + const isDarkMode = useIsDarkMode() + + const activeAccount = useActiveAccount() + const { sessions, hasPendingSessionError } = useWalletConnect(activeAccount?.address) + const [currentScreenState, setCurrentScreenState] = useState(initialScreenState) + const [shouldFreezeCamera, setShouldFreezeCamera] = useState(false) + const { preload, navigate } = useEagerExternalProfileRootNavigation() + const dispatch = useDispatch() + const isUwULinkEnabled = useFeatureFlag(FeatureFlags.UwULink) + const isScantasticEnabled = useFeatureFlag(FeatureFlags.Scantastic) + + const uwuLinkContractAllowlist = useUwuLinkContractAllowlist() + + const providerManager = useProviderManager() + const contractManager = useContractManager() + + const isScanningQr = currentScreenState === ScannerModalState.ScanQr + + // We want to always show the QR Code Scanner in "dark mode" + const colors = useSporeColorsForTheme(isScanningQr ? 'dark' : undefined) + + // Update QR scanner states when pending session error alert is shown from WCv2 saga event channel + useEffect(() => { + if (hasPendingSessionError) { + // Cancels the pending connection state in QRCodeScanner + setShouldFreezeCamera(false) + } + }, [hasPendingSessionError]) + + const onScanCode = useCallback( + // oxlint-disable-next-line complexity -- biome-parity: oxlint is stricter here + async (uri: string) => { + // don't scan any QR codes if there is an error popup open or camera is frozen + if (!activeAccount || hasPendingSessionError || shouldFreezeCamera) { + return + } + + const supportedURI = await getSupportedURI(uri, { + isUwULinkEnabled, + isScantasticEnabled, + }) + if (!supportedURI) { + setShouldFreezeCamera(true) + Alert.alert(t('walletConnect.error.unsupported.title'), t('walletConnect.error.unsupported.message'), [ + { + text: t('common.button.tryAgain'), + onPress: (): void => { + setShouldFreezeCamera(false) + }, + }, + ]) + + return + } + + if (supportedURI.type === URIType.Address) { + setShouldFreezeCamera(true) + await preload(supportedURI.value) + await navigate(supportedURI.value, onClose) + return + } + + if (supportedURI.type === URIType.WalletConnectURL) { + setShouldFreezeCamera(true) + Alert.alert(t('walletConnect.error.unsupportedV1.title'), t('walletConnect.error.unsupportedV1.message'), [ + { + text: t('common.button.ok'), + onPress: (): void => { + setShouldFreezeCamera(false) + }, + }, + ]) + return + } + + if (supportedURI.type === URIType.WalletConnectV2URL) { + setShouldFreezeCamera(true) + try { + await pairWithWalletConnectURI(supportedURI.value) + } catch (error) { + logger.error(error, { + tags: { file: 'WalletConnectModal', function: 'onScanCode' }, + extra: { wcUri: supportedURI.value }, + }) + + const title = t('walletConnect.error.general.title') + const message = + isDevEnv() || isBetaEnv() + ? error?.toString?.() || t('walletConnect.error.general.message') + : t('walletConnect.error.general.message') + + Alert.alert(title, message, [ + { + text: t('common.button.ok'), + onPress: (): void => { + setShouldFreezeCamera(false) + }, + }, + ]) + } + } + + if (supportedURI.type === URIType.Scantastic) { + setShouldFreezeCamera(true) + dispatch(openDeepLink({ url: uri, coldStart: false })) + onClose() + return + } + + if (supportedURI.type === URIType.UwULink) { + setShouldFreezeCamera(true) + try { + const parsedUwulinkRequest: UwULinkRequest = JSON.parse(supportedURI.value) + const isSignerAccount = activeAccount.type === AccountType.SignerMnemonic + const isAllowed = isAllowedUwuLinkRequest(parsedUwulinkRequest, uwuLinkContractAllowlist) + if (!isAllowed || !isSignerAccount) { + Alert.alert( + t('walletConnect.error.uwu.title'), + !isAllowed ? t('walletConnect.error.uwu.unsupported') : t('account.wallet.viewOnly.title'), + [ + { + text: t('common.button.ok'), + onPress: (): void => { + setShouldFreezeCamera(false) + }, + }, + ], + ) + return + } + + const wcRequest = await getFormattedUwuLinkTxnRequest({ + request: parsedUwulinkRequest, + activeAccount, + allowList: uwuLinkContractAllowlist, + providerManager, + contractManager, + }) + + dispatch(addRequest(wcRequest.request)) + + onClose() + } catch { + Alert.alert(t('walletConnect.error.uwu.title'), t('walletConnect.error.uwu.scan'), [ + { + text: t('common.button.ok'), + onPress: (): void => { + setShouldFreezeCamera(false) + }, + }, + ]) + } + } + + if (supportedURI.type === URIType.EasterEgg) { + setShouldFreezeCamera(true) + Alert.alert('Have you tried full-sending lately?', 'Highly recommend it', [ + { + text: 'Bye', + onPress: (): void => { + setShouldFreezeCamera(true) + onClose() + }, + }, + ]) + } + }, + [ + activeAccount, + hasPendingSessionError, + shouldFreezeCamera, + isUwULinkEnabled, + isScantasticEnabled, + t, + preload, + navigate, + onClose, + dispatch, + uwuLinkContractAllowlist, + providerManager, + contractManager, + ], + ) + + const onPressBottomToggle = (): void => { + if (isScanningQr) { + setCurrentScreenState(ScannerModalState.WalletQr) + } else { + setCurrentScreenState(ScannerModalState.ScanQr) + } + } + + const onPressShowConnectedDapps = (): void => { + setCurrentScreenState(ScannerModalState.ConnectedDapps) + } + + const onPressShowScanQr = (): void => { + setCurrentScreenState(ScannerModalState.ScanQr) + } + + if (!activeAccount) { + return null + } + + return ( + + <> + {currentScreenState === ScannerModalState.ConnectedDapps && ( + + + + } + sessions={sessions} + /> + )} + {isScanningQr && ( + + + + )} + {currentScreenState === ScannerModalState.WalletQr && ( + + + + )} + + + + {isScanningQr ? ( + + ) : ( + + )} + + {isScanningQr ? t('qrScanner.recipient.action.show') : t('qrScanner.recipient.action.scan')} + + + + + + + ) +} diff --git a/apps/mobile/src/components/Requests/ScanSheet/util.test.ts b/apps/mobile/src/components/Requests/ScanSheet/util.test.ts new file mode 100644 index 00000000..76993011 --- /dev/null +++ b/apps/mobile/src/components/Requests/ScanSheet/util.test.ts @@ -0,0 +1,208 @@ +import * as wcUtils from '@walletconnect/utils' +import { CUSTOM_UNI_QR_CODE_PREFIX, getSupportedURI, URIType } from 'src/components/Requests/ScanSheet/util' +import { + UNISWAP_URL_SCHEME_WALLETCONNECT_AS_PARAM, + UNISWAP_WALLETCONNECT_URL, +} from 'src/features/deepLinking/constants' +import { + wcAsParamInUniwapScheme, + wcInUniwapScheme, + wcUniversalLinkUrl, +} from 'src/features/deepLinking/handleDeepLinkSaga.test' + +const VALID_WC_V1_URI = 'validWcV1Uri@1?relay-protocol=irn&symKey=51e' +const VALID_WC_V2_URI = 'validWcV2Uri@2?relay-protocol=irn&symKey=51e' + +function getWcVersion(uri: string): number { + switch (uri) { + case VALID_WC_V1_URI: + return 1 + case VALID_WC_V2_URI: + return 2 + default: + return -1 + } +} + +jest.spyOn(wcUtils, 'parseUri').mockImplementation((uri) => { + return { + version: getWcVersion(uri), + protocol: '', + topic: '', + symKey: '', + relay: { + protocol: '', + }, + } +}) + +describe('getSupportedURI', () => { + it('should return undefined for empty URIs', async () => { + expect(await getSupportedURI('')).toBeUndefined() + }) + + it('should return undefined for invalid URIs', async () => { + expect(await getSupportedURI('invalid_uri')).toBeUndefined() + }) + + it('should return undefined for hello_uniwallet v1 URI', async () => { + const result = await getSupportedURI(CUSTOM_UNI_QR_CODE_PREFIX + VALID_WC_V1_URI) + expect(result).toBeUndefined() + }) + + it('should return correct values for hello_uniwallet v2 URI', async () => { + const result = await getSupportedURI('hello_uniwallet:' + VALID_WC_V2_URI) + expect(result).toEqual({ type: URIType.WalletConnectV2URL, value: VALID_WC_V2_URI }) + }) + + it('should return undefined for uniswap scheme v1 URI with wc URI as query param', async () => { + const result = await getSupportedURI(wcAsParamInUniwapScheme + VALID_WC_V1_URI) + expect(result).toBeUndefined() + }) + + it('should return correct values for uniswap scheme v2 URI with wc URI as query param', async () => { + const result = await getSupportedURI('uniswap://wc?uri=' + VALID_WC_V2_URI) + expect(result).toEqual({ type: URIType.WalletConnectV2URL, value: VALID_WC_V2_URI }) + }) + + it('should return undefined for uniswap scheme v1 URI', async () => { + const result = await getSupportedURI(wcInUniwapScheme + VALID_WC_V1_URI) + expect(result).toBeUndefined() + }) + + it('should return correct values for uniswap scheme v2 URI', async () => { + const result = await getSupportedURI('uniswap://' + VALID_WC_V2_URI) + expect(result).toEqual({ type: URIType.WalletConnectV2URL, value: VALID_WC_V2_URI }) + }) + + it('should return undefined for uniswap scheme deep link URI', async () => { + const result = await getSupportedURI('uniswap://widget/' + VALID_WC_V2_URI) + expect(result).toBeUndefined() + }) + + it('should return undefined for uniswap app URL v1 URI', async () => { + const result = await getSupportedURI(wcUniversalLinkUrl + VALID_WC_V1_URI) + expect(result).toBeUndefined() + }) + + it('should return correct values for uniswap app URL v2 URI', async () => { + const result = await getSupportedURI('https://uniswap.org/app/wc?uri=' + VALID_WC_V2_URI) + expect(result).toEqual({ type: URIType.WalletConnectV2URL, value: VALID_WC_V2_URI }) + }) + + it('should return correct values for valid v1 URIs', async () => { + const result = await getSupportedURI(VALID_WC_V1_URI) + expect(result).toEqual({ type: URIType.WalletConnectURL, value: VALID_WC_V1_URI }) + }) + + it('should return correct values for valid v2 URIs', async () => { + const result = await getSupportedURI(VALID_WC_V2_URI) + expect(result).toEqual({ type: URIType.WalletConnectV2URL, value: VALID_WC_V2_URI }) + }) + + it('should extract correct address from address URI', async () => { + const validUri = 'address:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' + const result = await getSupportedURI(validUri) + expect(result).toEqual({ + type: URIType.Address, + value: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + }) + }) + + it('should return undefined for invalid address URI', async () => { + expect(await getSupportedURI('address:invalid_address')).toBeUndefined() + }) + + it('should extract correct address from metamask URI', async () => { + const validUri = 'ethereum:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' + const result = await getSupportedURI(validUri) + expect(result).toEqual({ + type: URIType.Address, + value: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + }) + }) + + it('should return undefined for invalid metamask address', async () => { + expect(await getSupportedURI('ethereum:invalid_address')).toBeUndefined() + }) + + describe('URL and HTML encoded URIs', () => { + it('should handle percent-encoded WalletConnect v2 URI', async () => { + // Simulate a URI that has been percent-encoded (& becomes %26) + const encodedUri = encodeURIComponent(VALID_WC_V2_URI) + const result = await getSupportedURI(encodedUri) + expect(result).toEqual({ type: URIType.WalletConnectV2URL, value: VALID_WC_V2_URI }) + }) + + it('should handle HTML entity-encoded ampersands in WalletConnect v2 URI', async () => { + // Simulate a URI with HTML-encoded ampersands (& becomes &) + const htmlEncodedUri = VALID_WC_V2_URI.replace(/&/g, '&') + const result = await getSupportedURI(htmlEncodedUri) + expect(result).toEqual({ type: URIType.WalletConnectV2URL, value: VALID_WC_V2_URI }) + }) + + it('should handle both percent-encoded and HTML entity-encoded URI', async () => { + // First apply HTML encoding, then percent encoding + const htmlEncodedUri = VALID_WC_V2_URI.replace(/&/g, '&') + const doubleEncodedUri = encodeURIComponent(htmlEncodedUri) + const result = await getSupportedURI(doubleEncodedUri) + expect(result).toEqual({ type: URIType.WalletConnectV2URL, value: VALID_WC_V2_URI }) + }) + + it('should handle percent-encoded uniswap scheme URI with query param', async () => { + const fullUri = UNISWAP_URL_SCHEME_WALLETCONNECT_AS_PARAM + VALID_WC_V2_URI + const encodedUri = encodeURIComponent(fullUri) + const result = await getSupportedURI(encodedUri) + expect(result).toEqual({ type: URIType.WalletConnectV2URL, value: VALID_WC_V2_URI }) + }) + + it('should handle HTML entity-encoded uniswap scheme URI with query param', async () => { + const fullUri = UNISWAP_URL_SCHEME_WALLETCONNECT_AS_PARAM + VALID_WC_V2_URI + const htmlEncodedUri = fullUri.replace(/&/g, '&') + const result = await getSupportedURI(htmlEncodedUri) + expect(result).toEqual({ type: URIType.WalletConnectV2URL, value: VALID_WC_V2_URI }) + }) + + it('should handle percent-encoded uniswap app URL', async () => { + const fullUri = UNISWAP_WALLETCONNECT_URL + VALID_WC_V2_URI + const encodedUri = encodeURIComponent(fullUri) + const result = await getSupportedURI(encodedUri) + expect(result).toEqual({ type: URIType.WalletConnectV2URL, value: VALID_WC_V2_URI }) + }) + + it('should handle HTML entity-encoded uniswap app URL', async () => { + const fullUri = UNISWAP_WALLETCONNECT_URL + VALID_WC_V2_URI + const htmlEncodedUri = fullUri.replace(/&/g, '&') + const result = await getSupportedURI(htmlEncodedUri) + expect(result).toEqual({ type: URIType.WalletConnectV2URL, value: VALID_WC_V2_URI }) + }) + + it('should handle percent-encoded hello_uniwallet prefix', async () => { + const fullUri = CUSTOM_UNI_QR_CODE_PREFIX + VALID_WC_V2_URI + const encodedUri = encodeURIComponent(fullUri) + const result = await getSupportedURI(encodedUri) + expect(result).toEqual({ type: URIType.WalletConnectV2URL, value: VALID_WC_V2_URI }) + }) + + it('should handle HTML entity-encoded hello_uniwallet prefix', async () => { + const fullUri = CUSTOM_UNI_QR_CODE_PREFIX + VALID_WC_V2_URI + const htmlEncodedUri = fullUri.replace(/&/g, '&') + const result = await getSupportedURI(htmlEncodedUri) + expect(result).toEqual({ type: URIType.WalletConnectV2URL, value: VALID_WC_V2_URI }) + }) + + it('should handle malformed percent-encoded URI without crashing', async () => { + // Malformed URI with invalid percent encoding (% not followed by valid hex) + const malformedUri = 'uniswap://wc?uri=%E0%A4%A' + // Should not throw an error, even with malformed encoding + await expect(getSupportedURI(malformedUri)).resolves.not.toThrow() + }) + + it('should handle URI with standalone percent sign', async () => { + // URI with a standalone % which is invalid for decodeURIComponent + const malformedUri = 'hello_uniwallet:' + VALID_WC_V2_URI + '%' + // Should not throw an error, even with malformed encoding + await expect(getSupportedURI(malformedUri)).resolves.not.toThrow() + }) + }) +}) diff --git a/apps/mobile/src/components/Requests/ScanSheet/util.ts b/apps/mobile/src/components/Requests/ScanSheet/util.ts new file mode 100644 index 00000000..f8f07334 --- /dev/null +++ b/apps/mobile/src/components/Requests/ScanSheet/util.ts @@ -0,0 +1,218 @@ +import { parseUri } from '@walletconnect/utils' +import { + isUwULinkDirectLink, + isUwuLinkUniswapDeepLink, + parseUwuLinkDataFromDeeplink, + UWULINK_PREFIX, +} from 'src/components/Requests/Uwulink/utils' +import { + UNISWAP_URL_SCHEME, + UNISWAP_URL_SCHEME_SCANTASTIC, + UNISWAP_URL_SCHEME_WALLETCONNECT_AS_PARAM, + UNISWAP_WALLETCONNECT_URL, +} from 'src/features/deepLinking/constants' +import { Platform } from 'uniswap/src/features/platforms/types/Platform' +import { getValidAddress } from 'uniswap/src/utils/addresses' +import { logger } from 'utilities/src/logger/logger' +import { ScantasticParams, ScantasticParamsSchema } from 'wallet/src/features/scantastic/types' + +export enum URIType { + WalletConnectURL = 'walletconnect', + WalletConnectV2URL = 'walletconnect-v2', + Address = 'address', + EasterEgg = 'easter-egg', + Scantastic = 'scantastic', + UwULink = 'uwu-link', +} + +type URIFormat = { + type: URIType + value: string +} + +interface EnabledFeatureFlags { + isUwULinkEnabled: boolean + isScantasticEnabled: boolean +} + +const EASTER_EGG_QR_CODE = 'DO_NOT_SCAN_OR_ELSE_YOU_WILL_GO_TO_MOBILE_TEAM_JAIL' +export const CUSTOM_UNI_QR_CODE_PREFIX = 'hello_uniwallet:' + +export async function getSupportedURI( + uri: string, + enabledFeatureFlags?: EnabledFeatureFlags, +): Promise { + if (!uri) { + return undefined + } + + // Decode URI in case it's encoded (handles both percent encoding and HTML ampersand) + uri = safeDecodeURIComponent(uri).replace(/&/g, '&') + + const maybeAddress = getValidAddress({ + address: uri, + platform: Platform.EVM, + withEVMChecksum: true, + log: false, + }) + if (maybeAddress) { + return { type: URIType.Address, value: maybeAddress } + } + + const maybeMetamaskAddress = getMetamaskAddress(uri) + if (maybeMetamaskAddress) { + return { type: URIType.Address, value: maybeMetamaskAddress } + } + + const maybeScantasticQueryParams = getScantasticQueryParams(uri) + if (enabledFeatureFlags?.isScantasticEnabled && maybeScantasticQueryParams) { + return { type: URIType.Scantastic, value: maybeScantasticQueryParams } + } + + // The check for custom prefixes must be before the parseUri version 2 check because + // parseUri(hello_uniwallet:[valid_wc_uri]) also returns version 2 + const { uri: maybeCustomWcUri, type } = + (await getWcUriWithCustomPrefix(uri, CUSTOM_UNI_QR_CODE_PREFIX)) || + (await getWcUriWithCustomPrefix(uri, UNISWAP_URL_SCHEME_WALLETCONNECT_AS_PARAM)) || + (await getWcUriWithCustomPrefix(uri, UNISWAP_URL_SCHEME)) || + (await getWcUriWithCustomPrefix(uri, UNISWAP_WALLETCONNECT_URL)) || + {} + + if (maybeCustomWcUri && type) { + return { type, value: maybeCustomWcUri } + } + + const wctUriVersion = parseUri(uri).version + if (wctUriVersion === 1) { + return { type: URIType.WalletConnectURL, value: uri } + } + + if (wctUriVersion === 2) { + return { type: URIType.WalletConnectV2URL, value: uri } + } + + if (uri === EASTER_EGG_QR_CODE) { + return { type: URIType.EasterEgg, value: uri } + } + + if (isUwULinkDirectLink(uri)) { + // remove escape strings from the stringified JSON before parsing it + return { type: URIType.UwULink, value: uri.slice(UWULINK_PREFIX.length).replaceAll('\\', '') } + } + + if (isUwuLinkUniswapDeepLink(uri)) { + return { + type: URIType.UwULink, + // remove escape strings from the stringified JSON before parsing it + value: parseUwuLinkDataFromDeeplink(uri), + } + } + + return undefined +} + +async function getWcUriWithCustomPrefix(uri: string, prefix: string): Promise<{ uri: string; type: URIType } | null> { + if (uri.indexOf(prefix) !== 0) { + return null + } + + const maybeWcUri = uri.slice(prefix.length) + + if (parseUri(maybeWcUri).version === 2) { + return { uri: maybeWcUri, type: URIType.WalletConnectV2URL } + } + + return null +} + +// metamask QR code values have the format "ethereum:
" +function getMetamaskAddress(uri: string): Nullable { + const uriParts = uri.split(':') + if (uriParts.length < 2) { + return null + } + + return getValidAddress({ address: uriParts[1], platform: Platform.EVM, withEVMChecksum: true, log: false }) +} + +// format is uniswap://scantastic? +export function getScantasticQueryParams(uri: string): Nullable { + if (!uri.startsWith(UNISWAP_URL_SCHEME_SCANTASTIC)) { + return null + } + + const uriParts = uri.split('://scantastic?') + + if (uriParts.length < 2) { + return null + } + + return uriParts[1] || null +} + +function safeDecodeURIComponent(value: string): string { + try { + return decodeURIComponent(value) + } catch (e) { + logger.error(new Error('Failed to decode URI component'), { + tags: { + file: 'util.ts', + function: 'safeDecodeURIComponent', + }, + extra: { value, error: e }, + }) + // If decoding fails, return the original value + return value + } +} + +const PARAM_PUB_KEY = 'pubKey' +const PARAM_UUID = 'uuid' +const PARAM_VENDOR = 'vendor' +const PARAM_MODEL = 'model' +const PARAM_BROWSER = 'browser' + +/** parses scantastic params for a valid scantastic URI. */ +export function parseScantasticParams(uri: string): ScantasticParams | undefined { + const uriParams = new URLSearchParams(uri) + const paramKeys = [PARAM_PUB_KEY, PARAM_UUID, PARAM_VENDOR, PARAM_MODEL, PARAM_BROWSER] + + // Validate all keys are unique for security + for (const paramKey of paramKeys) { + if (uriParams.getAll(paramKey).length > 1) { + logger.error(new Error('Invalid scantastic params due to duplicate keys'), { + tags: { + file: 'util.ts', + function: 'parseScantasticParams', + }, + extra: { uri }, + }) + return undefined + } + } + + const publicKey = uriParams.get(PARAM_PUB_KEY) + const uuid = uriParams.get(PARAM_UUID) + const vendor = uriParams.get(PARAM_VENDOR) + const model = uriParams.get(PARAM_MODEL) + const browser = uriParams.get(PARAM_BROWSER) + + try { + return ScantasticParamsSchema.parse({ + publicKey: publicKey ? JSON.parse(publicKey) : undefined, + uuid: uuid ? safeDecodeURIComponent(uuid) : undefined, + vendor: vendor ? safeDecodeURIComponent(vendor) : undefined, + model: model ? safeDecodeURIComponent(model) : undefined, + browser: browser ? safeDecodeURIComponent(browser) : undefined, + }) + } catch (e) { + const wrappedError = new Error('Invalid scantastic params', { cause: e }) + logger.error(wrappedError, { + tags: { + file: 'util.ts', + function: 'parseScantasticParams', + }, + }) + return undefined + } +} diff --git a/apps/mobile/src/components/Requests/Uwulink/utils.ts b/apps/mobile/src/components/Requests/Uwulink/utils.ts new file mode 100644 index 00000000..f1d138ac --- /dev/null +++ b/apps/mobile/src/components/Requests/Uwulink/utils.ts @@ -0,0 +1,235 @@ +import { + DynamicConfigs, + UwULinkAllowlist, + UwULinkAllowlistItem, + UwuLinkConfigKey, + useDynamicConfigValue, +} from '@universe/gating' +import { parseEther } from 'ethers/lib/utils' +import { WalletConnectSigningRequest } from 'src/features/walletConnect/walletConnectSlice' +import { AssetType } from 'uniswap/src/entities/assets' +import { SignerMnemonicAccountMeta } from 'uniswap/src/features/accounts/types' +import { EthMethod } from 'uniswap/src/features/dappRequests/types' +import { isUwULinkAllowlistType } from 'uniswap/src/features/gating/typeGuards' +import { + DappRequestType, + EthTransaction, + UwULinkErc20SendRequest, + UwULinkMethod, + UwULinkRequest, + UwULinkRequestInfo, +} from 'uniswap/src/types/walletConnect' +import { areAddressesEqual } from 'uniswap/src/utils/addresses' +import { ContractManager } from 'wallet/src/features/contracts/ContractManager' +import { ProviderManager } from 'wallet/src/features/providers/ProviderManager' +import { getTokenSendRequest } from 'wallet/src/features/transactions/send/hooks/useSendTransactionRequest' +import { SendCurrencyParams } from 'wallet/src/features/transactions/send/types' + +const UWULINK_MAX_TXN_VALUE = '0.001' + +export const UNISWAP_URL_SCHEME_UWU_LINK = 'uniswap://uwulink?' +export const UWULINK_PREFIX = 'uwulink' as const + +// uwulink{...} format for uwulink direct link +export function isUwULinkDirectLink(uri: string): boolean { + // Note the trailing `{` char is required for UwULink. See spec: + // https://github.com/ethereum/EIPs/pull/7253/files#diff-ec1218463dc29af4f2826e540d30abe987ab4c5b7152e1f6c567a0f71938a293R30 + return uri.startsWith(`${UWULINK_PREFIX}{`) +} + +// uniswap://uwulink?uwulink{...} format for uwulink deep link +export function isUwuLinkUniswapDeepLink(uri: string): boolean { + return uri.startsWith(`${UNISWAP_URL_SCHEME_UWU_LINK}${UWULINK_PREFIX}`) +} + +export function parseUwuLinkDataFromDeeplink(uri: string): string { + return uri.slice(UNISWAP_URL_SCHEME_UWU_LINK.length + UWULINK_PREFIX.length).replaceAll('\\', '') +} + +// Gets the UWULink contract allow list from statsig dynamic config. +// We can safely cast as long as the statsig config format matches our `UwuLinkAllowlist` type. +export function useUwuLinkContractAllowlist(): UwULinkAllowlist { + return useDynamicConfigValue({ + config: DynamicConfigs.UwuLink, + key: UwuLinkConfigKey.Allowlist, + defaultValue: { + contracts: [], + tokenRecipients: [], + }, + customTypeGuard: isUwULinkAllowlistType, + }) +} + +function findAllowedTokenRecipientForUwuLink( + request: UwULinkRequest, + allowlist: UwULinkAllowlist, +): UwULinkAllowlistItem | undefined { + if (request.method !== UwULinkMethod.Erc20Send) { + return undefined + } + + const { chainId, recipient } = request + return allowlist.tokenRecipients.find( + (item) => + item.chainId === chainId && + areAddressesEqual({ + addressInput1: { address: item.address, chainId: item.chainId }, + addressInput2: { address: recipient, chainId }, + }), + ) +} +/** + * Util function to check if a UwULinkRequest is valid. + * + * Current testing conditions requires: + * 1. The to address is in the UWULINK_CONTRACT_ALLOWLIST + * 2. The value is less than or equal to UWULINK_MAX_TXN_VALUE + * + * TODO: also check for validity of the entire request object (e.g. all the required fields exist) + * + * @param request parsed UwULinkRequest + * @returns boolean for whether the UwULinkRequest is allowed + */ +export function isAllowedUwuLinkRequest(request: UwULinkRequest, allowlist: UwULinkAllowlist): boolean { + // token sends + if (request.method === UwULinkMethod.Erc20Send) { + return Boolean(findAllowedTokenRecipientForUwuLink(request, allowlist)) + } + + if (request.method === EthMethod.PersonalSign) { + return true + } + + // generic transactions + const { to, value } = request.value + const belowMaximumValue = !value || parseFloat(value) <= parseEther(UWULINK_MAX_TXN_VALUE).toNumber() + const isAllowedContractAddress = + to && + allowlist.contracts.some((item) => + areAddressesEqual({ + addressInput1: { address: item.address, chainId: item.chainId }, + addressInput2: { address: to, chainId: request.chainId }, + }), + ) + + if (!belowMaximumValue || !isAllowedContractAddress) { + return false + } + + return true +} + +type HandleUwuLinkRequestParams = { + request: UwULinkRequest + activeAccount: SignerMnemonicAccountMeta + allowList: UwULinkAllowlist + providerManager: ProviderManager + contractManager: ContractManager +} + +export async function getFormattedUwuLinkTxnRequest({ + request, + activeAccount, + allowList, + providerManager, + contractManager, +}: HandleUwuLinkRequestParams): Promise<{ request: WalletConnectSigningRequest; account: string }> { + const newRequest: { + sessionId: string + internalId: string + account: string + dappRequestInfo: UwULinkRequestInfo + chainId: number + } = { + sessionId: UWULINK_PREFIX, // session/internalId is WalletConnect specific, but not needed here + internalId: UWULINK_PREFIX, + account: activeAccount.address, + dappRequestInfo: { + name: '', + url: '', + icon: null, + ...request.dapp, + requestType: DappRequestType.UwULink, + chain_id: request.chainId, + webhook: request.webhook, + }, + chainId: request.chainId, + } + + if (request.method === EthMethod.PersonalSign) { + return { + account: activeAccount.address, + request: { + ...newRequest, + type: EthMethod.PersonalSign, + message: request.message, + // rawMessage should be the hex version of `message`, but our wallet will only use + // `message` if it exists. so this is mostly to appease Typescript + rawMessage: request.message, + }, + } + } else if (request.method === UwULinkMethod.Erc20Send) { + const preparedTransaction = await toTokenTransferRequest({ + request, + account: activeAccount, + providerManager, + contractManager, + }) + const tokenRecipient = findAllowedTokenRecipientForUwuLink(request, allowList) + return { + account: activeAccount.address, + request: { + ...newRequest, + type: UwULinkMethod.Erc20Send, + recipient: { + address: request.recipient, + name: tokenRecipient?.name ?? '', + logo: tokenRecipient?.logo, + }, + amount: request.amount, + tokenAddress: request.tokenAddress, + isStablecoin: request.isStablecoin, + transaction: { + from: activeAccount.address, + ...preparedTransaction, + }, + }, + } + } + + return { + account: activeAccount.address, + request: { + ...newRequest, + type: EthMethod.EthSendTransaction, + transaction: { + from: activeAccount.address, + ...request.value, + }, + }, + } +} + +async function toTokenTransferRequest({ + request, + account, + providerManager, + contractManager, +}: { + request: UwULinkErc20SendRequest + account: SignerMnemonicAccountMeta + providerManager: ProviderManager + contractManager: ContractManager +}): Promise { + const provider = providerManager.getProvider(request.chainId) + const params: SendCurrencyParams = { + type: AssetType.Currency, + account, + chainId: request.chainId, + toAddress: request.recipient, + tokenAddress: request.tokenAddress, + amountInWei: request.amount.toString(), + } + const transaction = await getTokenSendRequest({ params, provider, contractManager }) + return transaction as EthTransaction +} diff --git a/apps/mobile/src/components/Requests/WalletConnectModals.tsx b/apps/mobile/src/components/Requests/WalletConnectModals.tsx new file mode 100644 index 00000000..8c2b1711 --- /dev/null +++ b/apps/mobile/src/components/Requests/WalletConnectModals.tsx @@ -0,0 +1,155 @@ +import React, { useEffect } from 'react' +import { useTranslation } from 'react-i18next' +import { useDispatch } from 'react-redux' +import { WalletConnectRequestModal } from 'src/components/Requests/RequestModal/WalletConnectRequestModal' +import { PendingConnectionModal } from 'src/components/Requests/ScanSheet/PendingConnectionModal' +import { WalletConnectModal } from 'src/components/Requests/ScanSheet/WalletConnectModal' +import { closeModal } from 'src/features/modals/modalSlice' +import { useWalletConnect } from 'src/features/walletConnect/useWalletConnect' +import { + removePendingSession, + removeRequest, + setDidOpenFromDeepLink, + WalletConnectSigningRequest, +} from 'src/features/walletConnect/walletConnectSlice' +import { useAppStateTrigger } from 'src/utils/useAppStateTrigger' +import { Flex } from 'ui/src' +import { Eye } from 'ui/src/components/icons' +import { WarningSeverity } from 'uniswap/src/components/modals/WarningModal/types' +import { WarningModal } from 'uniswap/src/components/modals/WarningModal/WarningModal' +import { Platform } from 'uniswap/src/features/platforms/types/Platform' +import { ModalName } from 'uniswap/src/features/telemetry/constants' +import { areAddressesEqual } from 'uniswap/src/utils/addresses' +import { AccountDetails } from 'wallet/src/components/accounts/AccountDetails' +import { ErrorBoundary } from 'wallet/src/components/ErrorBoundary/ErrorBoundary' +import { useActiveAccount, useActiveAccountAddressWithThrow, useSignerAccounts } from 'wallet/src/features/wallet/hooks' + +const WalletConnectModalName = { + Scan: ModalName.WalletConnectScan, + PendingSession: 'wallet-connect-pending-session', + Request: 'wallet-connect-request-modal', +} as const + +export function WalletConnectModals(): JSX.Element { + const activeAccount = useActiveAccount() + const dispatch = useDispatch() + + const { pendingRequests, modalState, pendingSession } = useWalletConnect(activeAccount?.address) + + /* + * Reset didOpenFromDeepLink state when app is backgrounded, since we only want + * to call `returnToPreviousApp` when the app was deep linked to from another app. + * Handles case where user opens app via WalletConnect deep link, backgrounds app, then + * opens Uniswap app via Spotlight search – we don't want `returnToPreviousApp` to return + * to Spotlight search. + * */ + useAppStateTrigger({ + from: 'active', + to: 'inactive', + callback: () => { + dispatch(setDidOpenFromDeepLink(undefined)) + }, + }) + + const currRequest = pendingRequests[0] ?? null + + const onCloseWCModal = (): void => { + dispatch(closeModal({ name: ModalName.WalletConnectScan })) + } + + // TODO: Move returnToPreviousApp() call to onClose but ensure it is not called twice + const onClosePendingConnection = (): void => { + dispatch(removePendingSession()) + } + + // When WalletConnectModal is open and a WC QR code is scanned to add a pendingSession, + // dismiss the scan modal in favor of showing PendingConnectionModal + useEffect(() => { + if (modalState.isOpen && (pendingSession || currRequest)) { + dispatch(closeModal({ name: ModalName.WalletConnectScan })) + } + }, [modalState.isOpen, pendingSession, currRequest, dispatch]) + + return ( + <> + {modalState.isOpen && ( + dispatch(closeModal({ name: ModalName.WalletConnectScan }))} + > + + + )} + {pendingSession ? ( + + + + ) : null} + {currRequest ? ( + + dispatch(removeRequest({ requestInternalId: currRequest.internalId, account: currRequest.account })) + } + > + + + ) : null} + + ) +} + +type RequestModalProps = { + currRequest: WalletConnectSigningRequest +} + +function RequestModal({ currRequest }: RequestModalProps): JSX.Element { + const signerAccounts = useSignerAccounts() + const activeAccountAddress = useActiveAccountAddressWithThrow() + const { t } = useTranslation() + const dispatch = useDispatch() + + // TODO: Move returnToPreviousApp() call to onClose but ensure it is not called twice + const onClose = (): void => { + dispatch(removeRequest({ requestInternalId: currRequest.internalId, account: activeAccountAddress })) + } + + const isRequestFromSignerAccount = signerAccounts.some((account) => + // TODO(WALL-7065): Update to support solana + areAddressesEqual({ + addressInput1: { address: account.address, platform: Platform.EVM }, + addressInput2: { address: currRequest.account, platform: Platform.EVM }, + }), + ) + + if (!isRequestFromSignerAccount) { + return ( + } + isOpen={!isRequestFromSignerAccount} + modalName={ModalName.WCViewOnlyWarning} + severity={WarningSeverity.None} + title={t('walletConnect.request.warning.title')} + onReject={onClose} + onClose={onClose} + > + + + + + ) + } + + return +} diff --git a/apps/mobile/src/components/RestoreWalletModal/PrivateKeySpeedBumpModal.test.tsx b/apps/mobile/src/components/RestoreWalletModal/PrivateKeySpeedBumpModal.test.tsx new file mode 100644 index 00000000..ab2b0cab --- /dev/null +++ b/apps/mobile/src/components/RestoreWalletModal/PrivateKeySpeedBumpModal.test.tsx @@ -0,0 +1,41 @@ +import React from 'react' +import { useReactNavigationModal } from 'src/components/modals/useReactNavigationModal' +import { PrivateKeySpeedBumpModal } from 'src/components/RestoreWalletModal/PrivateKeySpeedBumpModal' +import { fireEvent, render } from 'src/test/test-utils' +import { TestID } from 'uniswap/src/test/fixtures/testIDs' +import { MobileScreens } from 'uniswap/src/types/screens/mobile' + +jest.mock('src/components/modals/useReactNavigationModal', () => ({ + useReactNavigationModal: jest.fn(), +})) + +describe('PrivateKeySpeedBumpModal', () => { + const mockPreventCloseRef = { current: false } + const mockNavigation = { navigate: jest.fn() } + const mockOnClose = jest.fn() + + beforeEach(() => { + jest.clearAllMocks() + ;(useReactNavigationModal as jest.Mock).mockReturnValue({ + onClose: mockOnClose, + preventCloseRef: mockPreventCloseRef, + }) + }) + + it('renders correctly', () => { + // @ts-expect-error Mocking navigation object since it's not critical to this test + const { toJSON } = render() + expect(toJSON()).toMatchSnapshot() + }) + + it('navigates to ViewPrivateKeys screen when Continue button is pressed', () => { + // @ts-expect-error Mocking navigation object since it's not critical to this test + const screen = render() + + const continueButton = screen.getByTestId(TestID.Continue) + fireEvent.press(continueButton) + + expect(mockOnClose).toHaveBeenCalled() + expect(mockNavigation.navigate).toHaveBeenCalledWith(MobileScreens.ViewPrivateKeys) + }) +}) diff --git a/apps/mobile/src/components/RestoreWalletModal/PrivateKeySpeedBumpModal.tsx b/apps/mobile/src/components/RestoreWalletModal/PrivateKeySpeedBumpModal.tsx new file mode 100644 index 00000000..5aa6efc6 --- /dev/null +++ b/apps/mobile/src/components/RestoreWalletModal/PrivateKeySpeedBumpModal.tsx @@ -0,0 +1,86 @@ +import React from 'react' +import { useTranslation } from 'react-i18next' +import { AppStackScreenProp } from 'src/app/navigation/types' +import { useReactNavigationModal } from 'src/components/modals/useReactNavigationModal' +import { Button, Flex, IconButton, InlineCard, Text, useSporeColors } from 'ui/src' +import { AlertTriangleFilled, Key } from 'ui/src/components/icons' +import { Modal } from 'uniswap/src/components/modals/Modal' +import { uniswapUrls } from 'uniswap/src/constants/urls' +import { ModalName } from 'uniswap/src/features/telemetry/constants' +import { TestID } from 'uniswap/src/test/fixtures/testIDs' +import { MobileScreens } from 'uniswap/src/types/screens/mobile' +import { openUri } from 'uniswap/src/utils/linking' +import { SPACE_STRING } from 'utilities/src/primitives/string' + +/** + * This modal is used as an informational speedbump before the user + * is sent to the screen to view their private keys. + */ +export function PrivateKeySpeedBumpModal({ + navigation, +}: AppStackScreenProp): JSX.Element | null { + const colors = useSporeColors() + const { onClose } = useReactNavigationModal() + + const onContinue = (): void => { + onClose() + navigation.navigate(MobileScreens.ViewPrivateKeys) + } + + return ( + + + + ) +} + +const PrivateKeySpeedBumpModalContent = ({ + onClose, + onContinue, +}: { + onClose: () => void + onContinue: () => void +}): JSX.Element => { + const { t } = useTranslation() + + return ( + + + } onPress={onClose} /> + + + {t('privateKeys.export.modal.title')} + + + {t('privateKeys.export.modal.subtitle')} + + {SPACE_STRING + t('common.button.learn')} + + + + + {t('privateKeys.export.modal.warning')} + + } + iconColor="$neutral2" + /> + + + + + + ) +} + +const openLearnMore = async (): Promise => { + await openUri({ uri: uniswapUrls.helpArticleUrls.whatIsPrivateKey }) +} diff --git a/apps/mobile/src/components/RestoreWalletModal/RestoreWalletModal.tsx b/apps/mobile/src/components/RestoreWalletModal/RestoreWalletModal.tsx new file mode 100644 index 00000000..1c73c5de --- /dev/null +++ b/apps/mobile/src/components/RestoreWalletModal/RestoreWalletModal.tsx @@ -0,0 +1,170 @@ +import React, { useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { useDispatch } from 'react-redux' +import { navigate } from 'src/app/navigation/rootNavigation' +import { AppStackScreenProp } from 'src/app/navigation/types' +import { useReactNavigationModal } from 'src/components/modals/useReactNavigationModal' +import { WalletRestoreType } from 'src/components/RestoreWalletModal/RestoreWalletModalState' +import { closeAllModals } from 'src/features/modals/modalSlice' +import { Button, Flex, useSporeColors } from 'ui/src' +import { ArrowDownCircleFilledWithBorder, WalletFilled } from 'ui/src/components/icons' +import { spacing } from 'ui/src/theme' +import { GenericHeader } from 'uniswap/src/components/misc/GenericHeader' +import { Modal } from 'uniswap/src/components/modals/Modal' +import { ElementName, ModalName } from 'uniswap/src/features/telemetry/constants' +import Trace from 'uniswap/src/features/telemetry/Trace' +import { TestID } from 'uniswap/src/test/fixtures/testIDs' +import { ImportType, OnboardingEntryPoint } from 'uniswap/src/types/onboarding' +import { MobileScreens, OnboardingScreens } from 'uniswap/src/types/screens/mobile' + +const CONTAINER_HEIGHT = 160 +const OUTER_RING_SIZE = 260 +const INNER_RING_SIZE = 175 +const SHADOW_RADIUS = 20 +const SHADOW_OPACITY = 0.3 +const SHADOW_OFFSET = { width: 0, height: 0 } as const +const ICON_OFFSET = -spacing.spacing8 + +/** + * This modal is used to prompt the user to restore their wallet depending on the type of + * restoration needed. + */ +export function RestoreWalletModal({ route }: AppStackScreenProp): JSX.Element | null { + const { t } = useTranslation() + const colors = useSporeColors() + const dispatch = useDispatch() + const { onClose } = useReactNavigationModal() + + const restoreType = route.params.restoreType + const { title, description, isDismissible, modalName } = useMemo(() => { + switch (restoreType) { + case WalletRestoreType.SeedPhrase: + return { + title: t('account.wallet.restore.seed_phrase.title'), + description: t('account.wallet.restore.seed_phrase.description'), + isDismissible: true, + modalName: ModalName.RestoreWalletSeedPhrase, + } + case WalletRestoreType.NewDevice: + return { + title: t('account.wallet.restore.new_device.title'), + description: t('account.wallet.restore.new_device.description'), + isDismissible: false, + modalName: ModalName.RestoreWallet, + } + default: + return {} + } + }, [restoreType, t]) + + const onRestore = (): void => { + onClose() + dispatch(closeAllModals()) // still need this until all modals are migrated to react-navigation + + switch (restoreType) { + case WalletRestoreType.SeedPhrase: { + navigate(MobileScreens.OnboardingStack, { + screen: OnboardingScreens.RestoreMethod, + params: { + entryPoint: OnboardingEntryPoint.Sidebar, + importType: ImportType.RestoreMnemonic, + restoreType, + }, + }) + break + } + case WalletRestoreType.NewDevice: { + navigate(MobileScreens.OnboardingStack, { + screen: OnboardingScreens.RestoreCloudBackupLoading, + params: { + entryPoint: OnboardingEntryPoint.Sidebar, + importType: ImportType.RestoreMnemonic, + restoreType, + }, + }) + break + } + } + } + + return ( + + + + + + + + + + + + + + + + + + + + + + {isDismissible && ( + + + + + + )} + + + + ) +} + +function BackgroundRing({ size }: { size: number }): JSX.Element { + return ( + + ) +} diff --git a/apps/mobile/src/components/RestoreWalletModal/RestoreWalletModalState.tsx b/apps/mobile/src/components/RestoreWalletModal/RestoreWalletModalState.tsx new file mode 100644 index 00000000..06b56624 --- /dev/null +++ b/apps/mobile/src/components/RestoreWalletModal/RestoreWalletModalState.tsx @@ -0,0 +1,22 @@ +/** + * If the wallet needs to be restored such as migrating to a new device, + * this enum describes the type of restore that is needed. + */ +export enum WalletRestoreType { + None = 'none', + /** + * The wallet needs to be restored because it is a new device. This case is + * when the local app state has been restored but the native private keys and + * seed phrase are not present. + */ + NewDevice = 'device', + /** + * The wallet needs to be restored because the seed phrase is not present. This case + * is when the local app state is using a wallet but it's seed phrase is missing. + */ + SeedPhrase = 'seedPhrase', +} + +export interface RestoreWalletModalState { + restoreType: WalletRestoreType +} diff --git a/apps/mobile/src/components/RestoreWalletModal/__snapshots__/PrivateKeySpeedBumpModal.test.tsx.snap b/apps/mobile/src/components/RestoreWalletModal/__snapshots__/PrivateKeySpeedBumpModal.test.tsx.snap new file mode 100644 index 00000000..a7a02717 --- /dev/null +++ b/apps/mobile/src/components/RestoreWalletModal/__snapshots__/PrivateKeySpeedBumpModal.test.tsx.snap @@ -0,0 +1,566 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`PrivateKeySpeedBumpModal renders correctly 1`] = ` + + + + + + + + + + + + + + Export your private key + + + It looks like you’re not able to fully restore your wallet. To make sure you never lose access to your funds, copy your private key. + + Learn more + + + + + + + + + + + + + + + If you lose your phone or delete the app, you’ll need to import this key into a different wallet. + + + + + + + + + Continue + + + + + + +`; diff --git a/apps/mobile/src/components/Settings/BiometricAuthWarningModal.tsx b/apps/mobile/src/components/Settings/BiometricAuthWarningModal.tsx new file mode 100644 index 00000000..4cea2cee --- /dev/null +++ b/apps/mobile/src/components/Settings/BiometricAuthWarningModal.tsx @@ -0,0 +1,45 @@ +import React from 'react' +import { useTranslation } from 'react-i18next' +import { useBiometricName } from 'src/features/biometricsSettings/hooks' +import { WarningSeverity } from 'uniswap/src/components/modals/WarningModal/types' +import { WarningModal, WarningModalProps } from 'uniswap/src/components/modals/WarningModal/WarningModal' +import { ModalName } from 'uniswap/src/features/telemetry/constants' +import { isAndroid } from 'utilities/src/platform' + +type Props = { + isOpen: boolean + isTouchIdDevice: boolean + onConfirm: WarningModalProps['onAcknowledge'] + onClose: WarningModalProps['onClose'] + rejectText?: WarningModalProps['rejectText'] + acknowledgeText?: WarningModalProps['acknowledgeText'] +} + +export function BiometricAuthWarningModal({ + isOpen, + isTouchIdDevice, + onConfirm, + onClose, + rejectText, + acknowledgeText, +}: Props): JSX.Element { + const { t } = useTranslation() + const biometricsMethod = useBiometricName(isTouchIdDevice) + return ( + + ) +} diff --git a/apps/mobile/src/components/Settings/ConnectionsDappModal/ConnectionsDappListModal.tsx b/apps/mobile/src/components/Settings/ConnectionsDappModal/ConnectionsDappListModal.tsx new file mode 100644 index 00000000..4d21ff7d --- /dev/null +++ b/apps/mobile/src/components/Settings/ConnectionsDappModal/ConnectionsDappListModal.tsx @@ -0,0 +1,26 @@ +import { default as React } from 'react' +import { AppStackScreenProp } from 'src/app/navigation/types' +import { BackButton } from 'src/components/buttons/BackButton' +import { useReactNavigationModal } from 'src/components/modals/useReactNavigationModal' +import { ConnectedDappsList } from 'src/components/Requests/ConnectedDapps/ConnectedDappsList' +import { useWalletConnect } from 'src/features/walletConnect/useWalletConnect' +import { Modal } from 'uniswap/src/components/modals/Modal' +import { ModalName } from 'uniswap/src/features/telemetry/constants' + +export function ConnectionsDappListModal({ + route, +}: AppStackScreenProp): JSX.Element { + const { onClose } = useReactNavigationModal() + const { address } = route.params + const { sessions } = useWalletConnect(address) + + return ( + + } + sessions={sessions} + selectedAddress={address} + /> + + ) +} diff --git a/apps/mobile/src/components/Settings/ConnectionsDappModal/ConnectionsDappsListModalState.tsx b/apps/mobile/src/components/Settings/ConnectionsDappModal/ConnectionsDappsListModalState.tsx new file mode 100644 index 00000000..afbaa12a --- /dev/null +++ b/apps/mobile/src/components/Settings/ConnectionsDappModal/ConnectionsDappsListModalState.tsx @@ -0,0 +1,3 @@ +export interface ConnectionsDappsListModalState { + address?: Address +} diff --git a/apps/mobile/src/components/Settings/EditWalletModal/EditLabelSettingsModal.tsx b/apps/mobile/src/components/Settings/EditWalletModal/EditLabelSettingsModal.tsx new file mode 100644 index 00000000..055610ed --- /dev/null +++ b/apps/mobile/src/components/Settings/EditWalletModal/EditLabelSettingsModal.tsx @@ -0,0 +1,139 @@ +import { default as React, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { TextInput as NativeTextInput, StyleSheet } from 'react-native' +import { Gesture, GestureDetector } from 'react-native-gesture-handler' +import { KeyboardStickyView } from 'react-native-keyboard-controller' +import { useDispatch } from 'react-redux' +import { AppStackScreenProp } from 'src/app/navigation/types' +import { BackHeader } from 'src/components/layout/BackHeader' +import { useReactNavigationModal } from 'src/components/modals/useReactNavigationModal' +import { navigateBackFromEditingWallet } from 'src/components/Settings/EditWalletModal/EditWalletNavigation' +import { Button, Flex, Text } from 'ui/src' +import { fonts } from 'ui/src/theme' +import { TextInput } from 'uniswap/src/components/input/TextInput' +import { Modal } from 'uniswap/src/components/modals/Modal' +import { DisplayNameType } from 'uniswap/src/features/accounts/types' +import { ModalName } from 'uniswap/src/features/telemetry/constants' +import { MobileScreens } from 'uniswap/src/types/screens/mobile' +import { sanitizeAddressText } from 'uniswap/src/utils/addresses' +import { shortenAddress } from 'utilities/src/addresses' +import { dismissNativeKeyboard } from 'utilities/src/device/keyboard/dismissNativeKeyboard' +import { NICKNAME_MAX_LENGTH } from 'wallet/src/constants/accounts' +import { EditAccountAction, editAccountActions } from 'wallet/src/features/wallet/accounts/editAccountSaga' +import { useDisplayName } from 'wallet/src/features/wallet/hooks' + +export function EditLabelSettingsModal({ + route, +}: AppStackScreenProp): JSX.Element { + const dispatch = useDispatch() + const { t } = useTranslation() + const { onClose } = useReactNavigationModal() + + const { address, accessPoint } = route.params + const entryPoint = accessPoint ?? MobileScreens.SettingsWallet + + const displayName = useDisplayName(address) + const [nickname, setNickname] = useState(displayName?.type === DisplayNameType.Local ? displayName.name : '') + const [isUpdatingWalletLabel, setIsUpdatingWalletLabel] = useState(false) + + const accountNameIsEditable = + displayName?.type === DisplayNameType.Local || displayName?.type === DisplayNameType.Address + + const inputRef = useRef(null) + + const onFinishEditing = (): void => { + dismissNativeKeyboard() + + setNickname(nickname.trim()) + } + + const onPressSaveChanges = (): void => { + setIsUpdatingWalletLabel(true) + onFinishEditing() + dispatch( + editAccountActions.trigger({ + type: EditAccountAction.Rename, + address, + newName: nickname.trim(), + }), + ) + onPressBack() + } + + const onPressBack = (): void => { + onClose() + if (!isUpdatingWalletLabel) { + navigateBackFromEditingWallet(entryPoint, address) + } + } + + return ( + + {/* This GestureDetector is used to consume all pan gestures and prevent + keyboard from flickering (see https://github.com/Uniswap/universe/pull/8242) */} + + + + {t('settings.setting.wallet.action.editLabel')} + + + + + + + {accountNameIsEditable && ( + + {t('settings.setting.wallet.editLabel.description')} + + )} + + + + + + + + + + + ) +} + +const styles = StyleSheet.create({ + base: { + flex: 1, + justifyContent: 'flex-end', + }, +}) diff --git a/apps/mobile/src/components/Settings/EditWalletModal/EditProfileSettingsModal.tsx b/apps/mobile/src/components/Settings/EditWalletModal/EditProfileSettingsModal.tsx new file mode 100644 index 00000000..9a7095c0 --- /dev/null +++ b/apps/mobile/src/components/Settings/EditWalletModal/EditProfileSettingsModal.tsx @@ -0,0 +1,140 @@ +import { default as React, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { StyleSheet } from 'react-native' +import ContextMenu from 'react-native-context-menu-view' +import { KeyboardStickyView } from 'react-native-keyboard-controller' +import { navigate } from 'src/app/navigation/rootNavigation' +import { AppStackScreenProp } from 'src/app/navigation/types' +import { BackHeader } from 'src/components/layout/BackHeader' +import { useReactNavigationModal } from 'src/components/modals/useReactNavigationModal' +import { navigateBackFromEditingWallet } from 'src/components/Settings/EditWalletModal/EditWalletNavigation' +import { Flex, Text } from 'ui/src' +import { Ellipsis } from 'ui/src/components/icons' +import { Modal } from 'uniswap/src/components/modals/Modal' +import { useBottomSheetSafeKeyboard } from 'uniswap/src/components/modals/useBottomSheetSafeKeyboard' +import { useUnitagsAddressQuery } from 'uniswap/src/data/apiClients/unitagsApi/useUnitagsAddressQuery' +import { ModalName } from 'uniswap/src/features/telemetry/constants' +import { MobileScreens } from 'uniswap/src/types/screens/mobile' +import { dismissNativeKeyboard } from 'utilities/src/device/keyboard/dismissNativeKeyboard' +import { ChangeUnitagModal } from 'wallet/src/features/unitags/ChangeUnitagModal' +import { DeleteUnitagModal } from 'wallet/src/features/unitags/DeleteUnitagModal' +import { EditUnitagProfileContent } from 'wallet/src/features/unitags/EditUnitagProfileContent' + +export function EditProfileSettingsModal({ + route, +}: AppStackScreenProp): JSX.Element { + const { onClose } = useReactNavigationModal() + const { address, accessPoint } = route.params + const entryPoint = accessPoint ?? MobileScreens.SettingsWallet + + const { data: retrievedUnitag } = useUnitagsAddressQuery({ + params: address ? { address } : undefined, + }) + const unitag = retrievedUnitag?.username + + const { t } = useTranslation() + const { keyboardHeight } = useBottomSheetSafeKeyboard() + + const [showDeleteUnitagModal, setShowDeleteUnitagModal] = useState(false) + const [showChangeUnitagModal, setShowChangeUnitagModal] = useState(false) + const [isUpdatingWalletProfile, setIsUpdatingWalletProfile] = useState(false) + + const onButtonClick = (): void => { + setIsUpdatingWalletProfile(true) + } + + const onNavigate = (): void => { + navigate(MobileScreens.Home) + } + + const onBack = (): void => { + onClose() + } + + const onCloseChangeModal = (): void => { + setShowChangeUnitagModal(false) + } + + const onCloseDeleteModal = (): void => { + setShowDeleteUnitagModal(false) + } + + const menuActions = useMemo(() => { + return [ + { title: t('unitags.profile.action.edit'), systemIcon: 'pencil' }, + { title: t('unitags.profile.action.delete'), systemIcon: 'trash', destructive: true }, + ] + }, [t]) + const onPressBack = (): void => { + onClose() + + if (!isUpdatingWalletProfile) { + navigateBackFromEditingWallet(entryPoint, address) + } + } + + return ( + + { + dismissNativeKeyboard() + // Emitted index based on order of menu action array + // Edit username + if (e.nativeEvent.index === 0) { + setShowChangeUnitagModal(true) + } + // Delete username + if (e.nativeEvent.index === 1) { + setShowDeleteUnitagModal(true) + } + }} + > + + + + + } + p="$spacing16" + onPressBack={onPressBack} + > + {t('settings.setting.wallet.action.editProfile')} + + + {unitag && ( + + )} + + {showDeleteUnitagModal && unitag && ( + + )} + {showChangeUnitagModal && unitag && ( + + )} + + ) +} + +const styles = StyleSheet.create({ + base: { + flex: 1, + justifyContent: 'flex-end', + }, +}) diff --git a/apps/mobile/src/components/Settings/EditWalletModal/EditWalletNavigation.ts b/apps/mobile/src/components/Settings/EditWalletModal/EditWalletNavigation.ts new file mode 100644 index 00000000..082893ee --- /dev/null +++ b/apps/mobile/src/components/Settings/EditWalletModal/EditWalletNavigation.ts @@ -0,0 +1,16 @@ +import { navigate } from 'src/app/navigation/rootNavigation' +import { ModalName } from 'uniswap/src/features/telemetry/constants' +import { MobileScreens, UnitagScreens } from 'uniswap/src/types/screens/mobile' + +export const navigateBackFromEditingWallet = ( + entryPoint: UnitagScreens.UnitagConfirmation | MobileScreens.SettingsWallet, + address: string, +): void => { + if (entryPoint === UnitagScreens.UnitagConfirmation) { + navigate(ModalName.AccountSwitcher) + } else { + navigate(ModalName.ManageWalletsModal, { + address, + }) + } +} diff --git a/apps/mobile/src/components/Settings/EditWalletModal/EditWalletSettingsModalState.tsx b/apps/mobile/src/components/Settings/EditWalletModal/EditWalletSettingsModalState.tsx new file mode 100644 index 00000000..a07b8299 --- /dev/null +++ b/apps/mobile/src/components/Settings/EditWalletModal/EditWalletSettingsModalState.tsx @@ -0,0 +1,6 @@ +import { MobileScreens, UnitagScreens } from 'uniswap/src/types/screens/mobile' + +export interface EditWalletSettingsModalState { + address: Address + accessPoint?: UnitagScreens.UnitagConfirmation | MobileScreens.SettingsWallet +} diff --git a/apps/mobile/src/components/Settings/FooterSettings.tsx b/apps/mobile/src/components/Settings/FooterSettings.tsx new file mode 100644 index 00000000..d972af8e --- /dev/null +++ b/apps/mobile/src/components/Settings/FooterSettings.tsx @@ -0,0 +1,66 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { StyleSheet } from 'react-native' +import { FadeInDown, FadeOutUp } from 'react-native-reanimated' +import { getFullAppVersion } from 'src/utils/version' +import { Flex, Image, Text, useIsDarkMode } from 'ui/src' +import { AVATARS_DARK, AVATARS_LIGHT } from 'ui/src/assets' +import { AnimatedFlex } from 'ui/src/components/layout/AnimatedFlex' +import { ONE_SECOND_MS } from 'utilities/src/time/time' +import { useTimeout } from 'utilities/src/time/timing' + +const SIGNATURE_VISIBLE_DURATION = ONE_SECOND_MS * 10 + +export function FooterSettings(): JSX.Element { + const { t } = useTranslation() + const [showSignature, setShowSignature] = useState(false) + const isDarkMode = useIsDarkMode() + + // Fade out signature after duration + useTimeout( + showSignature + ? (): void => { + setShowSignature(false) + } + : (): void => undefined, + SIGNATURE_VISIBLE_DURATION, + ) + + return ( + + {showSignature ? ( + + + + {t('settings.footer')} + + + {isDarkMode ? ( + + ) : ( + + )} + + ) : null} + { + setShowSignature(true) + }} + > + {t('settings.version', { appVersion: getFullAppVersion({ includeBuildNumber: true }) })} + + + ) +} + +const styles = StyleSheet.create({ + responsiveImage: { + aspectRatio: 135 / 76, + height: undefined, + width: '100%', + }, +}) diff --git a/apps/mobile/src/components/Settings/ForceReduxDataLossRow.tsx b/apps/mobile/src/components/Settings/ForceReduxDataLossRow.tsx new file mode 100644 index 00000000..fba494a7 --- /dev/null +++ b/apps/mobile/src/components/Settings/ForceReduxDataLossRow.tsx @@ -0,0 +1,64 @@ +import { useState } from 'react' +import { DevSettings } from 'react-native' +import { MMKV } from 'react-native-mmkv' +import { Flex, type IconProps, Text, TouchableArea } from 'ui/src' +import { RotatableChevron, UniswapLogo } from 'ui/src/components/icons' +import { WarningSeverity } from 'uniswap/src/components/modals/WarningModal/types' +import { WarningModal } from 'uniswap/src/components/modals/WarningModal/WarningModal' +import { ModalName } from 'uniswap/src/features/telemetry/constants' +import { logger } from 'utilities/src/logger/logger' + +/** + * Dev tool to simulate complete Redux data loss while keeping Keyring intact. + * This helps test the on-device recovery flow. + */ +export function ForceReduxDataLossRow({ iconProps }: { iconProps: IconProps }): JSX.Element { + const [showConfirmModal, setShowConfirmModal] = useState(false) + + const onConfirm = (): void => { + setShowConfirmModal(false) + // Clear the default MMKV instance which stores Redux persisted state + const storage = new MMKV() + storage.clearAll() + logger.debug('ForceReduxDataLossRow', 'onConfirm', 'MMKV storage cleared, exiting app') + + try { + DevSettings.reload('Dev menu: force redux data loss') + } catch { + // Fallback: crash the app to force restart if reload isn't available + setTimeout(() => { + throw new Error('Forcing app restart after Redux data loss simulation') + }, 500) + } + } + + return ( + <> + setShowConfirmModal(false)} + onReject={() => setShowConfirmModal(false)} + onAcknowledge={onConfirm} + /> + setShowConfirmModal(true)}> + + + + + + + Test recovery + + + + + + + ) +} diff --git a/apps/mobile/src/components/Settings/ManageWalletsModal.tsx b/apps/mobile/src/components/Settings/ManageWalletsModal.tsx new file mode 100644 index 00000000..7ced6aa5 --- /dev/null +++ b/apps/mobile/src/components/Settings/ManageWalletsModal.tsx @@ -0,0 +1,202 @@ +import { useNavigation } from '@react-navigation/core' +import { default as React, useCallback, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { ListRenderItemInfo, SectionList } from 'react-native' +import { navigate } from 'src/app/navigation/rootNavigation' +import { + AppStackScreenProp, + OnboardingStackNavigationProp, + SettingsStackNavigationProp, +} from 'src/app/navigation/types' +import { useReactNavigationModal } from 'src/components/modals/useReactNavigationModal' +import { RemoveWalletContent } from 'src/components/RemoveWallet/RemoveWalletContent' +import { + SettingsRow, + SettingsSection, + SettingsSectionItem, + SettingsSectionItemComponent, +} from 'src/components/Settings/SettingsRow' +import { UnitagBanner } from 'src/components/unitags/UnitagBanner' +import { useWalletConnect } from 'src/features/walletConnect/useWalletConnect' +import { Button, Flex, IconProps, useSporeColors } from 'ui/src' +import { Edit, Global } from 'ui/src/components/icons' +import { Person } from 'ui/src/components/icons/Person' +import { iconSizes, spacing } from 'ui/src/theme' +import { AddressDisplay } from 'uniswap/src/components/accounts/AddressDisplay' +import { Modal } from 'uniswap/src/components/modals/Modal' +import { useUnitagsAddressQuery } from 'uniswap/src/data/apiClients/unitagsApi/useUnitagsAddressQuery' +import { AccountType } from 'uniswap/src/features/accounts/types' +import { useEnabledChains } from 'uniswap/src/features/chains/hooks/useEnabledChains' +import { useENS } from 'uniswap/src/features/ens/useENS' +import { ModalName } from 'uniswap/src/features/telemetry/constants' +import { MobileScreens } from 'uniswap/src/types/screens/mobile' +import { useCanAddressClaimUnitag } from 'wallet/src/features/unitags/hooks/useCanAddressClaimUnitag' +import { useAccounts } from 'wallet/src/features/wallet/hooks' + +export function ManageWalletsModal({ route }: AppStackScreenProp): JSX.Element { + const { t } = useTranslation() + const colors = useSporeColors() + const addressToAccount = useAccounts() + const { onClose } = useReactNavigationModal() + const navigation = useNavigation() + + const [isRemoveWalletOpen, setRemoveWalletState] = useState(false) + + const { address } = route.params + + const currentAccount = addressToAccount[address] + const { sessions } = useWalletConnect(address) + + const { defaultChainId } = useEnabledChains() + const { data: unitag } = useUnitagsAddressQuery({ + params: address ? { address } : undefined, + }) + const ensName = useENS({ nameOrAddress: address, chainId: defaultChainId }).name + const onlyLabeledWallet = ensName === null && unitag?.username === undefined + + const { canClaimUnitag } = useCanAddressClaimUnitag(address) + const showUnitagBanner = currentAccount?.type === AccountType.SignerMnemonic && canClaimUnitag + + const onRemoveWallet = (): void => { + setRemoveWalletState(true) + } + + const renderItem = useCallback( + ({ item }: ListRenderItemInfo): JSX.Element | null => { + if (item.isHidden) { + return null + } + if ('component' in item) { + return item.component + } + return ( + { + onClose() + return item.checkIfCanProceed ? item.checkIfCanProceed() : true + }} + /> + ) + }, + [navigation, onClose], + ) + + const renderItemSeparator = (): JSX.Element => + + const sections: SettingsSection[] = useMemo((): SettingsSection[] => { + const iconProps: IconProps = { + color: colors.neutral2.get(), + size: iconSizes.icon24, + strokeLinecap: 'round', + strokeLinejoin: 'round', + strokeWidth: 2, + } + + const editNicknameSectionOption: SettingsSectionItem = { + navigationModal: ModalName.EditLabelSettingsModal, + text: t('settings.setting.wallet.action.editLabel'), + icon: , + screenProps: { address }, + isHidden: !!ensName || !!unitag?.username, + checkIfCanProceed: (): boolean => { + navigate(ModalName.EditLabelSettingsModal, { + address, + accessPoint: MobileScreens.SettingsWallet, + }) + return false + }, + } + + const editLabelSectionOption: SettingsSectionItem = { + navigationModal: ModalName.EditProfileSettingsModal, + text: unitag?.username + ? t('settings.setting.wallet.action.editProfile') + : t('settings.setting.wallet.action.editLabel'), + icon: unitag?.username ? ( + + ) : ( + + ), + screenProps: { address }, + isHidden: currentAccount?.type === AccountType.Readonly, + checkIfCanProceed: (): boolean => { + navigate(ModalName.EditProfileSettingsModal, { + address, + accessPoint: MobileScreens.SettingsWallet, + }) + return false + }, + } + + return [ + { + data: [ + ...(currentAccount?.type === AccountType.SignerMnemonic && !onlyLabeledWallet + ? [] + : [editNicknameSectionOption]), + ...(unitag?.username !== undefined ? [editLabelSectionOption] : []), + { + navigationModal: ModalName.ConnectionsDappListModal, + text: t('settings.setting.wallet.connections.title'), + count: sessions.length, + icon: , + isHidden: currentAccount?.type === AccountType.Readonly, + checkIfCanProceed: (): boolean => { + navigate(ModalName.ConnectionsDappListModal, { + address, + }) + return false + }, + }, + ], + }, + ] + }, [onlyLabeledWallet, ensName, unitag, address, sessions, colors, currentAccount?.type, t]) + + return ( + + + {isRemoveWalletOpen ? ( + + ) : ( + + + + + + {showUnitagBanner && ( + + )} + + + 'wallet_settings' + index} + renderItem={renderItem} + sections={sections.filter((p) => !p.isHidden)} + showsVerticalScrollIndicator={false} + stickySectionHeadersEnabled={false} + /> + + + + + + )} + + + ) +} diff --git a/apps/mobile/src/components/Settings/ManageWalletsModalState.tsx b/apps/mobile/src/components/Settings/ManageWalletsModalState.tsx new file mode 100644 index 00000000..bfc082df --- /dev/null +++ b/apps/mobile/src/components/Settings/ManageWalletsModalState.tsx @@ -0,0 +1,4 @@ +// this was moved to its own file to avoid circular dependencies +export interface ManageWalletsModalState { + address: Address +} diff --git a/apps/mobile/src/components/Settings/OnboardingRow.tsx b/apps/mobile/src/components/Settings/OnboardingRow.tsx new file mode 100644 index 00000000..02a80e46 --- /dev/null +++ b/apps/mobile/src/components/Settings/OnboardingRow.tsx @@ -0,0 +1,73 @@ +import { useState } from 'react' +import { useDispatch } from 'react-redux' +import { useSettingsStackNavigation } from 'src/app/navigation/types' +import { clearOnboardingTimestamp } from 'src/features/analytics/onboardingTimestamp' +import { useAppStateResetter } from 'src/features/appState/appStateResetter' +import { Flex, type IconProps, Text, TouchableArea } from 'ui/src' +import { RotatableChevron, UniswapLogo } from 'ui/src/components/icons' +import { WarningSeverity } from 'uniswap/src/components/modals/WarningModal/types' +import { WarningModal } from 'uniswap/src/components/modals/WarningModal/WarningModal' +import { ModalName } from 'uniswap/src/features/telemetry/constants' +import { logger } from 'utilities/src/logger/logger' +import { useSignerAccounts } from 'wallet/src/features/wallet/hooks' +import { Keyring } from 'wallet/src/features/wallet/Keyring/Keyring' +import { resetWallet, setFinishedOnboarding } from 'wallet/src/features/wallet/slice' + +export function OnboardingRow({ iconProps }: { iconProps: IconProps }): JSX.Element { + const dispatch = useDispatch() + const navigation = useSettingsStackNavigation() + const associatedAccounts = useSignerAccounts() + const appStateResetter = useAppStateResetter() + const [showConfirmModal, setShowConfirmModal] = useState(false) + + const onPressReset = (): void => { + setShowConfirmModal(false) + const uniqueMnemonicIds = new Set(associatedAccounts.map((a) => a.mnemonicId)) + const mnemonicPromises = [...uniqueMnemonicIds].map(Keyring.removeMnemonic) + const accountAddresses = associatedAccounts.map((a) => a.address) + const keyPromises = accountAddresses.map(Keyring.removePrivateKey) + Promise.all([...mnemonicPromises, ...keyPromises]) + .then(() => appStateResetter.resetAll()) + .then(() => { + navigation.goBack() + clearOnboardingTimestamp() + dispatch(resetWallet()) + dispatch(setFinishedOnboarding({ finishedOnboarding: false })) + }) + .catch((error) => { + logger.error(error, { + tags: { file: 'OnboardingRow.tsx', function: 'onPressReset' }, + }) + }) + } + + return ( + <> + setShowConfirmModal(false)} + onReject={() => setShowConfirmModal(false)} + onAcknowledge={onPressReset} + /> + setShowConfirmModal(true)}> + + + + + + + Reset wallet (onboarding) + + + + + + + ) +} diff --git a/apps/mobile/src/components/Settings/ResetBehaviorHistoryRow.tsx b/apps/mobile/src/components/Settings/ResetBehaviorHistoryRow.tsx new file mode 100644 index 00000000..e7e5c9b2 --- /dev/null +++ b/apps/mobile/src/components/Settings/ResetBehaviorHistoryRow.tsx @@ -0,0 +1,29 @@ +import { useDispatch } from 'react-redux' +import { Flex, IconProps, Text, TouchableArea } from 'ui/src' +import { UniswapLogo } from 'ui/src/components/icons' +import { resetUniswapBehaviorHistory } from 'uniswap/src/features/behaviorHistory/slice' +import { resetWalletBehaviorHistory } from 'wallet/src/features/behaviorHistory/slice' + +export function ResetBehaviorHistoryRow({ iconProps }: { iconProps: IconProps }): JSX.Element { + const dispatch = useDispatch() + + const onPressReset = (): void => { + dispatch(resetWalletBehaviorHistory()) + dispatch(resetUniswapBehaviorHistory()) + } + + return ( + + + + + + + + Reset behavior history + + + + + ) +} diff --git a/apps/mobile/src/components/Settings/SettingsAppearanceModal.tsx b/apps/mobile/src/components/Settings/SettingsAppearanceModal.tsx new file mode 100644 index 00000000..d35b113d --- /dev/null +++ b/apps/mobile/src/components/Settings/SettingsAppearanceModal.tsx @@ -0,0 +1,99 @@ +import { default as React, useCallback } from 'react' +import { useTranslation } from 'react-i18next' +import { useDispatch } from 'react-redux' +import { useReactNavigationModal } from 'src/components/modals/useReactNavigationModal' +import { Flex, GeneratedIcon, Text, TouchableArea } from 'ui/src' +import { Check, Contrast, Moon, Sun } from 'ui/src/components/icons' +import { Modal } from 'uniswap/src/components/modals/Modal' +import { useCurrentAppearanceSetting } from 'uniswap/src/features/appearance/hooks' +import { AppearanceSettingType, setSelectedAppearanceSettings } from 'uniswap/src/features/appearance/slice' +import { ModalName } from 'uniswap/src/features/telemetry/constants' + +export function SettingsAppearanceModal(): JSX.Element { + const { t } = useTranslation() + const currentTheme = useCurrentAppearanceSetting() + const { onClose } = useReactNavigationModal() + + return ( + + + + + {t('settings.setting.appearance.title')} + + + + + + + + + + ) +} + +interface AppearanceOptionProps { + active?: boolean + title: string + subtitle: string + option: AppearanceSettingType + Icon: GeneratedIcon + onClose: () => void +} + +function AppearanceOption({ active, title, subtitle, Icon, option, onClose }: AppearanceOptionProps): JSX.Element { + const dispatch = useDispatch() + + const showCheckMarkOpacity = active ? 1 : 0 + + const changeTheme = useCallback(async (): Promise => { + dispatch(setSelectedAppearanceSettings(option)) + onClose() + }, [dispatch, option, onClose]) + + return ( + + + + + + {title} + + + {subtitle} + + + + + + + + ) +} diff --git a/apps/mobile/src/components/Settings/SettingsBiometricModal.tsx b/apps/mobile/src/components/Settings/SettingsBiometricModal.tsx new file mode 100644 index 00000000..f6a2277d --- /dev/null +++ b/apps/mobile/src/components/Settings/SettingsBiometricModal.tsx @@ -0,0 +1,230 @@ +import React, { useCallback, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Alert, ListRenderItemInfo } from 'react-native' +import { FlatList } from 'react-native-gesture-handler' +import { useDispatch } from 'react-redux' +import { useReactNavigationModal } from 'src/components/modals/useReactNavigationModal' +import { BiometricAuthWarningModal } from 'src/components/Settings/BiometricAuthWarningModal' +import { enroll } from 'src/features/biometrics/biometrics-utils' +import { useBiometricAppSettings } from 'src/features/biometrics/useBiometricAppSettings' +import { useDeviceSupportsBiometricAuth } from 'src/features/biometrics/useDeviceSupportsBiometricAuth' +import { + checkOsBiometricAuthEnabled, + useBiometricName, + useBiometricPrompt, +} from 'src/features/biometricsSettings/hooks' +import { + BiometricSettingType, + setRequiredForAppAccess, + setRequiredForTransactions, +} from 'src/features/biometricsSettings/slice' +import { openSettings } from 'src/utils/linking' +import { Flex, Switch, Text, TouchableArea } from 'ui/src' +import { Modal } from 'uniswap/src/components/modals/Modal' +import { ModalName } from 'uniswap/src/features/telemetry/constants' +import { isAndroid, isIOS } from 'utilities/src/platform' + +interface BiometricAuthSetting { + onValueChange: (newValue: boolean) => void + value: boolean + text: string + subText: string +} + +type BiometricPromptTriggerArgs = { + biometricAppSettingType: BiometricSettingType | null + newValue: boolean +} + +export function SettingsBiometricModal(): JSX.Element { + const { t } = useTranslation() + const dispatch = useDispatch() + + const [showUnsafeWarningModal, setShowUnsafeWarningModal] = useState(false) + const [unsafeWarningModalType, setUnsafeWarningModalType] = useState(null) + const onCloseModal = useCallback(() => setShowUnsafeWarningModal(false), []) + const { onClose } = useReactNavigationModal() + + const { touchId } = useDeviceSupportsBiometricAuth() + const biometricsMethod = useBiometricName(touchId) + + const { requiredForAppAccess, requiredForTransactions } = useBiometricAppSettings() + const { trigger } = useBiometricPrompt((args?: BiometricPromptTriggerArgs) => { + if (!args) { + return + } + const { biometricAppSettingType, newValue } = args + switch (biometricAppSettingType) { + case BiometricSettingType.RequiredForAppAccess: + dispatch(setRequiredForAppAccess(newValue)) + break + case BiometricSettingType.RequiredForTransactions: + dispatch(setRequiredForTransactions(newValue)) + break + } + }) + + const options: BiometricAuthSetting[] = useMemo((): BiometricAuthSetting[] => { + const handleOSBiometricAuthTurnedOff = (): void => { + isIOS + ? Alert.alert( + isAndroid + ? t('settings.setting.biometrics.off.title.android') + : t('settings.setting.biometrics.off.title.ios', { biometricsMethod }), + isAndroid + ? t('settings.setting.biometrics.off.message.android') + : t('settings.setting.biometrics.off.message.ios', { biometricsMethod }), + [ + { text: t('common.navigation.systemSettings'), onPress: openSettings }, + { text: t('common.button.cancel') }, + ], + ) + : Alert.alert( + isAndroid + ? t('settings.setting.biometrics.unavailable.title.android') + : t('settings.setting.biometrics.unavailable.title.ios', { biometricsMethod }), + isAndroid + ? t('settings.setting.biometrics.unavailable.message.android') + : t('settings.setting.biometrics.unavailable.message.ios', { biometricsMethod }), + [{ text: t('common.button.setup'), onPress: enroll }, { text: t('common.button.cancel') }], + ) + } + + return [ + { + onValueChange: async (newRequiredForAppAccessValue): Promise => { + if (!newRequiredForAppAccessValue && !requiredForTransactions) { + setShowUnsafeWarningModal(true) + setUnsafeWarningModalType(BiometricSettingType.RequiredForAppAccess) + return + } + + if (newRequiredForAppAccessValue) { + // We only need to check if biometrics are enabled at the OS level when turning this setting on. + // We can skip this check when turning it off because the user will be prompted to authenticate via passcode anyway. + const isOSBiometricAuthEnabled = await checkOsBiometricAuthEnabled() + + if (!isOSBiometricAuthEnabled) { + handleOSBiometricAuthTurnedOff() + return + } + } + + await trigger({ + params: { + biometricAppSettingType: BiometricSettingType.RequiredForAppAccess, + newValue: newRequiredForAppAccessValue, + }, + }) + }, + value: requiredForAppAccess, + text: t('settings.setting.biometrics.appAccess.title'), + subText: isAndroid + ? t('settings.setting.biometrics.appAccess.subtitle.android') + : t('settings.setting.biometrics.appAccess.subtitle.ios', { biometricsMethod }), + }, + { + onValueChange: async (newRequiredForTransactionsValue): Promise => { + if (!newRequiredForTransactionsValue && !requiredForAppAccess) { + setShowUnsafeWarningModal(true) + setUnsafeWarningModalType(BiometricSettingType.RequiredForTransactions) + return + } + + if (newRequiredForTransactionsValue) { + // We only need to check if biometrics are enabled at the OS level when turning this setting on. + // We can skip this check when turning it off because the user will be prompted to authenticate via passcode anyway. + const isOSBiometricAuthEnabled = await checkOsBiometricAuthEnabled() + + if (!isOSBiometricAuthEnabled) { + handleOSBiometricAuthTurnedOff() + return + } + } + + await trigger({ + params: { + biometricAppSettingType: BiometricSettingType.RequiredForTransactions, + newValue: newRequiredForTransactionsValue, + }, + }) + }, + value: requiredForTransactions, + text: t('settings.setting.biometrics.transactions.title'), + subText: isAndroid + ? t('settings.setting.biometrics.transactions.subtitle.android') + : t('settings.setting.biometrics.transactions.subtitle.ios', { biometricsMethod }), + }, + ] + }, [requiredForAppAccess, t, biometricsMethod, requiredForTransactions, trigger]) + + const renderItem = ({ + item: { text, subText, value, onValueChange }, + }: ListRenderItemInfo): JSX.Element => { + return ( + + + + + {text} + + + {subText} + + + + + { + onValueChange(!value) + }} + > + + + + + ) + } + + return ( + + => { + await trigger({ + params: { + biometricAppSettingType: unsafeWarningModalType, + newValue: !(unsafeWarningModalType === BiometricSettingType.RequiredForAppAccess + ? requiredForAppAccess + : requiredForTransactions), + }, + }) + setShowUnsafeWarningModal(false) + setUnsafeWarningModalType(null) + }} + /> + + + + {isAndroid ? t('settings.setting.biometrics.title') : biometricsMethod} + + + + + + + + ) +} + +const renderItemSeparator = (): JSX.Element => diff --git a/apps/mobile/src/components/Settings/SettingsRow.tsx b/apps/mobile/src/components/Settings/SettingsRow.tsx new file mode 100644 index 00000000..aa8ffee3 --- /dev/null +++ b/apps/mobile/src/components/Settings/SettingsRow.tsx @@ -0,0 +1,286 @@ +import { NavigatorScreenParams, useNavigation } from '@react-navigation/native' +import { memo, useCallback } from 'react' +import { ValueOf } from 'react-native-gesture-handler/lib/typescript/typeUtils' +import { navigate } from 'src/app/navigation/rootNavigation' +import { + AppStackNavigationProp, + OnboardingStackNavigationProp, + OnboardingStackParamList, + SettingsStackNavigationProp, + SettingsStackParamList, +} from 'src/app/navigation/types' +import { ConnectionsDappsListModalState } from 'src/components/Settings/ConnectionsDappModal/ConnectionsDappsListModalState' +import { EditWalletSettingsModalState } from 'src/components/Settings/EditWalletModal/EditWalletSettingsModalState' +import { useIsScreenNavigationReady } from 'src/utils/useIsScreenNavigationReady' +import { Flex, Skeleton, Switch, Text, TouchableArea, useSporeColors } from 'ui/src' +import { Arrow } from 'ui/src/components/arrow/Arrow' +import { RotatableChevron } from 'ui/src/components/icons' +import { iconSizes } from 'ui/src/theme' +import { ModalName } from 'uniswap/src/features/telemetry/constants' +import { MobileScreens } from 'uniswap/src/types/screens/mobile' +import { openUri } from 'uniswap/src/utils/linking' +import { SmartWalletAdvancedSettingsModalState } from 'wallet/src/components/smartWallet/modals/SmartWalletAdvancedSettingsModal' + +export const SETTINGS_ROW_HEIGHT = 60 + +export interface SettingsSection { + subTitle?: string + data: (SettingsSectionItem | SettingsSectionItemComponent)[] + isHidden?: boolean +} + +export interface SettingsSectionItemComponent { + component: JSX.Element + isHidden?: boolean +} + +type SettingsNavigationModal = + | typeof ModalName.BiometricsModal + | typeof ModalName.FiatCurrencySelector + | typeof ModalName.EditProfileSettingsModal + | typeof ModalName.EditLabelSettingsModal + | typeof ModalName.ConnectionsDappListModal + | typeof ModalName.SmartWalletAdvancedSettingsModal + | typeof ModalName.PasskeyManagement + | typeof ModalName.Experiments + | typeof ModalName.SettingsAppearance + | typeof ModalName.PermissionsModal + | typeof ModalName.PortfolioBalanceModal + | typeof ModalName.LanguageSelector + +export interface SettingsSectionItem { + screen?: keyof SettingsStackParamList | typeof MobileScreens.OnboardingStack + navigationModal?: SettingsNavigationModal + testID?: string + screenProps?: ValueOf | NavigatorScreenParams + navigationProps?: + | ConnectionsDappsListModalState + | EditWalletSettingsModalState + | SmartWalletAdvancedSettingsModalState + externalLink?: string + action?: JSX.Element + disabled?: boolean + text: string + subText?: string + icon: JSX.Element + isHidden?: boolean + currentSetting?: string + onToggle?: () => void + isToggleEnabled?: boolean + checkIfCanProceed?: () => boolean + cantProceedFallback?: () => void + count?: number +} + +interface SettingsRowProps { + page: SettingsSectionItem + navigation: SettingsStackNavigationProp & OnboardingStackNavigationProp + checkIfCanProceed?: SettingsSectionItem['checkIfCanProceed'] + cantProceedFallback?: SettingsSectionItem['cantProceedFallback'] + testID?: string +} + +export const SettingsRow = memo( + ({ + page: { + screen, + navigationModal, + screenProps, + navigationProps, + externalLink, + disabled, + action, + icon, + text, + subText, + currentSetting, + onToggle, + isToggleEnabled, + count, + testID, + }, + navigation, + checkIfCanProceed, + cantProceedFallback, + }: SettingsRowProps): JSX.Element => { + const colors = useSporeColors() + + const handleRow = useCallback(async (): Promise => { + if (checkIfCanProceed && !checkIfCanProceed()) { + cantProceedFallback?.() + return + } + + if (onToggle) { + return + } else if (screen) { + // Type assignment to `any` is a workaround until we figure out how to + // properly type screen param. `navigate` function also brings some issues, + // where it accepts other screen's params, and not throws an error on required ones. + // oxlint-disable-next-line typescript/no-explicit-any -- Navigation types don't properly handle dynamic screen names + navigation.navigate(screen as any, screenProps) + } else if (navigationModal) { + navigate(navigationModal, navigationProps) + } else if (externalLink) { + await openUri({ uri: externalLink }) + } + }, [ + checkIfCanProceed, + cantProceedFallback, + onToggle, + screen, + navigation, + screenProps, + navigationProps, + navigationModal, + externalLink, + ]) + + return ( + + + + + {icon} + + + {text} + {subText && ( + + {subText} + + )} + + + {count !== undefined && ( + + {count} + + )} + + + + ) + }, +) + +SettingsRow.displayName = 'SettingsRow' + +const LOADING_DIMENSIONS = { + chevron: { + height: 24, + width: 24, + }, + text: { + height: 16, + width: 72, + }, + action: { + height: 24, + width: 40, + }, +} + +const RowRightContent = memo( + ({ + screen, + navigationModal, + externalLink, + disabled, + action, + currentSetting, + onToggle, + isToggleEnabled, + colors, + }: Pick< + SettingsSectionItem, + | 'screen' + | 'navigationModal' + | 'externalLink' + | 'disabled' + | 'action' + | 'currentSetting' + | 'onToggle' + | 'isToggleEnabled' + > & { + colors: ReturnType + }): JSX.Element | null => { + const navigation = useNavigation() as AppStackNavigationProp + // we do this to prevent jank on navigation transition + // mostly for the switch component + const shouldRender = useIsScreenNavigationReady({ navigation }) + + if (onToggle) { + if (typeof isToggleEnabled === 'undefined') { + throw new Error('Should pass valid isToggleEnabled prop when onToggle is passed') + } + + return ( + + ) + } + + if (screen || navigationModal) { + return ( + + {currentSetting && + (shouldRender ? ( + + + {currentSetting} + + + ) : ( + + + + ))} + + + ) + } + + if (externalLink) { + return + } + + if (action) { + return shouldRender ? ( + action + ) : ( + + + + ) + } + + return null + }, +) + +RowRightContent.displayName = 'RowRightContent' diff --git a/apps/mobile/src/components/Settings/WalletSettings.tsx b/apps/mobile/src/components/Settings/WalletSettings.tsx new file mode 100644 index 00000000..e0e4a28d --- /dev/null +++ b/apps/mobile/src/components/Settings/WalletSettings.tsx @@ -0,0 +1,86 @@ +import React, { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { navigate } from 'src/app/navigation/rootNavigation' +import { Flex, TouchableArea } from 'ui/src' +import { RotatableChevron } from 'ui/src/components/icons' +import { iconSizes } from 'ui/src/theme' +import { AddressDisplay } from 'uniswap/src/components/accounts/AddressDisplay' +import { ExpandoRow } from 'uniswap/src/components/ExpandoRow/ExpandoRow' +import { AccountType } from 'uniswap/src/features/accounts/types' +import { ModalName } from 'uniswap/src/features/telemetry/constants' +import { useAccountsList, useActiveAccountWithThrow } from 'wallet/src/features/wallet/hooks' + +const DEFAULT_ACCOUNTS_TO_DISPLAY = 3 +interface Account { + address: string + type: AccountType +} + +export function WalletSettings(): JSX.Element { + const { t } = useTranslation() + const allAccounts = useAccountsList() + const [showAll, setShowAll] = useState(false) + + const activeAccount = useActiveAccountWithThrow() + const activeAddress = activeAccount.address + + const accountsWithoutActiveAccount = allAccounts.filter((a) => a.address !== activeAddress) + + const toggleViewAll = (): void => { + setShowAll(!showAll) + } + + const handleNavigation = (address: string): void => { + navigate(ModalName.ManageWalletsModal, { + address, + }) + } + + const renderAccountRow = (account: Account): JSX.Element => { + const isViewOnlyWallet = account.type === AccountType.Readonly + return ( + handleNavigation(account.address)} + > + + + + + + ) + } + + return ( + + {allAccounts.length > DEFAULT_ACCOUNTS_TO_DISPLAY ? ( + <> + {renderAccountRow(activeAccount)} + + toggleViewAll()} + /> + + {showAll && accountsWithoutActiveAccount.map(renderAccountRow)} + + ) : ( + <> + {renderAccountRow(activeAccount)} + {accountsWithoutActiveAccount.map(renderAccountRow)} + + )} + + ) +} diff --git a/apps/mobile/src/components/Settings/lists/SettingsFlashList.tsx b/apps/mobile/src/components/Settings/lists/SettingsFlashList.tsx new file mode 100644 index 00000000..9ddeaad4 --- /dev/null +++ b/apps/mobile/src/components/Settings/lists/SettingsFlashList.tsx @@ -0,0 +1,127 @@ +import { FlashList } from '@shopify/flash-list' +import { default as React, useCallback, useMemo } from 'react' +import { SectionData, SectionInfo, SettingsListProps } from 'src/components/Settings/lists/types' +import { SETTINGS_ROW_HEIGHT, SettingsSection } from 'src/components/Settings/SettingsRow' +import { useDeviceDimensions } from 'ui/src/hooks/useDeviceDimensions' +import { spacing } from 'ui/src/theme' +import { useAppInsets } from 'uniswap/src/hooks/useAppInsets' + +export function SettingsFlashList({ + sections, + ItemSeparatorComponent, + ListFooterComponent, + ListHeaderComponent, + renderItem, + renderSectionHeader, + renderSectionFooter, + showsVerticalScrollIndicator = false, +}: SettingsListProps): JSX.Element { + const data = useMemo(() => processSections(sections), [sections]) + const insets = useAppInsets() + const { fullWidth, fullHeight } = useDeviceDimensions() + + const renderFlashListItem = useCallback( + ({ item, ...rest }: { item: ProcessedRow; index: number }) => { + if (item.type === 'header' && renderSectionHeader) { + return renderSectionHeader(item.data) + } + if (item.type === 'footer' && renderSectionFooter) { + return renderSectionFooter(item.data) + } + if (item.type === 'item') { + return renderItem({ + item: item.data, + index: rest.index, + // no-op separators for flashlist + separators: { + highlight: () => {}, + unhighlight: () => {}, + updateProps: () => {}, + }, + }) + } + return null + }, + [renderItem, renderSectionHeader, renderSectionFooter], + ) + + const estimatedListSize = useMemo(() => { + return { + height: fullHeight, + width: fullWidth, + } + }, [fullHeight, fullWidth]) + + const contentContainerStyle = useMemo(() => { + return { + paddingBottom: insets.bottom - spacing.spacing16, + paddingTop: spacing.spacing12, + paddingHorizontal: spacing.spacing24, + } + }, [insets]) + + return ( + + ) +} + +function keyExtractor(_item: ProcessedRow, index: number): string { + return 'settings' + index +} + +type ProcessedRow = + | { type: 'header'; data: SectionInfo } + | { type: 'item'; data: SectionData } + | { type: 'footer'; data: SectionInfo } + +function processSections(sections: SettingsSection[]): ProcessedRow[] { + const result: ProcessedRow[] = [] + + for (const section of sections) { + if (section.isHidden) { + continue + } + + if (section.subTitle) { + result.push({ + type: 'header', + data: { + section, + }, + }) + } + + for (const data of section.data) { + if ('isHidden' in data && data.isHidden) { + continue + } + + result.push({ + type: 'item', + data, + }) + } + + if (section.subTitle) { + result.push({ + type: 'footer', + data: { + section, + }, + }) + } + } + + return result +} diff --git a/apps/mobile/src/components/Settings/lists/SettingsList.tsx b/apps/mobile/src/components/Settings/lists/SettingsList.tsx new file mode 100644 index 00000000..7edb26fc --- /dev/null +++ b/apps/mobile/src/components/Settings/lists/SettingsList.tsx @@ -0,0 +1,4 @@ +import { memo } from 'react' +import { SettingsFlashList } from 'src/components/Settings/lists/SettingsFlashList' + +export const SettingsList = memo(SettingsFlashList) diff --git a/apps/mobile/src/components/Settings/lists/SettingsListModal.tsx b/apps/mobile/src/components/Settings/lists/SettingsListModal.tsx new file mode 100644 index 00000000..6e020827 --- /dev/null +++ b/apps/mobile/src/components/Settings/lists/SettingsListModal.tsx @@ -0,0 +1,94 @@ +// TODO(WALL-7189): Explore removing FlatList. Currently using this to fix a scrolling regression. +import { FlatList } from 'react-native-gesture-handler' +import { useReactNavigationModal } from 'src/components/modals/useReactNavigationModal' +import { Flex, Text, TouchableArea } from 'ui/src' +import { Check } from 'ui/src/components/icons' +import { Modal } from 'uniswap/src/components/modals/Modal' +import { ModalNameType } from 'uniswap/src/features/telemetry/constants' +import { useEvent } from 'utilities/src/react/hooks' + +type SettingsListModalProps = { + modalName: ModalNameType + title: string + selectedItem: T + options: T[] + getItemTitle: (item: T) => string + getItemSubtitle?: (item: T) => string + onSelectItem: (item: T) => Promise +} + +export function SettingsListModal({ + modalName, + title, + selectedItem, + options, + getItemTitle, + getItemSubtitle, + onSelectItem, +}: SettingsListModalProps): JSX.Element { + const { onClose } = useReactNavigationModal() + + // render + const renderItem = useEvent(({ item }: { item: T }) => ( + + )) + + return ( + + + {title} + + {/* When modifying this component, please test on a physical device that + scrolling the languages list continues to work correctly. */} + item} + renderItem={renderItem} + showsVerticalScrollIndicator={false} + bounces={true} + keyboardShouldPersistTaps="always" + keyboardDismissMode="on-drag" + /> + + ) +} + +function SettingsListModalOption({ + active, + item, + getItemTitle, + getItemSubtitle, + onSelectItem, +}: { + active?: boolean + item: T +} & Pick, 'getItemSubtitle' | 'getItemTitle' | 'onSelectItem'>): JSX.Element { + const { onClose } = useReactNavigationModal() + + const onSelectOption = useEvent(async () => { + await onSelectItem(item) + onClose() + }) + + return ( + + + + {getItemTitle(item)} + {getItemSubtitle && ( + + {getItemSubtitle(item)} + + )} + + {active && } + + + ) +} diff --git a/apps/mobile/src/components/Settings/lists/types.ts b/apps/mobile/src/components/Settings/lists/types.ts new file mode 100644 index 00000000..e8c8620c --- /dev/null +++ b/apps/mobile/src/components/Settings/lists/types.ts @@ -0,0 +1,18 @@ +import { default as React } from 'react' +import { ListRenderItemInfo, SectionListData } from 'react-native' +import { SettingsSection, SettingsSectionItem, SettingsSectionItemComponent } from 'src/components/Settings/SettingsRow' + +export type SectionData = SettingsSectionItem | SettingsSectionItemComponent +export type SectionInfo = { section: SectionListData } + +export type SettingsListProps = { + sections: SettingsSection[] + ItemSeparatorComponent?: React.ComponentType | null + ListFooterComponent?: React.ComponentType | React.ReactElement | null + ListHeaderComponent?: React.ComponentType | React.ReactElement | null + renderItem: (info: ListRenderItemInfo) => React.ReactElement | null + renderSectionHeader?: (info: SectionInfo) => React.ReactElement | null + renderSectionFooter?: (info: SectionInfo) => React.ReactElement | null + showsVerticalScrollIndicator?: boolean + keyExtractor: (item: SectionData, index: number) => string +} diff --git a/apps/mobile/src/components/TokenBalanceList/TokenBalanceList.tsx b/apps/mobile/src/components/TokenBalanceList/TokenBalanceList.tsx new file mode 100644 index 00000000..1d474af7 --- /dev/null +++ b/apps/mobile/src/components/TokenBalanceList/TokenBalanceList.tsx @@ -0,0 +1,304 @@ +import { NetworkStatus } from '@apollo/client' +import { BottomSheetFlatList } from '@gorhom/bottom-sheet' +import { useIsFocused } from '@react-navigation/core' +import { ReactNavigationPerformanceView } from '@shopify/react-native-performance-navigation' +import { forwardRef, memo, useCallback, useEffect, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { FlatList, RefreshControl } from 'react-native' +import Animated, { FadeInDown, FadeOut } from 'react-native-reanimated' +import { useDispatch } from 'react-redux' +import { navigate } from 'src/app/navigation/rootNavigation' +import { useAppStackNavigation } from 'src/app/navigation/types' +import { useAdaptiveFooter } from 'src/components/home/hooks' +import { TAB_BAR_HEIGHT, TAB_VIEW_SCROLL_THROTTLE, TabProps } from 'src/components/layout/TabHelpers' +import { useAppStateTrigger } from 'src/utils/useAppStateTrigger' +import { Flex, Loader, useSporeColors } from 'ui/src' +import { AnimatedFlex } from 'ui/src/components/layout/AnimatedFlex' +import { zIndexes } from 'ui/src/theme' +import { BaseCard } from 'uniswap/src/components/BaseCard/BaseCard' +import { EmptyTokensList } from 'uniswap/src/components/portfolio/EmptyTokensList' +import { HiddenTokensRow } from 'uniswap/src/components/portfolio/HiddenTokensRow' +import { TokenBalanceItem } from 'uniswap/src/components/portfolio/TokenBalanceItem' +import { TokenBalanceItemContextMenu } from 'uniswap/src/components/portfolio/TokenBalanceItemContextMenu' +import { PortfolioBalance } from 'uniswap/src/features/dataApi/types' +import { pushNotification } from 'uniswap/src/features/notifications/slice/slice' +import { AppNotificationType, CopyNotificationType } from 'uniswap/src/features/notifications/slice/types' +import { + TokenBalanceListContextProvider, + useTokenBalanceListContext, +} from 'uniswap/src/features/portfolio/TokenBalanceListContext' +import { isHiddenTokenBalancesRow, TokenBalanceListRow } from 'uniswap/src/features/portfolio/types' +import { ModalName } from 'uniswap/src/features/telemetry/constants' +import { useAppInsets } from 'uniswap/src/hooks/useAppInsets' +import { CurrencyId } from 'uniswap/src/types/currency' +import { MobileScreens } from 'uniswap/src/types/screens/mobile' +import { setClipboard } from 'utilities/src/clipboard/clipboard' +import { DDRumManualTiming } from 'utilities/src/logger/datadog/datadogEvents' +import { usePerformanceLogger } from 'utilities/src/logger/usePerformanceLogger' +import { isAndroid } from 'utilities/src/platform' +import { noop } from 'utilities/src/react/noop' + +type TokenBalanceListProps = TabProps & { + empty?: JSX.Element | null + onPressToken: (currencyId: CurrencyId) => void + isExternalProfile?: boolean +} + +const ESTIMATED_TOKEN_ITEM_HEIGHT = 64 + +export const TokenBalanceList = forwardRef, TokenBalanceListProps>( + function TokenBalanceListInner({ owner, onPressToken, isExternalProfile = false, ...rest }, ref): JSX.Element { + return ( + + + + ) + }, +) + +const TokenBalanceListContent = forwardRef, TokenBalanceListProps>( + function TokenBalanceListContentInner( + { + empty, + containerProps, + scrollHandler, + isExternalProfile = false, + renderedInModal = false, + refreshing, + headerHeight = 0, + onRefresh, + testID, + }, + ref, + ) { + const colors = useSporeColors() + const insets = useAppInsets() + + usePerformanceLogger(DDRumManualTiming.RenderTokenBalanceList, []) + + const { rows, balancesById } = useTokenBalanceListContext() + + const { onContentSizeChange, adaptiveFooter } = useAdaptiveFooter(containerProps?.contentContainerStyle) + + const [localIsFocused, setLocalIsFocused] = useState(true) + + const reactNavigationIsFocused = useIsFocused() + + // used for window size adjustment based on focus state (smaller window size when out of focus) + const isFocused = localIsFocused || reactNavigationIsFocused + + const navigation = useAppStackNavigation() + + useEffect(() => { + // We use this instead of relying on react-navigation's `useIsFocused` because we want to speed up the screen transition + // when the user goes from the token details screen back to the home screen, so we want this state to change *after* the animation is done instead of *before*. + const unsubscribeTransitionEnd = navigation.addListener('transitionEnd', (e) => { + if (!e.data.closing) { + setLocalIsFocused(true) + } + }) + + return (): void => unsubscribeTransitionEnd() + }, [navigation]) + + const refreshControl = useMemo(() => { + return ( + + ) + }, [insets.top, headerHeight, refreshing, colors.neutral3, onRefresh]) + + // In order to avoid unnecessary re-renders of the entire FlatList, the `renderItem` function should never change. + // That's why we use a context provider so that each row can read from there instead of passing down new props every time the data changes. + const renderItem = useCallback( + ({ item }: { item: TokenBalanceListRow }): JSX.Element => , + [], + ) + + const keyExtractor = useCallback((item: TokenBalanceListRow): string => item, []) + + const ListEmptyComponent = useMemo(() => { + return ( + + {empty} + + } + emptyCondition={!!empty} + errorCardContainerStyle={{ pt: '$spacing24' }} + /> + ) + }, [empty]) + + const ListHeaderComponent = useMemo(() => { + return + }, []) + + // add negative z index to prevent footer from covering hidden tokens row when minimized + const ListFooterComponentStyle = useMemo(() => ({ zIndex: zIndexes.negative }), []) + + const List = renderedInModal ? BottomSheetFlatList : Animated.FlatList + + const getItemLayout = useCallback( + (_: Maybe>, index: number): { length: number; offset: number; index: number } => ({ + length: ESTIMATED_TOKEN_ITEM_HEIGHT, + offset: ESTIMATED_TOKEN_ITEM_HEIGHT * index, + index, + }), + [], + ) + + const hasData = !!balancesById + const data = hasData ? rows : undefined + + // Note: `PerformanceView` must wrap the entire return statement to properly track interactive states. + return ( + + + + ) + }, +) + +const HeaderComponent = memo(function HeaderComponentInner(): JSX.Element | null { + const { t } = useTranslation() + const { balancesById, networkStatus, refetch, isPortfolioBalancesLoading } = useTokenBalanceListContext() + + useAppStateTrigger({ from: 'background', to: 'active', callback: refetch || noop }) + + const hasData = !!balancesById + const hasErrorWithCachedValues = !isPortfolioBalancesLoading && hasData && networkStatus === NetworkStatus.error + + return hasErrorWithCachedValues ? ( + + + + ) : null +}) + +const TokenBalanceItemRow = memo(function TokenBalanceItemRow({ item }: { item: TokenBalanceListRow }) { + const dispatch = useDispatch() + const { balancesById, isWarmLoading, onPressToken } = useTokenBalanceListContext() + + const handlePressLearnMore = useCallback((): void => { + navigate(ModalName.HiddenTokenInfoModal) + }, []) + + const balance = balancesById?.[item] + const currencyInfo = balance?.tokens[0]?.currencyInfo + const isHidden = balance?.isHidden ?? false + + const copyAddressToClipboard = useCallback( + async (address: string): Promise => { + await setClipboard(address) + dispatch( + pushNotification({ + type: AppNotificationType.Copied, + copyType: CopyNotificationType.ContractAddress, + }), + ) + }, + [dispatch], + ) + + // Adapter to bridge multichain balance to PortfolioBalance shape for the context menu. + const portfolioBalance: PortfolioBalance | undefined = useMemo(() => { + if (!balance?.tokens[0]) { + return undefined + } + const primaryToken = balance.tokens[0] + return { + id: balance.id, + cacheId: balance.cacheId, + quantity: primaryToken.quantity, + balanceUSD: balance.totalValueUsd, + currencyInfo: primaryToken.currencyInfo, + relativeChange24: balance.pricePercentChange1d, + isHidden: balance.isHidden, + } + }, [balance]) + + const handlePressToken = useCallback((): void => { + if (currencyInfo?.currencyId && onPressToken) { + onPressToken(currencyInfo.currencyId) + } + }, [currencyInfo?.currencyId, onPressToken]) + + if (isHiddenTokenBalancesRow(item)) { + return + } + + if (!balance || !portfolioBalance || !currencyInfo) { + // This can happen when the view is out of focus and the user sells/sends 100% of a token's balance. + // In that case, the token is removed from the balances object, but the FlatList is still using the cached array of IDs until the view comes back into focus. + // As soon as the view comes back into focus, the FlatList will re-render with the latest data, so users won't really see this Skeleton for more than a few milliseconds when this happens. + return ( + + + + ) + } + + return ( + + navigate(ModalName.ReportTokenIssue, { + currency: portfolioBalance.currencyInfo.currency, + isMarkedSpam: portfolioBalance.currencyInfo.isSpam, + source: 'portfolio', + }) + } + onPressToken={handlePressToken} + > + + + ) +}) diff --git a/apps/mobile/src/components/TokenDetails/BuyNativeTokenModal.tsx b/apps/mobile/src/components/TokenDetails/BuyNativeTokenModal.tsx new file mode 100644 index 00000000..e9dac316 --- /dev/null +++ b/apps/mobile/src/components/TokenDetails/BuyNativeTokenModal.tsx @@ -0,0 +1,97 @@ +import { useTranslation } from 'react-i18next' +import { AppStackScreenProp } from 'src/app/navigation/types' +import { useReactNavigationModal } from 'src/components/modals/useReactNavigationModal' +import { ReceiveButton } from 'src/components/TokenDetails/ReceiveButton' +import { Flex, Text } from 'ui/src' +import { iconSizes } from 'ui/src/theme' +import { CurrencyLogo } from 'uniswap/src/components/CurrencyLogo/CurrencyLogo' +import { Modal } from 'uniswap/src/components/modals/Modal' +import { LearnMoreLink } from 'uniswap/src/components/text/LearnMoreLink' +import { uniswapUrls } from 'uniswap/src/constants/urls' +import { useBridgingTokenWithHighestBalance } from 'uniswap/src/features/bridging/hooks/tokens' +import { getChainInfo } from 'uniswap/src/features/chains/chainInfo' +import { UniverseChainId } from 'uniswap/src/features/chains/types' +import { ModalName } from 'uniswap/src/features/telemetry/constants' +import { useCurrencyInfo, useNativeCurrencyInfo } from 'uniswap/src/features/tokens/useCurrencyInfo' +import { BridgeTokenButton } from 'uniswap/src/features/transactions/components/InsufficientNativeTokenWarning/BridgeTokenButton' +import { BuyNativeTokenButton } from 'uniswap/src/features/transactions/components/InsufficientNativeTokenWarning/BuyNativeTokenButton' +import { currencyIdToAddress } from 'uniswap/src/utils/currencyId' +import { useActiveAccountAddress } from 'wallet/src/features/wallet/hooks' + +export function BuyNativeTokenModal({ + route, +}: AppStackScreenProp): JSX.Element | null { + const { chainId, currencyId } = route.params + const { t } = useTranslation() + const activeAddress = useActiveAccountAddress() + const nativeCurrencyInfo = useNativeCurrencyInfo(chainId) + const currencyInfo = useCurrencyInfo(currencyId) + const { onClose } = useReactNavigationModal() + + const { data: bridgingTokenWithHighestBalance } = useBridgingTokenWithHighestBalance({ + evmAddress: activeAddress ?? '', + currencyAddress: currencyIdToAddress(nativeCurrencyInfo?.currencyId ?? ''), + currencyChainId: chainId, + }) + + if (!nativeCurrencyInfo || !currencyInfo) { + return null + } + + const isMainnet = chainId === UniverseChainId.Mainnet + const chainName = getChainInfo(chainId).label + const nativeTokenSymbol = nativeCurrencyInfo.currency.symbol ?? '' + const nativeTokenName = nativeCurrencyInfo.currency.name ?? '' + const tokenSymbol = currencyInfo.currency.symbol ?? '' + + return ( + + + + + + + {isMainnet + ? t('token.zeroNativeBalance.title.mainnet', { nativeTokenName }) + : t('token.zeroNativeBalance.title.otherChains', { nativeTokenName, chainName })} + + + {t('token.zeroNativeBalance.subtitle', { tokenSymbol })} + + + {isMainnet + ? t('token.zeroNativeBalance.description.mainnet', { tokenSymbol }) + : t('token.zeroNativeBalance.description.otherChains', { + tokenSymbol, + nativeTokenSymbol, + chainName, + })} + + + + + + {bridgingTokenWithHighestBalance && ( + + )} + + {!bridgingTokenWithHighestBalance && } + + + + ) +} diff --git a/apps/mobile/src/components/TokenDetails/BuyNativeTokenModalState.tsx b/apps/mobile/src/components/TokenDetails/BuyNativeTokenModalState.tsx new file mode 100644 index 00000000..afb317df --- /dev/null +++ b/apps/mobile/src/components/TokenDetails/BuyNativeTokenModalState.tsx @@ -0,0 +1,6 @@ +import { UniverseChainId } from 'uniswap/src/features/chains/types' + +export interface BuyNativeTokenModalState { + chainId: UniverseChainId + currencyId: string +} diff --git a/apps/mobile/src/components/TokenDetails/ContractAddressExplainerModal.tsx b/apps/mobile/src/components/TokenDetails/ContractAddressExplainerModal.tsx new file mode 100644 index 00000000..23ef00f2 --- /dev/null +++ b/apps/mobile/src/components/TokenDetails/ContractAddressExplainerModal.tsx @@ -0,0 +1,46 @@ +import { useTranslation } from 'react-i18next' +import { Text } from 'ui/src' +import { WarningSeverity } from 'uniswap/src/components/modals/WarningModal/types' +import { WarningModal } from 'uniswap/src/components/modals/WarningModal/WarningModal' +import { LearnMoreLink } from 'uniswap/src/components/text/LearnMoreLink' +import { uniswapUrls } from 'uniswap/src/constants/urls' +import { ModalName } from 'uniswap/src/features/telemetry/constants' +import { TestID } from 'uniswap/src/test/fixtures/testIDs' + +export function ContractAddressExplainerModal({ + onAcknowledge, +}: { + onAcknowledge: (markViewed: boolean) => void +}): JSX.Element | null { + const { t } = useTranslation() + + return ( + + + {t('token.safety.warning.copyContractAddress.message')} + + + + } + severity={WarningSeverity.Low} + acknowledgeText={t('common.button.understand')} + onAcknowledge={() => onAcknowledge(true)} + onClose={() => onAcknowledge(false)} + /> + ) +} diff --git a/apps/mobile/src/components/TokenDetails/LinkButton.tsx b/apps/mobile/src/components/TokenDetails/LinkButton.tsx new file mode 100644 index 00000000..3acd1007 --- /dev/null +++ b/apps/mobile/src/components/TokenDetails/LinkButton.tsx @@ -0,0 +1,98 @@ +import { SharedEventName } from '@uniswap/analytics-events' +import React from 'react' +import { SvgProps } from 'react-native-svg' +import { useSelector } from 'react-redux' +import { useTokenDetailsContext } from 'src/components/TokenDetails/TokenDetailsContext' +import { Flex, GeneratedIcon, IconProps, Text, TouchableArea } from 'ui/src' +import { CopySheets } from 'ui/src/components/icons' +import { selectHasViewedContractAddressExplainer } from 'uniswap/src/features/behaviorHistory/selectors' +import { ElementName } from 'uniswap/src/features/telemetry/constants' +import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import Trace from 'uniswap/src/features/telemetry/Trace' +import { TestIDType } from 'uniswap/src/test/fixtures/testIDs' +import { MobileScreens } from 'uniswap/src/types/screens/mobile' +import { openUri } from 'uniswap/src/utils/linking' + +export enum LinkButtonType { + Copy = 'copy', + Link = 'link', +} + +export type LinkButtonProps = { + label: string + Icon?: React.FC | GeneratedIcon + element: ElementName + openExternalBrowser?: boolean + isSafeUri?: boolean + testID?: TestIDType + /** Override default press behavior (link/copy). When provided, buttonType and value are unused. */ + onPress?: () => void + /** Controls default press behavior and icon display. Not required when onPress is provided. */ + buttonType?: LinkButtonType + /** URI to open or address to copy. Not required when onPress is provided. */ + value?: string +} + +export function LinkButton({ + buttonType, + label, + Icon, + element, + openExternalBrowser = false, + isSafeUri = false, + value, + testID, + onPress: onPressProp, +}: LinkButtonProps): JSX.Element { + const hasViewedContractAddressExplainer = useSelector(selectHasViewedContractAddressExplainer) + const { openContractAddressExplainerModal, copyAddressToClipboard } = useTokenDetailsContext() + + const copyValue = async (): Promise => { + if (!value) { + return + } + if (!hasViewedContractAddressExplainer) { + openContractAddressExplainerModal() + return + } + await copyAddressToClipboard(value) + + sendAnalyticsEvent(SharedEventName.ELEMENT_CLICKED, { + element: ElementName.CopyAddress, + screen: MobileScreens.TokenDetails, + }) + } + + const onPress = async (): Promise => { + if (onPressProp) { + onPressProp() + return + } + if (buttonType === LinkButtonType.Link && value) { + await openUri({ uri: value, openExternalBrowser, isSafeUri }) + } else { + await copyValue() + } + } + + return ( + + + + {Icon && } + + {label} + + {buttonType === LinkButtonType.Copy && } + + + + ) +} diff --git a/apps/mobile/src/components/TokenDetails/NetworkBalanceList.test.tsx b/apps/mobile/src/components/TokenDetails/NetworkBalanceList.test.tsx new file mode 100644 index 00000000..46cad9cf --- /dev/null +++ b/apps/mobile/src/components/TokenDetails/NetworkBalanceList.test.tsx @@ -0,0 +1,150 @@ +import { fireEvent } from '@testing-library/react-native' +import React from 'react' +import { NetworkBalanceList } from 'src/components/TokenDetails/NetworkBalanceList' +import { render } from 'src/test/test-utils' +import { UniverseChainId } from 'uniswap/src/features/chains/types' +import { PortfolioBalance } from 'uniswap/src/features/dataApi/types' +import { ON_PRESS_EVENT_PAYLOAD } from 'uniswap/src/test/fixtures' +import { TestID } from 'uniswap/src/test/fixtures/testIDs' + +jest.mock('@universe/gating', () => ({ + ...jest.requireActual('@universe/gating'), + useFeatureFlag: jest.fn().mockReturnValue(false), + useFeatureFlagWithLoading: jest.fn().mockReturnValue({ value: false, isLoading: false }), + useFeatureFlagWithExposureLoggingDisabled: jest.fn().mockReturnValue(false), +})) + +function makeBalance({ + chainId, + balanceUSD, + quantity, +}: { + chainId: UniverseChainId + balanceUSD: number + quantity: number +}): PortfolioBalance { + return { + id: `balance-${chainId}`, + cacheId: `cache-${chainId}`, + quantity, + balanceUSD, + relativeChange24: 0, + isHidden: false, + currencyInfo: { + currencyId: `${chainId}-0xusdc`, + currency: { + chainId, + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + decimals: 6, + symbol: 'USDC', + name: 'USD Coin', + isNative: false, + isToken: true, + }, + logoUrl: null, + safetyLevel: null, + safetyInfo: null, + isSpam: false, + }, + } as unknown as PortfolioBalance +} + +const TEST_BALANCES: PortfolioBalance[] = [ + makeBalance({ chainId: UniverseChainId.Mainnet, balanceUSD: 3209.44, quantity: 3209.44 }), + makeBalance({ chainId: UniverseChainId.Base, balanceUSD: 1654.32, quantity: 1654.32 }), +] + +describe(NetworkBalanceList, () => { + const defaultProps = { + balances: TEST_BALANCES, + onSelectBalance: jest.fn(), + } + + beforeEach(() => jest.clearAllMocks()) + + it('renders chain names for each balance', () => { + const { queryByText } = render() + + expect(queryByText('Ethereum')).toBeTruthy() + expect(queryByText('Base')).toBeTruthy() + }) + + it('renders a row for each balance', () => { + const { getAllByTestId } = render() + + const rows = getAllByTestId(TestID.NetworkBalanceRow) + expect(rows).toHaveLength(2) + }) + + it('calls onSelectBalance with the correct balance when a row is pressed', () => { + const onSelectBalance = jest.fn() + const { getAllByTestId } = render() + + const rows = getAllByTestId(TestID.NetworkBalanceRow) + fireEvent.press(rows[0]!, ON_PRESS_EVENT_PAYLOAD) + + expect(onSelectBalance).toHaveBeenCalledTimes(1) + expect(onSelectBalance).toHaveBeenCalledWith( + expect.objectContaining({ + currencyInfo: expect.objectContaining({ + currency: expect.objectContaining({ chainId: expect.any(Number) }), + }), + }), + ) + }) + + it('renders empty state when no balances are provided', () => { + const { queryByTestId } = render() + + expect(queryByTestId(TestID.NetworkBalanceRow)).toBeNull() + }) + + it('renders fiat and token amounts for each balance', () => { + const { getByText } = render() + + expect(getByText('$3,209.44')).toBeTruthy() + expect(getByText('$1,654.32')).toBeTruthy() + expect(getByText(/3,209.44 USDC/)).toBeTruthy() + expect(getByText(/1,654.32 USDC/)).toBeTruthy() + }) + + it('passes the specific pressed balance to onSelectBalance', () => { + const onSelectBalance = jest.fn() + const { getAllByTestId } = render() + + const rows = getAllByTestId(TestID.NetworkBalanceRow) + fireEvent.press(rows[1]!, ON_PRESS_EVENT_PAYLOAD) + + expect(onSelectBalance).toHaveBeenCalledTimes(1) + expect(onSelectBalance).toHaveBeenCalledWith( + expect.objectContaining({ + currencyInfo: expect.objectContaining({ + currency: expect.objectContaining({ chainId: UniverseChainId.Base }), + }), + }), + ) + }) + + it('renders a single row when only one balance is provided', () => { + const singleBalance = [TEST_BALANCES[0]!] + const { getAllByTestId, queryByText } = render() + + expect(getAllByTestId(TestID.NetworkBalanceRow)).toHaveLength(1) + expect(queryByText('Ethereum')).toBeTruthy() + expect(queryByText('Base')).toBeNull() + }) + + it('sorts balances in descending order by fiat value', () => { + const unsortedBalances = [ + makeBalance({ chainId: UniverseChainId.Base, balanceUSD: 100, quantity: 100 }), + makeBalance({ chainId: UniverseChainId.ArbitrumOne, balanceUSD: 5000, quantity: 5000 }), + makeBalance({ chainId: UniverseChainId.Mainnet, balanceUSD: 500, quantity: 500 }), + ] + const { getAllByText } = render() + + const fiatTexts = getAllByText(/^\$[\d,]+/) + expect(fiatTexts[0]?.props['children']).toBe('$5,000.00') + expect(fiatTexts[1]?.props['children']).toBe('$500.00') + expect(fiatTexts[2]?.props['children']).toBe('$100.00') + }) +}) diff --git a/apps/mobile/src/components/TokenDetails/NetworkBalanceList.tsx b/apps/mobile/src/components/TokenDetails/NetworkBalanceList.tsx new file mode 100644 index 00000000..8e45a037 --- /dev/null +++ b/apps/mobile/src/components/TokenDetails/NetworkBalanceList.tsx @@ -0,0 +1,65 @@ +import { memo, useMemo } from 'react' +import { Flex, Text, TouchableArea } from 'ui/src' +import { borderRadii, iconSizes } from 'ui/src/theme' +import { NetworkLogo } from 'uniswap/src/components/CurrencyLogo/NetworkLogo' +import { getChainInfo } from 'uniswap/src/features/chains/chainInfo' +import { PortfolioBalance } from 'uniswap/src/features/dataApi/types' +import { useLocalizationContext } from 'uniswap/src/features/language/LocalizationContext' +import { TestID } from 'uniswap/src/test/fixtures/testIDs' +import { getSymbolDisplayText } from 'uniswap/src/utils/currency' +import { NumberType } from 'utilities/src/format/types' + +interface NetworkBalanceRowProps { + balance: PortfolioBalance + onSelectBalance: (balance: PortfolioBalance) => void +} + +const NetworkBalanceRow = memo(function NetworkBalanceRow({ + balance, + onSelectBalance, +}: NetworkBalanceRowProps): JSX.Element { + const { convertFiatAmountFormatted, formatNumberOrString } = useLocalizationContext() + const { chainId } = balance.currencyInfo.currency + const chainName = getChainInfo(chainId).label + const fiatValue = convertFiatAmountFormatted(balance.balanceUSD, NumberType.FiatTokenDetails) + const tokenAmount = `${formatNumberOrString({ value: balance.quantity, type: NumberType.TokenNonTx })} ${getSymbolDisplayText(balance.currencyInfo.currency.symbol)}` + + return ( + onSelectBalance(balance)}> + + + + {chainName} + + + + {fiatValue} + + + {tokenAmount} + + + + + ) +}) + +interface NetworkBalanceListProps { + balances: PortfolioBalance[] + onSelectBalance: (balance: PortfolioBalance) => void +} + +export function NetworkBalanceList({ balances, onSelectBalance }: NetworkBalanceListProps): JSX.Element { + const sortedBalances = useMemo( + () => [...balances].sort((a, b) => (b.balanceUSD ?? 0) - (a.balanceUSD ?? 0)), + [balances], + ) + + return ( + + {sortedBalances.map((balance) => ( + + ))} + + ) +} diff --git a/apps/mobile/src/components/TokenDetails/ReceiveButton.tsx b/apps/mobile/src/components/TokenDetails/ReceiveButton.tsx new file mode 100644 index 00000000..04e85bbc --- /dev/null +++ b/apps/mobile/src/components/TokenDetails/ReceiveButton.tsx @@ -0,0 +1,27 @@ +import { useTranslation } from 'react-i18next' +import { useOpenReceiveModal } from 'src/features/modals/hooks/useOpenReceiveModal' +import { Button, Flex } from 'ui/src' +import { ElementName } from 'uniswap/src/features/telemetry/constants' +import Trace from 'uniswap/src/features/telemetry/Trace' + +export function ReceiveButton({ onPress }: { onPress: () => void }): JSX.Element { + const { t } = useTranslation() + const openReceiveModal = useOpenReceiveModal() + + return ( + + + + + + ) +} diff --git a/apps/mobile/src/components/TokenDetails/TokenBalances.tsx b/apps/mobile/src/components/TokenDetails/TokenBalances.tsx new file mode 100644 index 00000000..77c53bd4 --- /dev/null +++ b/apps/mobile/src/components/TokenDetails/TokenBalances.tsx @@ -0,0 +1,159 @@ +import React, { useCallback } from 'react' +import { useTranslation } from 'react-i18next' +import { useTokenDetailsNavigation } from 'src/components/TokenDetails/hooks' +import { Flex, Separator, Text, TouchableArea } from 'ui/src' +import { iconSizes } from 'ui/src/theme' +import { TokenLogo } from 'uniswap/src/components/CurrencyLogo/TokenLogo' +import { InlineNetworkPill } from 'uniswap/src/components/network/NetworkPill' +import { AccountType } from 'uniswap/src/features/accounts/types' +import { useEnabledChains } from 'uniswap/src/features/chains/hooks/useEnabledChains' +import { PortfolioBalance } from 'uniswap/src/features/dataApi/types' +import { useLocalizationContext } from 'uniswap/src/features/language/LocalizationContext' +import { MobileEventName } from 'uniswap/src/features/telemetry/constants' +import Trace from 'uniswap/src/features/telemetry/Trace' +import { CurrencyId } from 'uniswap/src/types/currency' +import { getSymbolDisplayText } from 'uniswap/src/utils/currency' +import { NumberType } from 'utilities/src/format/types' +import { useActiveAccount, useDisplayName } from 'wallet/src/features/wallet/hooks' + +/** + * Renders token balances for current chain (if any) and other chains (if any). + * If user has no balance at all, it renders nothing. + */ +export function TokenBalances({ + currentChainBalance, + otherChainBalances, +}: { + currentChainBalance: PortfolioBalance | null + otherChainBalances: PortfolioBalance[] | null +}): JSX.Element | null { + const { t } = useTranslation() + + const activeAccount = useActiveAccount() + const accountType = activeAccount?.type + const displayName = useDisplayName(activeAccount?.address, { includeUnitagSuffix: true })?.name + const isReadonly = accountType === AccountType.Readonly + + const hasCurrentChainBalances = Boolean(currentChainBalance) + const hasOtherChainBalances = Boolean(otherChainBalances && otherChainBalances.length > 0) + + const { preload, navigateWithPop } = useTokenDetailsNavigation() + const navigateToCurrency = useCallback( + (currencyId: CurrencyId) => { + preload(currencyId) + navigateWithPop(currencyId) + }, + [navigateWithPop, preload], + ) + + if (!hasCurrentChainBalances && !hasOtherChainBalances) { + return null + } + + return ( + + {currentChainBalance && ( + + + + + )} + {hasOtherChainBalances && otherChainBalances ? ( + + + {t('token.balances.other')} + + + {otherChainBalances.map((balance) => { + return ( + + ) + })} + + + ) : null} + + ) +} + +function CurrentChainBalance({ + balance, + isReadonly, + displayName, +}: { + balance: PortfolioBalance + isReadonly: boolean + displayName?: string +}): JSX.Element { + const { t } = useTranslation() + const { convertFiatAmountFormatted, formatNumberOrString } = useLocalizationContext() + const { isTestnetModeEnabled } = useEnabledChains() + + const fiatBalance = convertFiatAmountFormatted(balance.balanceUSD, NumberType.FiatTokenDetails) + const tokenBalance = `${formatNumberOrString({ value: balance.quantity, type: NumberType.TokenNonTx })} ${getSymbolDisplayText(balance.currencyInfo.currency.symbol)}` + return ( + + + + {isReadonly ? t('token.balances.viewOnly', { ownerAddress: displayName ?? '' }) : t('token.balances.main')} + + + {isTestnetModeEnabled ? tokenBalance : fiatBalance} + + {!isTestnetModeEnabled && tokenBalance} + + + + + ) +} + +function OtherChainBalance({ + balance, + navigate, +}: { + balance: PortfolioBalance + navigate: (currencyId: CurrencyId) => void +}): JSX.Element { + const { convertFiatAmountFormatted, formatNumberOrString } = useLocalizationContext() + + return ( + + navigate(balance.currencyInfo.currencyId)}> + + + + + + {convertFiatAmountFormatted(balance.balanceUSD, NumberType.FiatTokenDetails)} + + + + + + {formatNumberOrString({ + value: balance.quantity, + type: NumberType.TokenNonTx, + })}{' '} + {getSymbolDisplayText(balance.currencyInfo.currency.symbol)} + + + + + ) +} diff --git a/apps/mobile/src/components/TokenDetails/TokenDetailsActionButtons.test.tsx b/apps/mobile/src/components/TokenDetails/TokenDetailsActionButtons.test.tsx new file mode 100644 index 00000000..a312fd57 --- /dev/null +++ b/apps/mobile/src/components/TokenDetails/TokenDetailsActionButtons.test.tsx @@ -0,0 +1,136 @@ +import { fireEvent } from '@testing-library/react-native' +import React from 'react' +import { + TokenDetailsBuySellButtons, + TokenDetailsSwapButtons, +} from 'src/components/TokenDetails/TokenDetailsActionButtons' +import { render } from 'src/test/test-utils' +import { ON_PRESS_EVENT_PAYLOAD } from 'uniswap/src/test/fixtures' +import { TestID } from 'uniswap/src/test/fixtures/testIDs' + +jest.mock('@universe/gating', () => ({ + ...jest.requireActual('@universe/gating'), + useFeatureFlag: jest.fn().mockReturnValue(false), + useFeatureFlagWithLoading: jest.fn().mockReturnValue({ value: false, isLoading: false }), + useFeatureFlagWithExposureLoggingDisabled: jest.fn().mockReturnValue(false), +})) + +jest.mock('src/components/TokenDetails/TokenDetailsContext', () => ({ + useTokenDetailsContext: jest.fn().mockReturnValue({ + currencyInfo: { + currency: { isToken: false }, + safetyInfo: { tokenList: 'default' }, + }, + isChainEnabled: true, + tokenColor: '#627EEA', + }), +})) + +describe('TokenDetailsSwapButtons', () => { + const defaultProps = { + ctaButton: { title: 'Swap', onPress: jest.fn() }, + userHasBalance: false, + actionMenuOptions: [], + onPressDisabled: jest.fn(), + } + + beforeEach(() => jest.clearAllMocks()) + + it('should render the CTA button', () => { + const { getByTestId } = render() + + expect(getByTestId(TestID.TokenDetailsSwapButton)).toBeTruthy() + }) + + it('should call ctaButton.onPress when CTA is tapped', () => { + const { getByTestId } = render() + + fireEvent.press(getByTestId(TestID.TokenDetailsSwapButton), ON_PRESS_EVENT_PAYLOAD) + + expect(defaultProps.ctaButton.onPress).toHaveBeenCalledTimes(1) + }) + + it('should show action menu when user has balance', () => { + const { getByTestId } = render() + + expect(getByTestId(TestID.TokenDetailsActionButton)).toBeTruthy() + }) + + it('should hide action menu when user has no balance', () => { + const { queryByTestId } = render() + + expect(queryByTestId(TestID.TokenDetailsActionButton)).toBeNull() + }) +}) + +describe('TokenDetailsBuySellButtons', () => { + const defaultProps = { + userHasBalance: false, + actionMenuOptions: [], + onPressDisabled: jest.fn(), + onPressBuy: jest.fn(), + onPressSell: jest.fn(), + } + + beforeEach(() => jest.clearAllMocks()) + + it('should always render the Buy button', () => { + const { getByTestId } = render() + + expect(getByTestId(TestID.TokenDetailsBuyButton)).toBeTruthy() + }) + + it('should call onPressBuy when Buy is tapped', () => { + const { getByTestId } = render() + + fireEvent.press(getByTestId(TestID.TokenDetailsBuyButton), ON_PRESS_EVENT_PAYLOAD) + + expect(defaultProps.onPressBuy).toHaveBeenCalledTimes(1) + }) + + it('should show Sell button when user has balance', () => { + const { getByTestId } = render() + + expect(getByTestId(TestID.TokenDetailsSellButton)).toBeTruthy() + }) + + it('should hide Sell button when user has no balance', () => { + const { queryByTestId } = render() + + expect(queryByTestId(TestID.TokenDetailsSellButton)).toBeNull() + }) + + it('should call onPressSell when Sell is tapped', () => { + const { getByTestId } = render() + + fireEvent.press(getByTestId(TestID.TokenDetailsSellButton), ON_PRESS_EVENT_PAYLOAD) + + expect(defaultProps.onPressSell).toHaveBeenCalledTimes(1) + }) + + it('should render custom buyButtonTitle when provided', () => { + const { getByText } = render() + + expect(getByText('Buy with cash')).toBeTruthy() + }) + + it('should render default "Buy" title when buyButtonTitle is not provided', () => { + const { getByText } = render() + + expect(getByText('Buy')).toBeTruthy() + }) + + it('should show action menu when using default Buy title', () => { + const { getByTestId } = render() + + expect(getByTestId(TestID.TokenDetailsActionButton)).toBeTruthy() + }) + + it('should hide action menu when buyButtonTitle is set', () => { + const { queryByTestId } = render( + , + ) + + expect(queryByTestId(TestID.TokenDetailsActionButton)).toBeNull() + }) +}) diff --git a/apps/mobile/src/components/TokenDetails/TokenDetailsActionButtons.tsx b/apps/mobile/src/components/TokenDetails/TokenDetailsActionButtons.tsx new file mode 100644 index 00000000..1aa64bcb --- /dev/null +++ b/apps/mobile/src/components/TokenDetails/TokenDetailsActionButtons.tsx @@ -0,0 +1,271 @@ +import React, { useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { useTokenDetailsContext } from 'src/components/TokenDetails/TokenDetailsContext' +import { Button, ColorTokens, Flex, GeneratedIcon, getContrastPassingTextColor } from 'ui/src' +import { IconButton } from 'ui/src/components/buttons/IconButton/IconButton' +import { GridView, X } from 'ui/src/components/icons' +import { opacify, validColor } from 'ui/src/theme' +import { ContextMenu, MenuOptionItem } from 'uniswap/src/components/menus/ContextMenu' +import { ContextMenuTriggerMode } from 'uniswap/src/components/menus/types' +import { TokenList } from 'uniswap/src/features/dataApi/types' +import { ElementName, MobileEventName, SectionName } from 'uniswap/src/features/telemetry/constants' +import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import Trace from 'uniswap/src/features/telemetry/Trace' +import { TestID, TestIDType } from 'uniswap/src/test/fixtures/testIDs' +import { useBooleanState } from 'utilities/src/react/useBooleanState' + +function CTAButton({ + title, + element, + onPress, + onPressDisabled, + testID, + tokenColor, + disabled, + icon: Icon, +}: { + title: string + element: ElementName + onPress: () => void + onPressDisabled?: () => void + testID?: TestIDType + tokenColor?: string | null + disabled?: boolean + icon?: GeneratedIcon +}): JSX.Element { + return ( + + + + ) +} + +interface ActionButtonState { + tokenColor: string | null + disabled: boolean + validTokenColor: ColorTokens | undefined + lightTokenColor: ColorTokens | undefined + actionsWithIcons: MenuOptionItem[] + actionMenuOpen: boolean + closeActionMenu: () => void + toggleActionMenu: () => void +} + +function useActionButtonState(actionMenuOptions: MenuOptionItem[]): ActionButtonState { + const { currencyInfo, isChainEnabled, tokenColor } = useTokenDetailsContext() + const { value: actionMenuOpen, setFalse: closeActionMenu, toggle: toggleActionMenu } = useBooleanState(false) + + const isBlocked = currencyInfo?.safetyInfo?.tokenList === TokenList.Blocked + const disabled = isBlocked || !isChainEnabled + + const validTokenColor = validColor(tokenColor) + const lightTokenColor = validTokenColor ? opacify(12, validTokenColor) : undefined + + const actionsWithIcons = useMemo(() => { + return actionMenuOptions.map( + (action): MenuOptionItem => ({ + ...action, + iconColor: tokenColor, + }), + ) + }, [actionMenuOptions, tokenColor]) + + return { + tokenColor, + disabled, + validTokenColor, + lightTokenColor, + actionsWithIcons, + actionMenuOpen, + closeActionMenu, + toggleActionMenu, + } +} + +/** Single contextual CTA (Swap/Buy/Get) with an overflow action menu */ +export function TokenDetailsSwapButtons({ + ctaButton, + userHasBalance, + actionMenuOptions, + onPressDisabled, +}: { + ctaButton: { + title: string + icon?: GeneratedIcon + onPress: () => void + } + userHasBalance: boolean + actionMenuOptions: MenuOptionItem[] + onPressDisabled?: () => void +}): JSX.Element { + const { + tokenColor, + disabled, + validTokenColor, + lightTokenColor, + actionsWithIcons, + actionMenuOpen, + closeActionMenu, + toggleActionMenu, + } = useActionButtonState(actionMenuOptions) + + return ( + + + + {userHasBalance && !disabled && ( + { + sendAnalyticsEvent(MobileEventName.TokenDetailsContextMenuAction, { + action: e.name, + }) + }} + > + + : } + size="large" + testID={TestID.TokenDetailsActionButton} + onPress={toggleActionMenu} + /> + + + )} + + + ) +} + +/** Dedicated Buy and Sell CTAs with a secondary action menu */ +export function TokenDetailsBuySellButtons({ + userHasBalance, + actionMenuOptions, + buyButtonTitle, + buyButtonIcon, + onPressDisabled, + onPressBuy, + onPressSell, +}: { + userHasBalance: boolean + actionMenuOptions: MenuOptionItem[] + buyButtonTitle?: string + buyButtonIcon?: GeneratedIcon + onPressDisabled?: () => void + onPressBuy: () => void + onPressSell: () => void +}): JSX.Element { + const { t } = useTranslation() + const { + tokenColor, + disabled, + validTokenColor, + lightTokenColor, + actionsWithIcons, + actionMenuOpen, + closeActionMenu, + toggleActionMenu, + } = useActionButtonState(actionMenuOptions) + + return ( + + + + {userHasBalance && ( + + )} + {/* buyButtonTitle is only set when hasTokenBalance is false (see useMultichainBuyVariant), + so this condition and userHasBalance are mutually exclusive in practice. */} + {!buyButtonTitle && !disabled && ( + { + sendAnalyticsEvent(MobileEventName.TokenDetailsContextMenuAction, { + action: e.name, + }) + }} + > + + : } + size="large" + testID={TestID.TokenDetailsActionButton} + onPress={toggleActionMenu} + /> + + + )} + + + ) +} diff --git a/apps/mobile/src/components/TokenDetails/TokenDetailsBridgedAssetSection.tsx b/apps/mobile/src/components/TokenDetails/TokenDetailsBridgedAssetSection.tsx new file mode 100644 index 00000000..cd3aecd6 --- /dev/null +++ b/apps/mobile/src/components/TokenDetails/TokenDetailsBridgedAssetSection.tsx @@ -0,0 +1,36 @@ +import { navigate } from 'src/app/navigation/rootNavigation' +import { useTokenDetailsContext } from 'src/components/TokenDetails/TokenDetailsContext' +import { BridgedAssetTDPSection } from 'uniswap/src/components/BridgedAsset/BridgedAssetTDPSection' +import { ModalName } from 'uniswap/src/features/telemetry/constants' +import { useCurrencyInfo } from 'uniswap/src/features/tokens/useCurrencyInfo' +import { CurrencyField } from 'uniswap/src/types/currency' +import { useEvent } from 'utilities/src/react/hooks' +import { useWalletNavigation } from 'wallet/src/contexts/WalletNavigationContext' + +export function TokenDetailsBridgedAssetSection(): JSX.Element | null { + const { currencyId, chainId, address } = useTokenDetailsContext() + const currencyInfo = useCurrencyInfo(currencyId) + const { navigateToSwapFlow } = useWalletNavigation() + const handlePress = useEvent(() => { + if (!currencyInfo) { + return + } + navigate(ModalName.BridgedAsset, { + currencyInfo0: currencyInfo, + onContinue: () => { + navigateToSwapFlow({ + currencyField: CurrencyField.OUTPUT, + currencyAddress: address, + currencyChainId: chainId, + origin: ModalName.BridgedAsset, + }) + }, + }) + }) + const isBridgedAsset = Boolean(currencyInfo?.isBridged) + if (!isBridgedAsset || !currencyInfo) { + return null + } + + return +} diff --git a/apps/mobile/src/components/TokenDetails/TokenDetailsContext.tsx b/apps/mobile/src/components/TokenDetails/TokenDetailsContext.tsx new file mode 100644 index 00000000..e78039e8 --- /dev/null +++ b/apps/mobile/src/components/TokenDetails/TokenDetailsContext.tsx @@ -0,0 +1,149 @@ +import { NativeStackNavigationProp } from '@react-navigation/native-stack' +import { createContext, PropsWithChildren, useCallback, useContext, useMemo, useState } from 'react' +import { useDispatch } from 'react-redux' +import { AppStackParamList } from 'src/app/navigation/types' +import { useTokenDetailsColors } from 'src/components/TokenDetails/useTokenDetailsColors' +import { setHasViewedContractAddressExplainer } from 'uniswap/src/features/behaviorHistory/slice' +import { useEnabledChains } from 'uniswap/src/features/chains/hooks/useEnabledChains' +import { UniverseChainId } from 'uniswap/src/features/chains/types' +import { CurrencyInfo } from 'uniswap/src/features/dataApi/types' +import { pushNotification } from 'uniswap/src/features/notifications/slice/slice' +import { AppNotificationType, CopyNotificationType } from 'uniswap/src/features/notifications/slice/types' +import { useCurrencyInfo } from 'uniswap/src/features/tokens/useCurrencyInfo' +import { CurrencyField } from 'uniswap/src/types/currency' +import { MobileScreens } from 'uniswap/src/types/screens/mobile' +import { currencyIdToAddress, currencyIdToChain } from 'uniswap/src/utils/currencyId' +import { setClipboard } from 'utilities/src/clipboard/clipboard' + +type TokenDetailsContextState = { + currencyId: string + navigation: NativeStackNavigationProp + address: Address + chainId: UniverseChainId + currencyInfo?: CurrencyInfo + tokenColor: string | null + tokenColorLoading: boolean + isChainEnabled: boolean + activeTransactionType?: CurrencyField + setActiveTransactionType: (activeTransactionType: CurrencyField | undefined) => void + isTokenWarningModalOpen: boolean + openTokenWarningModal: () => void + closeTokenWarningModal: () => void + isContractAddressExplainerModalOpen: boolean + openContractAddressExplainerModal: () => void + closeContractAddressExplainerModal: (markViewed: boolean) => void + copyAddressToClipboard: (address: string) => Promise + error: unknown | undefined + setError: (error: unknown | undefined) => void +} + +const TokenDetailsContext = createContext(undefined) + +export function TokenDetailsContextProvider({ + children, + currencyId, + navigation, +}: PropsWithChildren>): JSX.Element { + const dispatch = useDispatch() + + const [error, setError] = useState(undefined) + + const [isTokenWarningModalOpen, setIsTokenWarningModalOpen] = useState(false) + const openTokenWarningModal = useCallback(() => setIsTokenWarningModalOpen(true), []) + const closeTokenWarningModal = useCallback(() => setIsTokenWarningModalOpen(false), []) + + const [isContractAddressExplainerModalOpen, setIsContractAddressExplainerModalOpen] = useState(false) + const openContractAddressExplainerModal = useCallback(() => setIsContractAddressExplainerModalOpen(true), []) + const closeContractAddressExplainerModal = useCallback( + (markViewed: boolean) => { + if (markViewed) { + dispatch(setHasViewedContractAddressExplainer(true)) + } + setIsContractAddressExplainerModalOpen(false) + }, + [dispatch], + ) + + const copyAddressToClipboard = useCallback( + async (address: string): Promise => { + await setClipboard(address) + dispatch( + pushNotification({ + type: AppNotificationType.Copied, + copyType: CopyNotificationType.ContractAddress, + }), + ) + }, + [dispatch], + ) + + // Set if attempting to buy or sell, used for token warning modal. + const [activeTransactionType, setActiveTransactionType] = useState(undefined) + + const currencyInfo = useCurrencyInfo(currencyId) ?? undefined + + const { tokenColor, tokenColorLoading } = useTokenDetailsColors({ currencyId }) + + const { chains: enabledChains } = useEnabledChains() + + const state = useMemo((): TokenDetailsContextState => { + const chainId = currencyIdToChain(currencyId) + const address = currencyIdToAddress(currencyId) + + if (!chainId) { + throw new Error(`Unable to find chainId for currencyId: ${currencyId}`) + } + + const isChainEnabled = !!enabledChains.find((_chainId) => _chainId === chainId) + + return { + currencyId, + navigation, + address, + chainId, + currencyInfo, + tokenColor, + tokenColorLoading, + isChainEnabled, + activeTransactionType, + setActiveTransactionType, + isTokenWarningModalOpen, + openTokenWarningModal, + closeTokenWarningModal, + isContractAddressExplainerModalOpen, + openContractAddressExplainerModal, + closeContractAddressExplainerModal, + copyAddressToClipboard, + error, + setError, + } + }, [ + activeTransactionType, + closeTokenWarningModal, + closeContractAddressExplainerModal, + currencyId, + currencyInfo, + enabledChains, + error, + isContractAddressExplainerModalOpen, + isTokenWarningModalOpen, + navigation, + openContractAddressExplainerModal, + openTokenWarningModal, + tokenColor, + tokenColorLoading, + copyAddressToClipboard, + ]) + + return {children} +} + +export const useTokenDetailsContext = (): TokenDetailsContextState => { + const context = useContext(TokenDetailsContext) + + if (context === undefined) { + throw new Error('`useTokenDetailsContext` must be used inside of `TokenDetailsContextProvider`') + } + + return context +} diff --git a/apps/mobile/src/components/TokenDetails/TokenDetailsFavoriteButton.tsx b/apps/mobile/src/components/TokenDetails/TokenDetailsFavoriteButton.tsx new file mode 100644 index 00000000..7c530883 --- /dev/null +++ b/apps/mobile/src/components/TokenDetails/TokenDetailsFavoriteButton.tsx @@ -0,0 +1,29 @@ +import React from 'react' +import { useSelector } from 'react-redux' +import { Favorite } from 'src/components/icons/Favorite' +import { TouchableArea } from 'ui/src' +import { iconSizes } from 'ui/src/theme' +import { selectFavoriteTokens } from 'uniswap/src/features/favorites/selectors' +import { useToggleFavoriteCallback } from 'uniswap/src/features/favorites/useToggleFavoriteCallback' +import { TestID } from 'uniswap/src/test/fixtures/testIDs' + +export function TokenDetailsFavoriteButton({ + currencyId, + tokenName, +}: { + currencyId: string + tokenName?: string +}): JSX.Element { + const id = currencyId.toLowerCase() + const isFavoriteToken = useSelector(selectFavoriteTokens).indexOf(id) !== -1 + const onFavoritePress = useToggleFavoriteCallback({ id, tokenName, isFavoriteToken }) + return ( + + + + ) +} diff --git a/apps/mobile/src/components/TokenDetails/TokenDetailsHeader.tsx b/apps/mobile/src/components/TokenDetails/TokenDetailsHeader.tsx new file mode 100644 index 00000000..eb55793b --- /dev/null +++ b/apps/mobile/src/components/TokenDetails/TokenDetailsHeader.tsx @@ -0,0 +1,77 @@ +import React, { memo } from 'react' +import { useSelector } from 'react-redux' +import { useTokenDetailsContext } from 'src/components/TokenDetails/TokenDetailsContext' +import { EM_DASH, Flex, flexStyles, Text, TouchableArea } from 'ui/src' +import { CopyAlt } from 'ui/src/components/icons' +import { iconSizes } from 'ui/src/theme' +import { TokenLogo } from 'uniswap/src/components/CurrencyLogo/TokenLogo' +import { + useTokenBasicInfoPartsFragment, + useTokenBasicProjectPartsFragment, +} from 'uniswap/src/data/graphql/uniswap-data-api/fragments' +import { selectHasViewedContractAddressExplainer } from 'uniswap/src/features/behaviorHistory/selectors' +import { fromGraphQLChain } from 'uniswap/src/features/chains/utils' +import { TestID } from 'uniswap/src/test/fixtures/testIDs' + +export const TokenDetailsHeader = memo(function TokenDetailsHeaderInner(): JSX.Element { + const { currencyId, openContractAddressExplainerModal, copyAddressToClipboard } = useTokenDetailsContext() + const hasViewedContractAddressExplainer = useSelector(selectHasViewedContractAddressExplainer) + + const token = useTokenBasicInfoPartsFragment({ currencyId }).data + const project = useTokenBasicProjectPartsFragment({ currencyId }).data.project + + const handleCopyAddress = async (): Promise => { + if (!token.address) { + return + } + + if (!hasViewedContractAddressExplainer) { + openContractAddressExplainerModal() + return + } + + await copyAddressToClipboard(token.address) + } + + return ( + + + + + + {token.name || EM_DASH} + + + + {token.symbol || EM_DASH} + + {token.address && } + + + + ) +}) diff --git a/apps/mobile/src/components/TokenDetails/TokenDetailsLinks.tsx b/apps/mobile/src/components/TokenDetails/TokenDetailsLinks.tsx new file mode 100644 index 00000000..9924192c --- /dev/null +++ b/apps/mobile/src/components/TokenDetails/TokenDetailsLinks.tsx @@ -0,0 +1,216 @@ +import { BottomSheetScrollView } from '@gorhom/bottom-sheet' +import { GraphQLApi } from '@universe/api' +import { FeatureFlags, useFeatureFlag } from '@universe/gating' +import { useCallback, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useWindowDimensions } from 'react-native' +import { FlatList } from 'react-native-gesture-handler' +import { LinkButton, type LinkButtonProps, LinkButtonType } from 'src/components/TokenDetails/LinkButton' +import { useTokenDetailsContext } from 'src/components/TokenDetails/TokenDetailsContext' +import { Flex, Text } from 'ui/src' +import { BlockExplorer, GlobeFilled, Page, XTwitter } from 'ui/src/components/icons' +import { spacing } from 'ui/src/theme' +import { getBlockExplorerIcon } from 'uniswap/src/components/chains/BlockExplorerIcon' +import { Modal } from 'uniswap/src/components/modals/Modal' +import { MultichainAddressSheet } from 'uniswap/src/components/MultichainTokenDetails/MultichainAddressSheet' +import { MultichainExplorerList } from 'uniswap/src/components/MultichainTokenDetails/MultichainExplorerList' +import type { MultichainTokenEntry } from 'uniswap/src/components/MultichainTokenDetails/useOrderedMultichainEntries' +import { useOrderedMultichainEntries } from 'uniswap/src/components/MultichainTokenDetails/useOrderedMultichainEntries' +import { useTokenProjectUrlsPartsFragment } from 'uniswap/src/data/graphql/uniswap-data-api/fragments' +import { getChainInfo } from 'uniswap/src/features/chains/chainInfo' +import { fromGraphQLChain } from 'uniswap/src/features/chains/utils' +import { currencyIdToContractInput } from 'uniswap/src/features/dataApi/utils/currencyIdToContractInput' +import { chainIdToPlatform } from 'uniswap/src/features/platforms/utils/chains' +import { ElementName, ModalName } from 'uniswap/src/features/telemetry/constants' +import { TestID } from 'uniswap/src/test/fixtures/testIDs' +import { isDefaultNativeAddress, isNativeCurrencyAddress } from 'uniswap/src/utils/currencyId' +import { ExplorerDataType, getExplorerLink, getTwitterLink, openUri } from 'uniswap/src/utils/linking' + +const MIN_SHEET_HEIGHT = 520 +const INITIAL_SNAP_PERCENT = 0.65 + +const SCROLL_CONTENT_STYLE = { paddingHorizontal: spacing.spacing24 } + +const ListHeaderSpacer = (): JSX.Element => +const ItemSeparatorComponent = (): JSX.Element => + +const renderItem = ({ item }: { item: LinkButtonProps }): JSX.Element => + +const keyExtractor = (item: LinkButtonProps): string => item.testID ?? item.label + +/** Fetches cross-chain token data and returns entries ordered by network selector order. */ +function useMultichainTokenEntries(currencyId: string): MultichainTokenEntry[] { + const contractInput = useMemo(() => currencyIdToContractInput(currencyId), [currencyId]) + const { data } = GraphQLApi.useTokenProjectsQuery({ + variables: { contracts: [contractInput] }, + }) + + const entries = useMemo(() => { + const tokens = data?.tokenProjects?.[0]?.tokens + if (!tokens) { + return [] + } + const result: MultichainTokenEntry[] = [] + for (const token of tokens) { + const chainId = fromGraphQLChain(token.chain) + if (chainId && token.address) { + result.push({ chainId, address: token.address, isNative: false }) + } + } + return result + }, [data]) + + return useOrderedMultichainEntries(entries) +} + +export function TokenDetailsLinks(): JSX.Element { + const { t } = useTranslation() + + const { address, chainId, currencyId } = useTokenDetailsContext() + + const isMultichainTokenUx = useFeatureFlag(FeatureFlags.MultichainTokenUx) + const multichainEntries = useMultichainTokenEntries(currencyId) + const hasMultipleChains = multichainEntries.length > 1 + + const { height: screenHeight } = useWindowDimensions() + const multichainSnapPoints = useMemo(() => { + const percentHeight = INITIAL_SNAP_PERCENT * screenHeight + const initialSnap = Math.min(Math.max(percentHeight, MIN_SHEET_HEIGHT), screenHeight) + return [initialSnap, '100%'] + }, [screenHeight]) + + const { homepageUrl, twitterName } = useTokenProjectUrlsPartsFragment({ currencyId }).data.project ?? {} + + const explorerLink = getExplorerLink({ chainId, data: address, type: ExplorerDataType.TOKEN }) + const explorerName = getChainInfo(chainId).explorer.name + + const isNativeCurrency = isNativeCurrencyAddress(chainId, address) + + const [isExplorerSheetOpen, setIsExplorerSheetOpen] = useState(false) + const [isAddressSheetOpen, setIsAddressSheetOpen] = useState(false) + + const handleExplorerPress = useCallback(async (url: string) => { + await openUri({ uri: url }) + setIsExplorerSheetOpen(false) + }, []) + + const links = useMemo((): LinkButtonProps[] => { + const showMultichainDropdowns = isMultichainTokenUx && hasMultipleChains + const isNativeAddress = isDefaultNativeAddress({ address, platform: chainIdToPlatform(chainId) }) + const items: LinkButtonProps[] = [] + + if (!isNativeAddress) { + if (showMultichainDropdowns) { + items.push({ + Icon: Page, + element: ElementName.MultichainAddress, + label: t('common.address'), + testID: TestID.MultichainAddressDropdown, + onPress: () => setIsAddressSheetOpen(true), + }) + } else { + items.push({ + buttonType: LinkButtonType.Copy, + element: ElementName.Copy, + label: t('common.text.contract'), + testID: TestID.TokenLinkCopy, + value: address, + }) + } + } + + if (!isNativeCurrency) { + if (showMultichainDropdowns) { + items.push({ + Icon: BlockExplorer, + element: ElementName.MultichainExplorer, + label: t('common.explorer'), + testID: TestID.MultichainExplorerDropdown, + onPress: () => setIsExplorerSheetOpen(true), + }) + } else { + items.push({ + Icon: getBlockExplorerIcon(chainId), + buttonType: LinkButtonType.Link, + element: ElementName.TokenLinkEtherscan, + label: explorerName, + testID: TestID.TokenLinkEtherscan, + value: explorerLink, + }) + } + } + + if (homepageUrl) { + items.push({ + Icon: GlobeFilled, + buttonType: LinkButtonType.Link, + element: ElementName.TokenLinkWebsite, + label: t('token.links.website'), + testID: TestID.TokenLinkWebsite, + value: homepageUrl, + }) + } + + if (twitterName) { + items.push({ + Icon: XTwitter, + buttonType: LinkButtonType.Link, + element: ElementName.TokenLinkTwitter, + label: t('token.links.twitter'), + testID: TestID.TokenLinkTwitter, + value: getTwitterLink(twitterName), + }) + } + + return items + }, [ + chainId, + address, + isNativeCurrency, + isMultichainTokenUx, + hasMultipleChains, + homepageUrl, + twitterName, + explorerName, + explorerLink, + t, + ]) + + return ( + + + {t('token.links.title')} + + + + {isExplorerSheetOpen && ( + setIsExplorerSheetOpen(false)} + > + + + + + )} + + setIsAddressSheetOpen(false)} + /> + + ) +} diff --git a/apps/mobile/src/components/TokenDetails/TokenDetailsStats.tsx b/apps/mobile/src/components/TokenDetails/TokenDetailsStats.tsx new file mode 100644 index 00000000..8cb1cbd4 --- /dev/null +++ b/apps/mobile/src/components/TokenDetails/TokenDetailsStats.tsx @@ -0,0 +1,255 @@ +import { GraphQLApi } from '@universe/api' +import React, { memo, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import Animated, { FadeIn, FadeOut } from 'react-native-reanimated' +import { LongText } from 'src/components/text/LongText' +import { useTokenDetailsContext } from 'src/components/TokenDetails/TokenDetailsContext' +import { Flex, Text, TouchableArea, useSporeColors } from 'ui/src' +import { ChartBar, ChartPie, ChartPyramid, Language as LanguageIcon, TrendDown, TrendUp } from 'ui/src/components/icons' +import { InfoCircleFilled } from 'ui/src/components/icons/InfoCircleFilled' +import { DEP_accentColors, validColor } from 'ui/src/theme' +import { WarningSeverity } from 'uniswap/src/components/modals/WarningModal/types' +import { WarningModal } from 'uniswap/src/components/modals/WarningModal/WarningModal' +import { + useTokenBasicInfoPartsFragment, + useTokenBasicProjectPartsFragment, +} from 'uniswap/src/data/graphql/uniswap-data-api/fragments' +import { UniverseChainId } from 'uniswap/src/features/chains/types' +import { useTokenMarketStats } from 'uniswap/src/features/dataApi/tokenDetails/useTokenDetailsData' +import { currencyIdToContractInput } from 'uniswap/src/features/dataApi/utils/currencyIdToContractInput' +import { Language } from 'uniswap/src/features/language/constants' +import { useCurrentLanguage, useCurrentLanguageInfo } from 'uniswap/src/features/language/hooks' +import { useLocalizationContext } from 'uniswap/src/features/language/LocalizationContext' +import { ModalName } from 'uniswap/src/features/telemetry/constants' +import { TestID } from 'uniswap/src/test/fixtures/testIDs' +import { NumberType } from 'utilities/src/format/types' + +const StatsRow = memo(function StatsRowInner({ + label, + children, + statsIcon, + labelAfter, +}: { + label: string + children: JSX.Element + statsIcon: JSX.Element + labelAfter?: JSX.Element +}): JSX.Element { + return ( + + + {statsIcon} + + + {label} + + {labelAfter} + + + {children} + + ) +}) + +const TokenDetailsMarketData = memo(function TokenDetailsMarketDataInner(): JSX.Element { + const { t } = useTranslation() + const colors = useSporeColors() + const defaultTokenColor = colors.neutral3.get() + const { convertFiatAmountFormatted } = useLocalizationContext() + + const { currencyId, chainId, tokenColor } = useTokenDetailsContext() + const [showVolumeInfo, setShowVolumeInfo] = useState(false) + + // Use shared hook for unified data fetching (CoinGecko-first strategy) + const { marketCap, fdv, volume, high52w, low52w } = useTokenMarketStats(currencyId) + + const hasLimitedVolumeData = chainId === UniverseChainId.Tempo + + const maybeLimitedVolumeDataInfoIcon = useMemo(() => { + return hasLimitedVolumeData ? ( + setShowVolumeInfo(true)}> + + + ) : undefined + }, [hasLimitedVolumeData]) + + return ( + + } + > + + {convertFiatAmountFormatted(marketCap, NumberType.FiatTokenStats)} + + + + } + > + + {convertFiatAmountFormatted(fdv, NumberType.FiatTokenStats)} + + + + } + labelAfter={maybeLimitedVolumeDataInfoIcon} + > + + {convertFiatAmountFormatted(volume, NumberType.FiatTokenStats)} + + + + {hasLimitedVolumeData && ( + + {t('stats.volume.1d.description.tempo')} + + } + icon={} + backgroundIconColor={colors.surface2.get()} + modalName={ModalName.VolumeInfo} + rejectText={t('common.button.close')} + severity={WarningSeverity.None} + title={t('stats.volume.1d')} + onClose={(): void => setShowVolumeInfo(false)} + /> + )} + + } + > + + {convertFiatAmountFormatted(high52w, NumberType.FiatTokenDetails)} + + + + } + > + + {convertFiatAmountFormatted(low52w, NumberType.FiatTokenDetails)} + + + + ) +}) + +// oxlint-disable-next-line complexity -- biome-parity: oxlint is stricter here +export const TokenDetailsStats = memo(function TokenDetailsStatsInner(): JSX.Element { + const { t } = useTranslation() + const colors = useSporeColors() + const currentLanguage = useCurrentLanguage() + const currentLanguageInfo = useCurrentLanguageInfo() + + const [showTranslation, setShowTranslation] = useState(false) + + const { currencyId, tokenColor } = useTokenDetailsContext() + + const onChainData = useTokenBasicInfoPartsFragment({ currencyId }).data + const offChainData = useTokenBasicProjectPartsFragment({ currencyId }).data.project + + const language = useCurrentLanguage() + + const descriptions = GraphQLApi.useTokenProjectDescriptionQuery({ + variables: { + ...currencyIdToContractInput(currencyId), + includeSpanish: + language === Language.SpanishSpain || + language === Language.SpanishLatam || + language === Language.SpanishUnitedStates, + includeFrench: language === Language.French, + includeJapanese: language === Language.Japanese, + includePortuguese: language === Language.Portuguese, + includeVietnamese: language === Language.Vietnamese, + includeChineseSimplified: language === Language.ChineseSimplified, + includeChineseTraditional: language === Language.ChineseTraditional, + }, + fetchPolicy: 'cache-and-network', + returnPartialData: true, + }).data?.token?.project + + const description = descriptions?.description + + const translatedDescription = + descriptions?.descriptionTranslations?.descriptionEsEs || + descriptions?.descriptionTranslations?.descriptionFrFr || + descriptions?.descriptionTranslations?.descriptionJaJp || + descriptions?.descriptionTranslations?.descriptionPtPt || + descriptions?.descriptionTranslations?.descriptionViVn || + descriptions?.descriptionTranslations?.descriptionZhHans || + descriptions?.descriptionTranslations?.descriptionZhHant + + const name = offChainData?.name ?? onChainData.name + const currentDescription = showTranslation && translatedDescription ? translatedDescription : description + + return ( + + {currentDescription && ( + + {name && ( + + {t('token.stats.section.about', { token: name })} + + )} + + + + + + {currentLanguage !== Language.English && !!translatedDescription && ( + setShowTranslation(!showTranslation)}> + + {showTranslation ? ( + + + + + {currentLanguageInfo.displayName} + + + + {t('token.stats.translation.original')} + + + ) : ( + + + + + {t('token.stats.translation.translate', { + language: currentLanguageInfo.displayName, + })} + + + + )} + + + )} + + )} + + + + {t('token.stats.title')} + + + + + + ) +}) diff --git a/apps/mobile/src/components/TokenDetails/TokenPerformance.tsx b/apps/mobile/src/components/TokenDetails/TokenPerformance.tsx new file mode 100644 index 00000000..0dc9b65f --- /dev/null +++ b/apps/mobile/src/components/TokenDetails/TokenPerformance.tsx @@ -0,0 +1,50 @@ +import { FeatureFlags, useFeatureFlag } from '@universe/gating' +import { memo } from 'react' +import { useTokenDetailsContext } from 'src/components/TokenDetails/TokenDetailsContext' +import { Flex, Separator } from 'ui/src' +import { TokenProfitLoss } from 'uniswap/src/components/TokenProfitLoss/TokenProfitLoss' +import { useGetWalletTokenProfitLossQuery } from 'uniswap/src/data/rest/getWalletTokenProfitLoss' +import { DEFAULT_NATIVE_ADDRESS } from 'uniswap/src/features/chains/evm/rpc' +import { isStablecoinAddress } from 'uniswap/src/features/chains/utils' +import { isNativeCurrencyAddress } from 'uniswap/src/utils/currencyId' +import { useActiveAddresses } from 'wallet/src/features/accounts/store/hooks' + +export const TokenPerformance = memo(function TokenPerformance(): JSX.Element | null { + const isProfitLossEnabled = useFeatureFlag(FeatureFlags.ProfitLoss) + const { address, chainId } = useTokenDetailsContext() + const { evmAddress, svmAddress } = useActiveAddresses() + + const tokenAddress = isNativeCurrencyAddress(chainId, address) ? DEFAULT_NATIVE_ADDRESS : address + const isStablecoin = isStablecoinAddress(chainId, tokenAddress) + + const { data, isError } = useGetWalletTokenProfitLossQuery({ + input: { + evmAddress, + svmAddress, + chainId, + tokenAddress, + }, + enabled: isProfitLossEnabled && !isStablecoin, + }) + + const profitLoss = data?.profitLoss + + if (!isProfitLossEnabled || !profitLoss || isStablecoin || isError) { + return null + } + + return ( + + + + + ) +}) diff --git a/apps/mobile/src/components/TokenDetails/hooks.test.ts b/apps/mobile/src/components/TokenDetails/hooks.test.ts new file mode 100644 index 00000000..6aac6204 --- /dev/null +++ b/apps/mobile/src/components/TokenDetails/hooks.test.ts @@ -0,0 +1,248 @@ +import { NetworkStatus } from '@apollo/client' +import { useTokenDetailsNavigation } from 'src/components/TokenDetails/hooks' +import { preloadedMobileState } from 'src/test/fixtures' +import { act, renderHook, waitFor } from 'src/test/test-utils' +import { useCrossChainBalances } from 'uniswap/src/data/balances/hooks/useCrossChainBalances' +import { usePortfolioBalances } from 'uniswap/src/features/dataApi/balances/balances' +import { + portfolio, + portfolioBalances, + SAMPLE_CURRENCY_ID_1, + SAMPLE_SEED_ADDRESS_1, + tokenBalance, + usdcArbitrumToken, + usdcBaseToken, +} from 'uniswap/src/test/fixtures' +import { MobileScreens } from 'uniswap/src/types/screens/mobile' +import { portfolioBalancesById } from 'uniswap/src/utils/balances' + +const mockedNavigation = { + navigate: jest.fn(), + canGoBack: jest.fn(), + pop: jest.fn(), + push: jest.fn(), +} + +jest.mock('@react-navigation/native', () => { + const actualNav = jest.requireActual('@react-navigation/native') + return { + ...actualNav, + // oxlint-disable-next-line typescript/explicit-function-return-type + useNavigation: () => mockedNavigation, + } +}) + +jest.mock('uniswap/src/features/dataApi/balances/balances', () => { + const actual = jest.requireActual('uniswap/src/features/dataApi/balances/balances') + const { NetworkStatus: MockNetworkStatus } = jest.requireActual('@apollo/client') + return { + ...actual, + usePortfolioBalances: jest.fn(() => ({ + data: undefined, + loading: false, + networkStatus: MockNetworkStatus.ready, + refetch: jest.fn(), + error: undefined, + })), + } +}) + +const mockUsePortfolioBalances = usePortfolioBalances as jest.MockedFunction + +describe(useCrossChainBalances, () => { + beforeEach(() => { + jest.clearAllMocks() + // Reset mock to default state + mockUsePortfolioBalances.mockReturnValue({ + data: undefined, + loading: false, + networkStatus: NetworkStatus.ready, + refetch: jest.fn(), + error: undefined, + }) + }) + + describe('currentChainBalance', () => { + it('returns null if there are no balances for the specified currency', async () => { + const { result } = renderHook( + () => + useCrossChainBalances({ + evmAddress: SAMPLE_SEED_ADDRESS_1, + currencyId: SAMPLE_CURRENCY_ID_1, + crossChainTokens: null, + }), + { + preloadedState: preloadedMobileState(), + }, + ) + + await act(() => undefined) + + expect(result.current).toEqual( + expect.objectContaining({ + currentChainBalance: null, + }), + ) + }) + + it('returns balance if there is at least one for the specified currency', async () => { + const Portfolio = portfolio() + const testPortfolioBalances = portfolioBalances({ portfolio: Portfolio }) + const currentChainBalance = testPortfolioBalances[0]! + + const portfolioBalancesByIdData = portfolioBalancesById(testPortfolioBalances) + mockUsePortfolioBalances.mockReturnValue({ + data: portfolioBalancesByIdData, + loading: false, + networkStatus: NetworkStatus.ready, + refetch: jest.fn(), + error: undefined, + }) + + const { result } = renderHook( + () => + useCrossChainBalances({ + evmAddress: SAMPLE_SEED_ADDRESS_1, + currencyId: currentChainBalance.currencyInfo.currencyId, + crossChainTokens: null, + }), + { + preloadedState: preloadedMobileState(), + }, + ) + + await waitFor(() => { + expect(result.current).toEqual( + expect.objectContaining({ + currentChainBalance, + }), + ) + }) + }) + }) + + describe('otherChainBalances', () => { + it('returns null if there are no bridged currencies', async () => { + const { result } = renderHook( + () => + useCrossChainBalances({ + evmAddress: SAMPLE_SEED_ADDRESS_1, + currencyId: SAMPLE_CURRENCY_ID_1, + crossChainTokens: null, + }), + { + preloadedState: preloadedMobileState(), + }, + ) + + await act(() => undefined) + + expect(result.current).toEqual( + expect.objectContaining({ + otherChainBalances: null, + }), + ) + }) + + it('does not include current chain balance in other chain balances', async () => { + const tokenBalances = [tokenBalance({ token: usdcBaseToken() }), tokenBalance({ token: usdcArbitrumToken() })] + + const bridgeInfo = tokenBalances.map((balance) => ({ + chain: balance.token.chain, + address: balance.token.address, + })) + const Portfolio = portfolio({ tokenBalances }) + const testPortfolioBalances = portfolioBalances({ + portfolio: Portfolio, + }) + const [currentChainBalance, ...otherChainBalances] = testPortfolioBalances + + const portfolioBalancesByIdData = portfolioBalancesById(testPortfolioBalances) + mockUsePortfolioBalances.mockReturnValue({ + data: portfolioBalancesByIdData, + loading: false, + networkStatus: NetworkStatus.ready, + refetch: jest.fn(), + error: undefined, + }) + + const { result } = renderHook( + () => + useCrossChainBalances({ + evmAddress: SAMPLE_SEED_ADDRESS_1, + currencyId: currentChainBalance!.currencyInfo.currencyId, + crossChainTokens: bridgeInfo, + }), + { + preloadedState: preloadedMobileState(), + }, + ) + + await waitFor(() => { + expect(result.current).toEqual(expect.objectContaining({ currentChainBalance, otherChainBalances })) + }) + }) + }) +}) + +describe(useTokenDetailsNavigation, () => { + afterEach(() => { + jest.clearAllMocks() + }) + + it('returns correct result', () => { + const { result } = renderHook(() => useTokenDetailsNavigation()) + + expect(result.current).toEqual({ + preload: expect.any(Function), + navigate: expect.any(Function), + navigateWithPop: expect.any(Function), + }) + }) + + it('preloads token details when preload function is called', async () => { + const { result } = renderHook(() => useTokenDetailsNavigation()) + + await act(() => result.current.preload(SAMPLE_CURRENCY_ID_1)) + expect(result.current.preload).toBeDefined() + }) + + it('navigates to token details when navigate function is called', async () => { + const { result } = renderHook(() => useTokenDetailsNavigation()) + + await act(() => result.current.navigate(SAMPLE_CURRENCY_ID_1)) + + expect(mockedNavigation.navigate).toHaveBeenCalledTimes(1) + expect(mockedNavigation.navigate).toHaveBeenNthCalledWith(1, MobileScreens.TokenDetails, { + currencyId: SAMPLE_CURRENCY_ID_1, + }) + }) + + describe('navigationWithPop', () => { + it('pops the last screen from the stack and navigates to token details if can go back', async () => { + mockedNavigation.canGoBack.mockReturnValueOnce(true) + const { result } = renderHook(() => useTokenDetailsNavigation()) + + await act(() => result.current.navigateWithPop(SAMPLE_CURRENCY_ID_1)) + + expect(mockedNavigation.pop).toHaveBeenCalledTimes(1) + expect(mockedNavigation.push).toHaveBeenCalledTimes(1) + expect(mockedNavigation.push).toHaveBeenNthCalledWith(1, MobileScreens.TokenDetails, { + currencyId: SAMPLE_CURRENCY_ID_1, + }) + }) + + it('pushes token details screen to the stack without popping if there is no previous screen', async () => { + mockedNavigation.canGoBack.mockReturnValueOnce(false) + const { result } = renderHook(() => useTokenDetailsNavigation()) + + await act(() => result.current.navigateWithPop(SAMPLE_CURRENCY_ID_1)) + + expect(mockedNavigation.pop).not.toHaveBeenCalled() + expect(mockedNavigation.push).toHaveBeenCalledTimes(1) + expect(mockedNavigation.push).toHaveBeenNthCalledWith(1, MobileScreens.TokenDetails, { + currencyId: SAMPLE_CURRENCY_ID_1, + }) + }) + }) +}) diff --git a/apps/mobile/src/components/TokenDetails/hooks.ts b/apps/mobile/src/components/TokenDetails/hooks.ts new file mode 100644 index 00000000..15c03777 --- /dev/null +++ b/apps/mobile/src/components/TokenDetails/hooks.ts @@ -0,0 +1,49 @@ +import { GraphQLApi } from '@universe/api' +import { useCallback, useMemo } from 'react' +import { useAppStackNavigation } from 'src/app/navigation/types' +import { currencyIdToContractInput } from 'uniswap/src/features/dataApi/utils/currencyIdToContractInput' +import { CurrencyId } from 'uniswap/src/types/currency' +import { MobileScreens } from 'uniswap/src/types/screens/mobile' + +/** Utility hook to simplify navigating to token details screen */ +export function useTokenDetailsNavigation(): { + preload: (currencyId: CurrencyId) => void + navigate: (currencyId: CurrencyId) => void + navigateWithPop: (currencyId: CurrencyId) => void +} { + const navigation = useAppStackNavigation() + const [load] = GraphQLApi.useTokenDetailsScreenLazyQuery() + + const preload = useCallback( + async (currencyId: CurrencyId): Promise => { + await load({ + variables: currencyIdToContractInput(currencyId), + }) + }, + [load], + ) + + // the desired behavior is to push the new token details screen onto the stack instead of replacing it + // however, `push` could create an infinitely deep navigation stack that is hard to get out of + // for that reason, we first `pop` token details from the stack, and then push it. + // + // Use whenever we want to avoid nested token details screens in the nav stack. + const navigateWithPop = useCallback( + (currencyId: CurrencyId): void => { + if (navigation.canGoBack()) { + navigation.pop() + } + navigation.push(MobileScreens.TokenDetails, { currencyId }) + }, + [navigation], + ) + + const navigate = useCallback( + (currencyId: CurrencyId): void => { + navigation.navigate(MobileScreens.TokenDetails, { currencyId }) + }, + [navigation], + ) + + return useMemo(() => ({ preload, navigate, navigateWithPop }), [navigate, navigateWithPop, preload]) +} diff --git a/apps/mobile/src/components/TokenDetails/useTokenDetailsCTAVariant.test.ts b/apps/mobile/src/components/TokenDetails/useTokenDetailsCTAVariant.test.ts new file mode 100644 index 00000000..ef2728a7 --- /dev/null +++ b/apps/mobile/src/components/TokenDetails/useTokenDetailsCTAVariant.test.ts @@ -0,0 +1,145 @@ +import { renderHook } from '@testing-library/react' +import { useMultichainBuyVariant } from 'src/components/TokenDetails/useTokenDetailsCTAVariant' +import { Bank } from 'ui/src/components/icons' + +const defaultHandlers = { + onPressBuyWithCash: jest.fn(), + onPressGet: jest.fn(), + onPressBuy: jest.fn(), +} + +const defaultParams = { + hasTokenBalance: false, + isNativeCurrency: false, + nativeFiatOnRampCurrency: undefined, + fiatOnRampCurrency: undefined, + bridgingTokenWithHighestBalance: undefined, + hasZeroGasBalance: undefined, + tokenSymbol: 'UNI', + ...defaultHandlers, +} + +describe(useMultichainBuyVariant, () => { + beforeEach(() => jest.clearAllMocks()) + + it('should return onPressBuy with no custom title when user has balance', () => { + const { result } = renderHook(() => + useMultichainBuyVariant({ + ...defaultParams, + hasTokenBalance: true, + }), + ) + + expect(result.current.title).toBeUndefined() + expect(result.current.onPress).toBe(defaultHandlers.onPressBuy) + }) + + it('should return "Buy with cash" with Bank icon when fiat on-ramp supported and no bridging token', () => { + const { result } = renderHook(() => + useMultichainBuyVariant({ + ...defaultParams, + fiatOnRampCurrency: { symbol: 'UNI' }, + }), + ) + + expect(result.current.title).toBe('Buy with cash') + expect(result.current.icon).toBe(Bank) + expect(result.current.onPress).toBe(defaultHandlers.onPressBuyWithCash) + }) + + it('should return "Buy with cash" with Bank icon for native currency with native fiat on-ramp support', () => { + const { result } = renderHook(() => + useMultichainBuyVariant({ + ...defaultParams, + isNativeCurrency: true, + nativeFiatOnRampCurrency: { symbol: 'ETH' }, + }), + ) + + expect(result.current.title).toBe('Buy with cash') + expect(result.current.icon).toBe(Bank) + expect(result.current.onPress).toBe(defaultHandlers.onPressBuyWithCash) + }) + + it('should return "Get {token}" when non-native with zero native balance', () => { + const { result } = renderHook(() => + useMultichainBuyVariant({ + ...defaultParams, + isNativeCurrency: false, + hasZeroGasBalance: true, + tokenSymbol: 'UNI', + }), + ) + + expect(result.current.title).toBe('Get UNI') + expect(result.current.onPress).toBe(defaultHandlers.onPressGet) + }) + + it('should return fallback "Get Token" when symbol is undefined', () => { + const { result } = renderHook(() => + useMultichainBuyVariant({ + ...defaultParams, + isNativeCurrency: false, + hasZeroGasBalance: true, + tokenSymbol: undefined, + }), + ) + + expect(result.current.title).toBe('Get Token') + expect(result.current.onPress).toBe(defaultHandlers.onPressGet) + }) + + it('should prefer "Buy with cash" over "Get {token}" when both conditions match', () => { + const { result } = renderHook(() => + useMultichainBuyVariant({ + ...defaultParams, + fiatOnRampCurrency: { symbol: 'UNI' }, + isNativeCurrency: false, + hasZeroGasBalance: true, + }), + ) + + expect(result.current.title).toBe('Buy with cash') + expect(result.current.onPress).toBe(defaultHandlers.onPressBuyWithCash) + }) + + it('should return "Get {token}" when native currency with zero balance', () => { + const { result } = renderHook(() => + useMultichainBuyVariant({ + ...defaultParams, + isNativeCurrency: true, + hasZeroGasBalance: true, + tokenSymbol: 'MON', + }), + ) + + expect(result.current.title).toBe('Get MON') + expect(result.current.onPress).toBe(defaultHandlers.onPressGet) + }) + + it('should fall back to onPressBuy when no special conditions match', () => { + const { result } = renderHook(() => + useMultichainBuyVariant({ + ...defaultParams, + isNativeCurrency: true, + hasZeroGasBalance: false, + }), + ) + + expect(result.current.title).toBeUndefined() + expect(result.current.onPress).toBe(defaultHandlers.onPressBuy) + }) + + it('should fall back to onPressBuy when on-ramp is supported but bridging token exists', () => { + const { result } = renderHook(() => + useMultichainBuyVariant({ + ...defaultParams, + fiatOnRampCurrency: { symbol: 'UNI' }, + bridgingTokenWithHighestBalance: { balance: 100 }, + }), + ) + + expect(result.current.title).toBeUndefined() + expect(result.current.onPress).toBe(defaultHandlers.onPressBuy) + }) +}) diff --git a/apps/mobile/src/components/TokenDetails/useTokenDetailsCTAVariant.ts b/apps/mobile/src/components/TokenDetails/useTokenDetailsCTAVariant.ts new file mode 100644 index 00000000..996ab5a4 --- /dev/null +++ b/apps/mobile/src/components/TokenDetails/useTokenDetailsCTAVariant.ts @@ -0,0 +1,150 @@ +import { useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { GeneratedIcon } from 'ui/src' +import { Bank, SwapDotted } from 'ui/src/components/icons' +import { CurrencyField } from 'uniswap/src/types/currency' + +interface TokenCTAButtonVariant { + title: string + icon?: GeneratedIcon + onPress: () => void +} + +interface UseTokenDetailsCTAVariantParams { + hasTokenBalance: boolean + isNativeCurrency: boolean + nativeFiatOnRampCurrency: unknown | undefined + fiatOnRampCurrency: unknown | undefined + bridgingTokenWithHighestBalance: unknown | undefined + hasZeroGasBalance: boolean | undefined + tokenSymbol: string | undefined + onPressBuyFiatOnRamp: (isOfframp: boolean) => void + onPressGet: () => void + onPressSwap: (currencyField: CurrencyField) => void +} + +interface MultichainBuyVariant { + title?: string + icon?: GeneratedIcon + onPress: () => void +} + +interface UseMultichainBuyVariantParams { + hasTokenBalance: boolean + isNativeCurrency: boolean + nativeFiatOnRampCurrency: unknown | undefined + fiatOnRampCurrency: unknown | undefined + bridgingTokenWithHighestBalance: unknown | undefined + hasZeroGasBalance: boolean | undefined + tokenSymbol: string | undefined + onPressBuyWithCash: () => void + onPressGet: () => void + onPressBuy: () => void +} + +/** Determines the Buy button title and handler for the multichain TDP variant. */ +export function useMultichainBuyVariant({ + hasTokenBalance, + isNativeCurrency, + nativeFiatOnRampCurrency, + fiatOnRampCurrency, + bridgingTokenWithHighestBalance, + hasZeroGasBalance, + tokenSymbol, + onPressBuyWithCash, + onPressGet, + onPressBuy, +}: UseMultichainBuyVariantParams): MultichainBuyVariant { + const { t } = useTranslation() + + return useMemo(() => { + if (hasTokenBalance) { + return { onPress: onPressBuy } + } + + const isOnrampCurrency = (isNativeCurrency && nativeFiatOnRampCurrency) || fiatOnRampCurrency + + if (isOnrampCurrency && !bridgingTokenWithHighestBalance) { + return { title: t('fiatOnRamp.action.buyWithCash'), icon: Bank, onPress: onPressBuyWithCash } + } + + if (hasZeroGasBalance) { + return { + title: tokenSymbol ? t('tdp.button.getToken', { tokenSymbol }) : t('tdp.button.getTokenFallback'), + onPress: onPressGet, + } + } + + return { onPress: onPressBuy } + }, [ + hasTokenBalance, + isNativeCurrency, + fiatOnRampCurrency, + nativeFiatOnRampCurrency, + bridgingTokenWithHighestBalance, + hasZeroGasBalance, + tokenSymbol, + t, + onPressBuyWithCash, + onPressGet, + onPressBuy, + ]) +} + +export function useTokenDetailsCTAVariant({ + hasTokenBalance, + isNativeCurrency, + nativeFiatOnRampCurrency, + fiatOnRampCurrency, + bridgingTokenWithHighestBalance, + hasZeroGasBalance, + tokenSymbol, + onPressBuyFiatOnRamp, + onPressGet, + onPressSwap, +}: UseTokenDetailsCTAVariantParams): TokenCTAButtonVariant { + const { t } = useTranslation() + + return useMemo(() => { + const swapVariant = { + title: t('common.button.swap'), + icon: SwapDotted, + onPress: () => onPressSwap(hasTokenBalance ? CurrencyField.INPUT : CurrencyField.OUTPUT), + } + + if (hasTokenBalance) { + return swapVariant + } + + const isOnrampCurrency = (isNativeCurrency && nativeFiatOnRampCurrency) || fiatOnRampCurrency + + if (isOnrampCurrency && !bridgingTokenWithHighestBalance) { + return { + title: t('common.button.buy'), + icon: Bank, + onPress: () => onPressBuyFiatOnRamp(false), + } + } + + if (!isNativeCurrency && hasZeroGasBalance) { + return { + title: tokenSymbol ? t('tdp.button.getToken', { tokenSymbol }) : t('tdp.button.getTokenFallback'), + onPress: onPressGet, + } + } + + return swapVariant + }, [ + hasTokenBalance, + isNativeCurrency, + fiatOnRampCurrency, + nativeFiatOnRampCurrency, + bridgingTokenWithHighestBalance, + hasZeroGasBalance, + tokenSymbol, + t, + onPressBuyFiatOnRamp, + onPressGet, + onPressSwap, + ]) +} diff --git a/apps/mobile/src/components/TokenDetails/useTokenDetailsColors.ts b/apps/mobile/src/components/TokenDetails/useTokenDetailsColors.ts new file mode 100644 index 00000000..c10623e8 --- /dev/null +++ b/apps/mobile/src/components/TokenDetails/useTokenDetailsColors.ts @@ -0,0 +1,28 @@ +import { useExtractedTokenColor, useIsDarkMode, useSporeColors } from 'ui/src' +import { + useTokenBasicInfoPartsFragment, + useTokenBasicProjectPartsFragment, +} from 'uniswap/src/data/graphql/uniswap-data-api/fragments' + +export function useTokenDetailsColors({ currencyId }: { currencyId: string }): { + tokenColor: Nullable + tokenColorLoading: boolean +} { + const isDarkMode = useIsDarkMode() + const colors = useSporeColors() + + const token = useTokenBasicInfoPartsFragment({ currencyId }).data + const project = useTokenBasicProjectPartsFragment({ currencyId }).data.project + + const { tokenColor, tokenColorLoading } = useExtractedTokenColor({ + imageUrl: project?.logoUrl, + tokenName: token.symbol, + backgroundColor: colors.surface1.val, + defaultColor: colors.neutral3.val, + }) + + return { + tokenColor: tokenColor ? tokenColor : isDarkMode ? colors.neutral3.val : colors.surface3.val, + tokenColorLoading, + } +} diff --git a/apps/mobile/src/components/TokenDetails/useTokenDetailsCurrentChainBalance.ts b/apps/mobile/src/components/TokenDetails/useTokenDetailsCurrentChainBalance.ts new file mode 100644 index 00000000..d85d0f59 --- /dev/null +++ b/apps/mobile/src/components/TokenDetails/useTokenDetailsCurrentChainBalance.ts @@ -0,0 +1,19 @@ +import { useTokenDetailsContext } from 'src/components/TokenDetails/TokenDetailsContext' +import { useBalances } from 'uniswap/src/data/balances/hooks/useBalances' +import { PortfolioBalance } from 'uniswap/src/features/dataApi/types' +import { useActiveAccountAddressWithThrow } from 'wallet/src/features/wallet/hooks' + +export function useTokenDetailsCurrentChainBalance(): PortfolioBalance | null { + const activeAddress = useActiveAccountAddressWithThrow() + const { currencyId } = useTokenDetailsContext() + + return ( + useBalances({ + evmAddress: activeAddress, + currencies: [currencyId], + // There are already other requests in the TDP that will update the cache, + // so no need to do additional network requests when using this helper hook. + fetchPolicy: 'cache-first', + })?.[0] ?? null + ) +} diff --git a/apps/mobile/src/components/TokenSelector/TokenFiatOnRampList.tsx b/apps/mobile/src/components/TokenSelector/TokenFiatOnRampList.tsx new file mode 100644 index 00000000..8d81d73a --- /dev/null +++ b/apps/mobile/src/components/TokenSelector/TokenFiatOnRampList.tsx @@ -0,0 +1,204 @@ +import { BottomSheetSectionList } from '@gorhom/bottom-sheet' +import React, { memo, useCallback, useMemo, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { ListRenderItemInfo } from 'react-native' +import { Flex, Inset, Loader } from 'ui/src' +import { BaseCard } from 'uniswap/src/components/BaseCard/BaseCard' +import { TokenOptionItem } from 'uniswap/src/components/lists/items/tokens/TokenOptionItem' +import { OnchainItemListOptionType, TokenOption } from 'uniswap/src/components/lists/items/types' +import { PortfolioBalance } from 'uniswap/src/features/dataApi/types' +import { FiatOnRampCurrency, FORCurrencyOrBalance } from 'uniswap/src/features/fiatOnRamp/types' +import { getUnsupportedFORTokensWithBalance, isSupportedFORCurrency } from 'uniswap/src/features/fiatOnRamp/utils' +import { useLocalizationContext } from 'uniswap/src/features/language/LocalizationContext' +import { getTokenProtectionWarning } from 'uniswap/src/features/tokens/warnings/safetyUtils' +import { useDismissedTokenWarnings } from 'uniswap/src/features/tokens/warnings/slice/hooks' +import { ListSeparatorToggle } from 'uniswap/src/features/transactions/TransactionDetails/ListSeparatorToggle' +import { CurrencyId } from 'uniswap/src/types/currency' +import { NumberType } from 'utilities/src/format/types' + +interface Props { + onSelectCurrency: (currency: FiatOnRampCurrency) => void + onRetry: () => void + error: boolean + loading: boolean + list: FiatOnRampCurrency[] | undefined + balancesById: Record | undefined + selectedCurrency?: FiatOnRampCurrency + isOffRamp: boolean +} + +function TokenOptionItemWrapper({ + currency, + onSelectCurrency, + currencyBalance, + isSelected, + showUnsupported, +}: { + currency: FORCurrencyOrBalance + onSelectCurrency: (currency: FiatOnRampCurrency) => void + currencyBalance: Maybe + isSelected?: boolean + showUnsupported?: boolean +}): JSX.Element | null { + const { currencyInfo } = currency + const { quantity, balanceUSD } = currencyBalance || {} + const isUnsupported = !isSupportedFORCurrency(currency) + + const option: TokenOption | null = useMemo( + () => + currencyInfo + ? { type: OnchainItemListOptionType.Token, currencyInfo, quantity: quantity || null, balanceUSD, isUnsupported } + : null, + [currencyInfo, balanceUSD, quantity, isUnsupported], + ) + const onPress = useCallback(() => onSelectCurrency(currency), [currency, onSelectCurrency]) + const tokenProtectionWarning = getTokenProtectionWarning(currencyInfo) + const { tokenWarningDismissed } = useDismissedTokenWarnings(currencyInfo?.currency, tokenProtectionWarning) + const { convertFiatAmountFormatted, formatNumberOrString } = useLocalizationContext() + + if (!option) { + return null + } + + if (!showUnsupported && isUnsupported) { + return null + } + + return ( + + ) +} + +function TokenFiatOnRampListInner({ + onSelectCurrency, + error, + onRetry, + list = [], + loading, + balancesById, + selectedCurrency, + isOffRamp, +}: Props): JSX.Element { + const { t } = useTranslation() + const [showMore, setShowMore] = useState(true) + + enum ListSection { + SUPPORTED = 'SUPPORTED', + UNSUPPORTED = 'UNSUPPORTED', + } + + const sortedSupportedAssetsWithBalance = list + .filter((c) => { + if (!c.currencyInfo) { + return false + } + + const quantity = balancesById?.[c.currencyInfo.currencyId]?.quantity ?? 0 + return quantity > 0 + }) + .sort((a, b) => { + if (!a.currencyInfo || !b.currencyInfo) { + return 0 + } + + const aQuantity = balancesById?.[a.currencyInfo.currencyId]?.balanceUSD ?? 0 + const bQuantity = balancesById?.[b.currencyInfo.currencyId]?.balanceUSD ?? 0 + return bQuantity - aQuantity + }) + const supportedAssetsWithoutBalance = list.filter((c) => c.currencyInfo && !balancesById?.[c.currencyInfo.currencyId]) + const unsupportedAssetsWithBalance = getUnsupportedFORTokensWithBalance(list, balancesById) + + const tokenList = isOffRamp + ? [ + { title: ListSection.SUPPORTED, data: [...sortedSupportedAssetsWithBalance, ...supportedAssetsWithoutBalance] }, + { title: ListSection.UNSUPPORTED, data: unsupportedAssetsWithBalance }, + ] + : [{ title: ListSection.SUPPORTED, data: list }] + const flatListRef = useRef(null) + const renderItem = useCallback( + ({ item: currency }: ListRenderItemInfo) => { + const { currencyInfo } = currency + const currencyBalance = currencyInfo && balancesById?.[currencyInfo.currencyId] + + return ( + + ) + }, + [onSelectCurrency, balancesById, selectedCurrency, showMore], + ) + + if (error) { + return ( + + + + ) + } + + if (loading) { + return + } + + return ( + } + ListFooterComponent={} + keyExtractor={key} + keyboardDismissMode="on-drag" + keyboardShouldPersistTaps="always" + renderItem={renderItem} + renderSectionHeader={({ section }) => { + if (section.title !== ListSection.UNSUPPORTED) { + return <> + } + + if (section.data.length === 0) { + return <> + } + + return ( + + { + setShowMore(!showMore) + }} + /> + + ) + }} + sections={tokenList} + showsVerticalScrollIndicator={false} + stickySectionHeadersEnabled={false} + windowSize={5} + /> + ) +} + +function key(item: FiatOnRampCurrency): CurrencyId { + return item.currencyInfo?.currencyId ?? '' +} + +export const TokenFiatOnRampList = memo(TokenFiatOnRampListInner) diff --git a/apps/mobile/src/components/Trace/TraceTabView.tsx b/apps/mobile/src/components/Trace/TraceTabView.tsx new file mode 100644 index 00000000..3ad01900 --- /dev/null +++ b/apps/mobile/src/components/Trace/TraceTabView.tsx @@ -0,0 +1,25 @@ +import { SharedEventName } from '@uniswap/analytics-events' +import React from 'react' +import { Route, TabView, TabViewProps } from 'react-native-tab-view' +import { SectionName } from 'uniswap/src/features/telemetry/constants' +import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { MobileScreens } from 'uniswap/src/types/screens/mobile' + +type TraceRouteProps = { key: SectionName } & Route + +export default function TraceTabView({ + onIndexChange, + navigationState, + screenName, + ...rest +}: TabViewProps & { screenName: MobileScreens }): JSX.Element { + const onIndexChangeTrace = (index: number): void => { + sendAnalyticsEvent(SharedEventName.PAGE_VIEWED, { + section: navigationState.routes[index]?.key, + screen: screenName, + }) + onIndexChange(index) + } + + return +} diff --git a/apps/mobile/src/components/Trace/TraceUserProperties.test.tsx b/apps/mobile/src/components/Trace/TraceUserProperties.test.tsx new file mode 100644 index 00000000..59bd456f --- /dev/null +++ b/apps/mobile/src/components/Trace/TraceUserProperties.test.tsx @@ -0,0 +1,205 @@ +import React from 'react' +import { useColorScheme } from 'react-native' +import renderer, { act } from 'react-test-renderer' +import { TraceUserProperties } from 'src/components/Trace/TraceUserProperties' +import * as biometricAppSettingsHooks from 'src/features/biometrics/useBiometricAppSettings' +import * as deviceBiometricHooks from 'src/features/biometrics/useDeviceSupportsBiometricAuth' +import { AuthMethod } from 'src/features/telemetry/utils' +import * as versionUtils from 'src/utils/version' +import * as useIsDarkModeFile from 'ui/src/hooks/useIsDarkMode' +import { AccountType } from 'uniswap/src/features/accounts/types' +import { FiatCurrency } from 'uniswap/src/features/fiatCurrency/constants' +import * as fiatCurrencyHooks from 'uniswap/src/features/fiatCurrency/hooks' +import * as languageHooks from 'uniswap/src/features/language/hooks' +import * as userSettingsHooks from 'uniswap/src/features/settings/hooks' +import { MobileUserPropertyName } from 'uniswap/src/features/telemetry/user' +import { analytics } from 'utilities/src/telemetry/analytics/analytics' +import { BackupType, SignerMnemonicAccount } from 'wallet/src/features/wallet/accounts/types' +import * as walletHooks from 'wallet/src/features/wallet/hooks' +import { SwapProtectionSetting } from 'wallet/src/features/wallet/slice' + +// `any` is the actual type used by `jest.spyOn` +// oxlint-disable-next-line max-params +function mockFn(module: any, func: string, returnValue: any): jest.SpyInstance { + return jest.spyOn(module, func).mockImplementation(() => returnValue) +} + +jest.mock('@tanstack/react-query', () => ({ + ...jest.requireActual('@tanstack/react-query'), + useQuery: jest.fn().mockReturnValue({ data: undefined }), +})) +jest.mock('@universe/api', () => ({ + ...jest.requireActual('@universe/api'), + provideUniswapIdentifierService: {}, +})) +jest.mock('@universe/sessions', () => ({ + uniswapIdentifierQuery: jest.fn().mockReturnValue({}), +})) +jest.mock('react-native/Libraries/Utilities/useColorScheme') +jest.mock('wallet/src/features/gating/userPropertyHooks') +jest.mock('wallet/src/features/wallet/Keyring/Keyring', () => { + return { + Keyring: { + getMnemonicIds: (): Promise => Promise.resolve([]), + getAddressesForStoredPrivateKeys: (): Promise => Promise.resolve([]), + }, + } +}) +jest.mock('wallet/src/features/accounts/useAccountListData', () => { + return { + useAccountBalances: jest.fn().mockReturnValue({ totalBalance: 0 }), + } +}) + +const mockDispatch = jest.fn() +const mockSelector = jest.fn() + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useDispatch: (): jest.Mock => mockDispatch, + useSelector: (): jest.Mock => mockSelector, +})) + +const address1 = '0x168fA52Da8A45cEb01318E72B299b2d6A17167BF' +const address2 = '0x168fA52Da8A45cEb01318E72B299b2d6A17167BD' +const address3 = '0x168fA52Da8A45cEb01318E72B299b2d6A17167BE' + +const signerAccount1 = { + type: AccountType.SignerMnemonic, + address: address1, + timeImportedMs: 100000, + pushNotificationsEnabled: true, + mnemonicId: '111', + derivationIndex: 0, +} satisfies SignerMnemonicAccount + +const signerAccount2 = { + type: AccountType.SignerMnemonic, + address: address2, + timeImportedMs: 100000, + pushNotificationsEnabled: true, + mnemonicId: '222', + derivationIndex: 1, +} satisfies SignerMnemonicAccount + +const signerAccount3 = { + type: AccountType.SignerMnemonic, + address: address3, + timeImportedMs: 100000, + pushNotificationsEnabled: true, + mnemonicId: '333', + derivationIndex: 2, +} satisfies SignerMnemonicAccount + +describe('TraceUserProperties', () => { + afterEach(() => { + jest.clearAllMocks() + }) + + it('sets user properties with active account', async () => { + mockFn(versionUtils, 'getFullAppVersion', '1.0.0.345') + // Hooks mocks + const mockedUsedColorScheme = useColorScheme as jest.Mock + mockedUsedColorScheme.mockReturnValue('dark') + mockFn(walletHooks, 'useActiveAccount', { + address: 'address', + type: AccountType.SignerMnemonic, + backups: [BackupType.Cloud], + pushNotificationsEnabled: true, + }) + mockFn(walletHooks, 'useViewOnlyAccounts', ['address1', 'address2']) + mockFn(walletHooks, 'useSwapProtectionSetting', SwapProtectionSetting.On) + mockFn(walletHooks, 'useSignerAccounts', [signerAccount1, signerAccount2, signerAccount3]) + mockFn(userSettingsHooks, 'useHideSpamTokensSetting', true) + mockFn(userSettingsHooks, 'useHideSmallBalancesSetting', false) + mockFn(biometricAppSettingsHooks, 'useBiometricAppSettings', { + requiredForAppAccess: true, + requiredForTransactions: true, + }) + mockFn(deviceBiometricHooks, 'useDeviceSupportsBiometricAuth', { + touchId: false, + faceId: true, + }) + mockFn(useIsDarkModeFile, 'useIsDarkMode', true) + mockFn(fiatCurrencyHooks, 'useAppFiatCurrency', FiatCurrency.UnitedStatesDollar) + mockFn(languageHooks, 'useCurrentLanguageInfo', { loggingName: 'English' }) + + // mock setUserProperty + const mocked = mockFn(analytics, 'setUserProperty', undefined) + + // Execute useEffects + // https://reactjs.org/docs/test-renderer.html#testrendereract + await act(() => { + renderer.create() + }) + + // Check setUserProperty calls with correct values + expect(mocked).toHaveBeenCalledWith(MobileUserPropertyName.AppVersion, '1.0.0.345', undefined) + expect(mocked).toHaveBeenCalledWith(MobileUserPropertyName.DarkMode, true, undefined) + expect(mocked).toHaveBeenCalledWith(MobileUserPropertyName.ActiveWalletAddress, 'address', undefined) + expect(mocked).toHaveBeenCalledWith(MobileUserPropertyName.ActiveWalletType, AccountType.SignerMnemonic, undefined) + expect(mocked).toHaveBeenCalledWith(MobileUserPropertyName.IsCloudBackedUp, true, undefined) + expect(mocked).toHaveBeenCalledWith(MobileUserPropertyName.BackupTypes, [BackupType.Cloud], undefined) + expect(mocked).toHaveBeenCalledWith(MobileUserPropertyName.IsPushEnabled, true, undefined) + expect(mocked).toHaveBeenCalledWith(MobileUserPropertyName.IsHideSmallBalancesEnabled, false, undefined) + expect(mocked).toHaveBeenCalledWith(MobileUserPropertyName.IsHideSpamTokensEnabled, true, undefined) + expect(mocked).toHaveBeenCalledWith(MobileUserPropertyName.WalletViewOnlyCount, 2, undefined) + expect(mocked).toHaveBeenCalledWith(MobileUserPropertyName.WalletSignerCount, 3, undefined) + expect(mocked).toHaveBeenCalledWith( + MobileUserPropertyName.WalletSignerAccounts, + [address1, address2, address3], + undefined, + ) + expect(mocked).toHaveBeenCalledWith( + MobileUserPropertyName.WalletSwapProtectionSetting, + SwapProtectionSetting.On, + undefined, + ) + expect(mocked).toHaveBeenCalledWith(MobileUserPropertyName.AppOpenAuthMethod, AuthMethod.FaceId, undefined) + expect(mocked).toHaveBeenCalledWith(MobileUserPropertyName.TransactionAuthMethod, AuthMethod.FaceId, undefined) + expect(mocked).toHaveBeenCalledWith(MobileUserPropertyName.Language, 'English', undefined) + expect(mocked).toHaveBeenCalledWith(MobileUserPropertyName.Currency, 'USD', undefined) + + expect(mocked).toHaveBeenCalledTimes(22) + }) + + it('sets user properties without active account', async () => { + mockFn(versionUtils, 'getFullAppVersion', '1.0.0.345') + // Hooks mocks + const mockedUsedColorScheme = useColorScheme as jest.Mock + mockedUsedColorScheme.mockReturnValue('dark') + mockFn(walletHooks, 'useActiveAccount', null) + mockFn(walletHooks, 'useViewOnlyAccounts', []) + mockFn(walletHooks, 'useSwapProtectionSetting', SwapProtectionSetting.On) + mockFn(walletHooks, 'useSignerAccounts', []) + mockFn(biometricAppSettingsHooks, 'useBiometricAppSettings', { + requiredForAppAccess: false, + requiredForTransactions: false, + }) + mockFn(deviceBiometricHooks, 'useDeviceSupportsBiometricAuth', { + touchId: false, + faceId: false, + }) + mockFn(useIsDarkModeFile, 'useIsDarkMode', true) + mockFn(fiatCurrencyHooks, 'useAppFiatCurrency', FiatCurrency.UnitedStatesDollar) + mockFn(languageHooks, 'useCurrentLanguageInfo', { loggingName: 'English' }) + + // mock setUserProperty + const mocked = mockFn(analytics, 'setUserProperty', undefined) + + // Execute useEffects + await act(() => { + renderer.create() + }) + + // Check setUserProperty calls with correct values + expect(mocked).toHaveBeenCalledWith(MobileUserPropertyName.AppVersion, '1.0.0.345', undefined) + expect(mocked).toHaveBeenCalledWith(MobileUserPropertyName.DarkMode, true, undefined) + expect(mocked).toHaveBeenCalledWith(MobileUserPropertyName.WalletViewOnlyCount, 0, undefined) + expect(mocked).toHaveBeenCalledWith(MobileUserPropertyName.WalletSignerCount, 0, undefined) + expect(mocked).toHaveBeenCalledWith(MobileUserPropertyName.AppOpenAuthMethod, AuthMethod.None, undefined) + expect(mocked).toHaveBeenCalledWith(MobileUserPropertyName.TransactionAuthMethod, AuthMethod.None, undefined) + + expect(mocked).toHaveBeenCalledTimes(15) + }) +}) diff --git a/apps/mobile/src/components/Trace/TraceUserProperties.tsx b/apps/mobile/src/components/Trace/TraceUserProperties.tsx new file mode 100644 index 00000000..60ecb9f3 --- /dev/null +++ b/apps/mobile/src/components/Trace/TraceUserProperties.tsx @@ -0,0 +1,186 @@ +import { useQuery } from '@tanstack/react-query' +import { provideUniswapIdentifierService } from '@universe/api' +import { uniswapIdentifierQuery } from '@universe/sessions' +import { useEffect, useMemo } from 'react' +import { NativeModules, useWindowDimensions } from 'react-native' +import { OneSignal } from 'react-native-onesignal' +import { useSelector } from 'react-redux' +import { useBiometricAppSettings } from 'src/features/biometrics/useBiometricAppSettings' +import { useDeviceSupportsBiometricAuth } from 'src/features/biometrics/useDeviceSupportsBiometricAuth' +import { setDatadogUserWithUniqueId } from 'src/features/datadog/user' +import { OneSignalUserTagField } from 'src/features/notifications/constants' +import { getAuthMethod } from 'src/features/telemetry/utils' +import { getFullAppVersion } from 'src/utils/version' +import { useIsDarkMode } from 'ui/src' +import { useEnabledChains } from 'uniswap/src/features/chains/hooks/useEnabledChains' +import { useAppFiatCurrency } from 'uniswap/src/features/fiatCurrency/hooks' +import { useCurrentLanguageInfo } from 'uniswap/src/features/language/hooks' +import { useHideSmallBalancesSetting, useHideSpamTokensSetting } from 'uniswap/src/features/settings/hooks' +import { MobileUserPropertyName, setUserProperty } from 'uniswap/src/features/telemetry/user' +import { logger } from 'utilities/src/logger/logger' +import { isAndroid } from 'utilities/src/platform' +// oxlint-disable-next-line no-restricted-imports -- Required for analytics user properties +import { analytics } from 'utilities/src/telemetry/analytics/analytics' +import { useAccountBalances } from 'wallet/src/features/accounts/useAccountListData' +import { useGatingUserPropertyUsernames } from 'wallet/src/features/gating/userPropertyHooks' +import { selectAllowAnalytics } from 'wallet/src/features/telemetry/selectors' +import { BackupType } from 'wallet/src/features/wallet/accounts/types' +import { hasBackup } from 'wallet/src/features/wallet/accounts/utils' +import { + useActiveAccount, + useSignerAccounts, + useSwapProtectionSetting, + useViewOnlyAccounts, +} from 'wallet/src/features/wallet/hooks' +import { Keyring } from 'wallet/src/features/wallet/Keyring/Keyring' +import { selectFinishedOnboarding } from 'wallet/src/features/wallet/selectors' + +/** Component that tracks UserProperties during the lifetime of the app */ +export function TraceUserProperties(): null { + const isDarkMode = useIsDarkMode() + const { width: windowWidth, height: windowHeight } = useWindowDimensions() + const viewOnlyAccounts = useViewOnlyAccounts() + const activeAccount = useActiveAccount() + const signerAccounts = useSignerAccounts() + const biometricsAppSettingsState = useBiometricAppSettings() + const { touchId, faceId } = useDeviceSupportsBiometricAuth() + const swapProtectionSetting = useSwapProtectionSetting() + const currentLanguage = useCurrentLanguageInfo().loggingName + const currentFiatCurrency = useAppFiatCurrency() + const hideSpamTokens = useHideSpamTokensSetting() + const hideSmallBalances = useHideSmallBalancesSetting() + const { isTestnetModeEnabled } = useEnabledChains() + const finishedOnboarding = useSelector(selectFinishedOnboarding) + + const signerAccountAddresses = useMemo(() => signerAccounts.map((account) => account.address), [signerAccounts]) + const { totalBalance: signerAccountsTotalBalance } = useAccountBalances({ + addresses: signerAccountAddresses, + fetchPolicy: 'cache-first', + }) + + // Effects must check this and ensure they are setting properties for when analytics is reenabled + const allowAnalytics = useSelector(selectAllowAnalytics) + + const { data: uniswapIdentifier } = useQuery(uniswapIdentifierQuery(provideUniswapIdentifierService)) + + useGatingUserPropertyUsernames() + + // oxlint-disable-next-line react/exhaustive-deps -- we want to run this when allowAnalytics changes + useEffect(() => { + setUserProperty(MobileUserPropertyName.AppVersion, getFullAppVersion()) + if (isAndroid) { + NativeModules['AndroidDeviceModule'].getPerformanceClass().then((perfClass: number) => { + setUserProperty(MobileUserPropertyName.AndroidPerfClass, perfClass) + }) + } + return () => { + analytics.flushEvents() + } + }, [allowAnalytics]) + + // oxlint-disable-next-line react/exhaustive-deps -- we want to run this when finishedOnboarding changes + useEffect(() => { + const fetchKeyringData = async (): Promise => { + const mnemonicIds = await Keyring.getMnemonicIds() + setUserProperty(MobileUserPropertyName.MnemonicCount, mnemonicIds.length) + const privateKeyAddresses = await Keyring.getAddressesForStoredPrivateKeys() + setUserProperty(MobileUserPropertyName.PrivateKeyCount, privateKeyAddresses.length) + } + fetchKeyringData().catch((error) => { + logger.error(error, { + tags: { file: 'TraceUserProperties.tsx', function: 'fetchKeyringData' }, + }) + }) + }, [finishedOnboarding]) + + // Set user properties for datadog + + useEffect(() => { + setDatadogUserWithUniqueId(activeAccount?.address, uniswapIdentifier) + }, [activeAccount?.address, uniswapIdentifier]) + + // Set user properties for amplitude + + // oxlint-disable-next-line react/exhaustive-deps -- we want to run this also when allowAnalytics changes + useEffect(() => { + setUserProperty(MobileUserPropertyName.WalletSwapProtectionSetting, swapProtectionSetting) + }, [allowAnalytics, swapProtectionSetting]) + + // oxlint-disable-next-line react/exhaustive-deps -- we want to run this also when allowAnalytics changes + useEffect(() => { + setUserProperty(MobileUserPropertyName.DarkMode, isDarkMode) + }, [allowAnalytics, isDarkMode]) + + useEffect(() => { + setUserProperty(MobileUserPropertyName.WindowHeight, windowHeight) + setUserProperty(MobileUserPropertyName.WindowWidth, windowWidth) + }, [windowWidth, windowHeight]) + + // oxlint-disable-next-line react/exhaustive-deps -- we want to run this also when allowAnalytics changes + useEffect(() => { + setUserProperty(MobileUserPropertyName.WalletSignerCount, signerAccountAddresses.length) + setUserProperty(MobileUserPropertyName.WalletSignerAccounts, signerAccountAddresses) + }, [allowAnalytics, signerAccountAddresses]) + + // oxlint-disable-next-line react/exhaustive-deps -- we want to run this also when allowAnalytics changes + useEffect(() => { + setUserProperty(MobileUserPropertyName.WalletViewOnlyCount, viewOnlyAccounts.length) + }, [allowAnalytics, viewOnlyAccounts]) + + // oxlint-disable-next-line react/exhaustive-deps -- we want to run this also when allowAnalytics changes + useEffect(() => { + if (!activeAccount) { + return + } + if (activeAccount.backups) { + setUserProperty(MobileUserPropertyName.BackupTypes, activeAccount.backups) + } + setUserProperty(MobileUserPropertyName.ActiveWalletAddress, activeAccount.address) + setUserProperty(MobileUserPropertyName.ActiveWalletType, activeAccount.type) + setUserProperty(MobileUserPropertyName.IsCloudBackedUp, hasBackup(BackupType.Cloud, activeAccount)) + setUserProperty(MobileUserPropertyName.IsPushEnabled, Boolean(activeAccount.pushNotificationsEnabled)) + setUserProperty(MobileUserPropertyName.IsHideSmallBalancesEnabled, hideSmallBalances) + setUserProperty(MobileUserPropertyName.IsHideSpamTokensEnabled, hideSpamTokens) + }, [allowAnalytics, activeAccount, hideSmallBalances, hideSpamTokens]) + + // oxlint-disable-next-line react/exhaustive-deps -- we want to run this also when allowAnalytics changes + useEffect(() => { + setUserProperty( + MobileUserPropertyName.AppOpenAuthMethod, + getAuthMethod({ + isSettingEnabled: biometricsAppSettingsState.requiredForAppAccess, + isTouchIdSupported: touchId, + isFaceIdSupported: faceId, + }), + ) + setUserProperty( + MobileUserPropertyName.TransactionAuthMethod, + getAuthMethod({ + isSettingEnabled: biometricsAppSettingsState.requiredForTransactions, + isTouchIdSupported: touchId, + isFaceIdSupported: faceId, + }), + ) + }, [allowAnalytics, biometricsAppSettingsState, touchId, faceId]) + + // oxlint-disable-next-line react/exhaustive-deps -- we want to run this also when allowAnalytics changes + useEffect(() => { + setUserProperty(MobileUserPropertyName.Language, currentLanguage) + }, [allowAnalytics, currentLanguage]) + + // oxlint-disable-next-line react/exhaustive-deps -- we want to run this also when allowAnalytics changes + useEffect(() => { + setUserProperty(MobileUserPropertyName.Currency, currentFiatCurrency) + }, [allowAnalytics, currentFiatCurrency]) + + // oxlint-disable-next-line react/exhaustive-deps -- we want to run this also when allowAnalytics changes + useEffect(() => { + setUserProperty(MobileUserPropertyName.TestnetModeEnabled, isTestnetModeEnabled) + }, [allowAnalytics, isTestnetModeEnabled]) + + useEffect(() => { + OneSignal.User.addTag(OneSignalUserTagField.AccountIsUnfunded, signerAccountsTotalBalance === 0 ? 'true' : 'false') + }, [signerAccountsTotalBalance]) + + return null +} diff --git a/apps/mobile/src/components/accounts/AccountCardItem.test.tsx b/apps/mobile/src/components/accounts/AccountCardItem.test.tsx new file mode 100644 index 00000000..0696089d --- /dev/null +++ b/apps/mobile/src/components/accounts/AccountCardItem.test.tsx @@ -0,0 +1,109 @@ +import { AccountCardItem } from 'src/components/accounts/AccountCardItem' +import { fireEvent, render, screen, waitFor } from 'src/test/test-utils' +import { amount, ON_PRESS_EVENT_PAYLOAD, portfolio, SAMPLE_SEED_ADDRESS_1 } from 'uniswap/src/test/fixtures' +import { queryResolvers } from 'uniswap/src/test/utils' +import * as hooks from 'wallet/src/features/accounts/useAccountListData' + +describe(AccountCardItem, () => { + beforeEach(() => { + jest.spyOn(hooks, 'useAccountListData').mockReturnValue({ + data: undefined, + loading: false, + networkStatus: 7, + refetch: jest.fn(), + startPolling: jest.fn(), + stopPolling: jest.fn(), + }) + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + const defaultProps = { + address: SAMPLE_SEED_ADDRESS_1, + isPortfolioValueLoading: false, + portfolioValue: 100, + isViewOnly: false, + onPress: jest.fn(), + onClose: jest.fn(), + } + + it('renders correctly', () => { + const tree = render() + + expect(tree).toMatchSnapshot() + }) + + it('calls onPress when address is pressed', () => { + const onPress = jest.fn() + render() + + const address = screen.getByTestId(`account-item/${SAMPLE_SEED_ADDRESS_1}`) + fireEvent.press(address, ON_PRESS_EVENT_PAYLOAD) + + expect(onPress).toHaveBeenCalledTimes(1) + }) + + describe('portfolio value', () => { + it('displays loading shimmmer when portfolio value is loading', () => { + const { rerender } = render( + , + ) + + // Select shimmer placeholder because the actual shimmer is rendered after onLayout + // is fired and this logic is not a part of this test + expect(screen.queryByTestId('shimmer-placeholder')).toBeTruthy() + + rerender() + + expect(screen.queryByTestId('shimmer-placeholder')).toBeFalsy() + }) + + it('shows current portfolio value when available', () => { + render() + + expect(screen.queryByText('$100.00')).toBeTruthy() + }) + + it('shows placeholder text when portfolio value is not available', () => { + render() + + expect(screen.queryByText('N/A')).toBeTruthy() + }) + + it('shows cached portfolio value when not provided explicitly in props', async () => { + // We don't want to use the mocked query response for this test as we want to + // test if the cached value (returned by the query) is used when value is not provided + jest.restoreAllMocks() + const { resolvers: resolversWithPortfolioValue } = queryResolvers({ + portfolios: () => [portfolio({ tokensTotalDenominatedValue: amount({ value: 200 }) })], + }) + render(, { + resolvers: resolversWithPortfolioValue, + }) + + await waitFor(() => { + expect(screen.queryByText('$200.00')).toBeTruthy() + }) + }) + }) + + describe('view only accounts', () => { + it('renders view only badge when account is view only', () => { + render() + + const badge = screen.queryByTestId('account-icon/view-only-badge') + + expect(badge).toBeTruthy() + }) + + it('does not render view only badge when account is not view only', () => { + render() + + const badge = screen.queryByTestId('account-icon/view-only-badge') + + expect(badge).toBeFalsy() + }) + }) +}) diff --git a/apps/mobile/src/components/accounts/AccountCardItem.tsx b/apps/mobile/src/components/accounts/AccountCardItem.tsx new file mode 100644 index 00000000..19329f37 --- /dev/null +++ b/apps/mobile/src/components/accounts/AccountCardItem.tsx @@ -0,0 +1,244 @@ +import { SharedEventName } from '@uniswap/analytics-events' +import React, { useCallback, useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import ContextMenu from 'react-native-context-menu-view' +import { useDispatch } from 'react-redux' +import { MODAL_OPEN_WAIT_TIME } from 'src/app/navigation/constants' +import { navigate } from 'src/app/navigation/rootNavigation' +import { NotificationBadge } from 'src/components/notifications/Badge' +import { Flex, Text, TouchableArea } from 'ui/src' +import { iconSizes } from 'ui/src/theme' +import { AddressDisplay } from 'uniswap/src/components/accounts/AddressDisplay' +import { useUnitagsAddressQuery } from 'uniswap/src/data/apiClients/unitagsApi/useUnitagsAddressQuery' +import { AccountType } from 'uniswap/src/features/accounts/types' +import { useEnabledChains } from 'uniswap/src/features/chains/hooks/useEnabledChains' +import { useENS } from 'uniswap/src/features/ens/useENS' +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 { UnitagScreens } from 'uniswap/src/types/screens/mobile' +import { setClipboard } from 'utilities/src/clipboard/clipboard' +import { NumberType } from 'utilities/src/format/types' +import { noop } from 'utilities/src/react/noop' +import { useAccountListData } from 'wallet/src/features/accounts/useAccountListData' +import { useAccounts } from 'wallet/src/features/wallet/hooks' + +type AccountCardItemProps = { + address: Address + isViewOnly: boolean + onPress: (address: Address) => void + onClose: () => void +} & PortfolioValueProps + +type PortfolioValueProps = { + address: Address + isPortfolioValueLoading: boolean + portfolioValue: number | undefined +} + +function PortfolioValue({ + address, + isPortfolioValueLoading, + portfolioValue: providedPortfolioValue, +}: PortfolioValueProps): JSX.Element { + const { t } = useTranslation() + const { convertFiatAmountFormatted } = useLocalizationContext() + + // When we add a new wallet, we'll make a new network request to fetch all accounts as a single request. + // Since we're adding a new wallet address to the `ownerAddresses` array, this will be a brand new query, which won't be cached. + // To avoid all wallets showing a "loading" state, we read directly from cache while we wait for the other query to complete. + + const { data } = useAccountListData({ + fetchPolicy: 'cache-first', + addresses: [address], + }) + + const cachedPortfolioValue = data?.portfolios?.[0]?.tokensTotalDenominatedValue?.value + + const portfolioValue = providedPortfolioValue ?? cachedPortfolioValue + + const isLoading = isPortfolioValueLoading && portfolioValue === undefined + + return ( + + {portfolioValue === undefined + ? t('common.text.notAvailable') + : convertFiatAmountFormatted(portfolioValue, NumberType.PortfolioBalance)} + + ) +} + +export function AccountCardItem({ + address, + isViewOnly, + isPortfolioValueLoading, + portfolioValue, + onPress, + onClose, +}: AccountCardItemProps): JSX.Element { + const { t } = useTranslation() + const dispatch = useDispatch() + const { defaultChainId } = useEnabledChains() + const ensName = useENS({ nameOrAddress: address, chainId: defaultChainId }).name + const { data: unitag } = useUnitagsAddressQuery({ + params: address ? { address } : undefined, + }) + + const addressToAccount = useAccounts() + const selectedAccount = addressToAccount[address] + + const onlyLabeledWallet = ensName === null && unitag?.username === undefined + + const onPressCopyAddress = useCallback(async (): Promise => { + await setClipboard(address) + dispatch( + pushNotification({ + type: AppNotificationType.Copied, + copyType: CopyNotificationType.Address, + }), + ) + sendAnalyticsEvent(SharedEventName.ELEMENT_CLICKED, { + element: ElementName.CopyAddress, + modal: ModalName.AccountSwitcher, + }) + }, [address, dispatch]) + + const onPressEditWalletSettings = useCallback(() => { + onClose() + + if (selectedAccount?.type === AccountType.SignerMnemonic && !onlyLabeledWallet) { + navigate(ModalName.EditProfileSettingsModal, { + address, + accessPoint: UnitagScreens.UnitagConfirmation, + }) + } else { + navigate(ModalName.EditLabelSettingsModal, { + address, + accessPoint: UnitagScreens.UnitagConfirmation, + }) + } + }, [selectedAccount?.type, onlyLabeledWallet, address, onClose]) + + const onPressConnectionSettings = useCallback(() => { + onClose() + + //Wait 300ms to open the the connection Modal and avoid overlapping animation + setTimeout(() => { + navigate(ModalName.ConnectionsDappListModal, { + address, + }) + }, MODAL_OPEN_WAIT_TIME) + }, [address, onClose]) + + const onPressRemoveWallet = useCallback(() => { + onClose() + navigate(ModalName.RemoveWallet, { address }) + }, [address, onClose]) + + const menuActions = useMemo( + () => [ + { + title: t('account.wallet.action.copy'), + systemIcon: 'doc.on.doc', + onPress: onPressCopyAddress, + }, + ...(selectedAccount?.type === AccountType.Readonly + ? [ + { + title: t('settings.setting.wallet.action.editLabel'), + systemIcon: 'square.and.pencil', + onPress: onPressEditWalletSettings, + }, + ] + : []), + + ...(selectedAccount?.type === AccountType.Readonly + ? [ + { + title: t('account.wallet.button.remove'), + systemIcon: 'trash', + destructive: true, + onPress: onPressRemoveWallet, + }, + ] + : []), + + ...(selectedAccount?.type === AccountType.SignerMnemonic + ? [ + { + title: onlyLabeledWallet + ? t('settings.setting.wallet.action.editLabel') + : t('settings.setting.wallet.action.editProfile'), + systemIcon: 'square.and.pencil', + onPress: onPressEditWalletSettings, + }, + { + title: t('account.wallet.action.manageConnections'), + systemIcon: 'globe', + onPress: onPressConnectionSettings, + }, + { + title: t('account.wallet.button.remove'), + systemIcon: 'trash', + destructive: true, + onPress: onPressRemoveWallet, + }, + ] + : []), + ], + [ + t, + selectedAccount, + onlyLabeledWallet, + onPressCopyAddress, + onPressEditWalletSettings, + onPressConnectionSettings, + onPressRemoveWallet, + ], + ) + + return ( + => { + await menuActions[e.nativeEvent.index]?.onPress?.() + }} + > + onPress(address)} + > + + + + + + + + + ) +} + +const NotificationsBadgeContainer = ({ + children, + address, +}: { + children: React.ReactNode + address: string +}): JSX.Element => {children} diff --git a/apps/mobile/src/components/accounts/AccountHeader.test.tsx b/apps/mobile/src/components/accounts/AccountHeader.test.tsx new file mode 100644 index 00000000..a46fa6f1 --- /dev/null +++ b/apps/mobile/src/components/accounts/AccountHeader.test.tsx @@ -0,0 +1,117 @@ +import * as ExpoClipboard from 'expo-clipboard' +import { State } from 'react-native-gesture-handler' +import { fireGestureHandler, getByGestureTestId } from 'react-native-gesture-handler/jest-utils' +import { navigationRef } from 'src/app/navigation/navigationRef' +import { AccountHeader } from 'src/components/accounts/AccountHeader' +import { fireEvent, render, screen, waitFor, within } from 'src/test/test-utils' +import { ModalName } from 'uniswap/src/features/telemetry/constants' +import { ON_PRESS_EVENT_PAYLOAD } from 'uniswap/src/test/fixtures' +import { TestID } from 'uniswap/src/test/fixtures/testIDs' +import { MobileScreens } from 'uniswap/src/types/screens/mobile' +import { sanitizeAddressText } from 'uniswap/src/utils/addresses' +import { shortenAddress } from 'utilities/src/addresses' +import { ACCOUNT, preloadedWalletPackageState, signerMnemonicAccount } from 'wallet/src/test/fixtures' + +const preloadedState = preloadedWalletPackageState({ account: ACCOUNT }) +const address = ACCOUNT.address +const shortenedAddress = sanitizeAddressText(shortenAddress({ address }))! +const navigate = jest.fn() + +describe(AccountHeader, () => { + it('renders correctly', () => { + const tree = render(, { preloadedState }) + + expect(tree.toJSON()).toMatchSnapshot() + }) + + describe('when wallet has no display name', () => { + const accountWithoutName = signerMnemonicAccount({ name: undefined, address }) + const stateWithoutName = preloadedWalletPackageState({ + account: accountWithoutName, + }) + + it('renders shortened address within section address without name section', () => { + render(, { preloadedState: stateWithoutName }) + + const addressSection = screen.getByTestId(TestID.AccountHeaderCopyAddress) + const addressText = within(addressSection).queryByText(shortenedAddress) + + expect(addressText).toBeTruthy() + }) + + it('copies wallet address to clipboard when address section is pressed', async () => { + const setStringAsync = jest.fn() + jest.spyOn(ExpoClipboard, 'setStringAsync').mockImplementation(setStringAsync) + render(, { preloadedState: stateWithoutName }) + + const addressSection = screen.getByTestId(TestID.AccountHeaderCopyAddress) + fireEvent.press(addressSection, ON_PRESS_EVENT_PAYLOAD) + + await waitFor(() => { + expect(setStringAsync).toHaveBeenCalledTimes(1) + expect(setStringAsync).toHaveBeenCalledWith(address) + }) + }) + }) + + describe('when wallet has a display name', () => { + it('renders section with display name and address', () => { + render(, { preloadedState }) + + const displayNameSection = screen.getByTestId('account-header/display-name') + const displayNameText = within(displayNameSection).queryByText(ACCOUNT.name) + const addressText = within(displayNameSection).queryByText(shortenedAddress) + + expect(displayNameText).toBeTruthy() + expect(addressText).toBeTruthy() + }) + + it('opens account switcher modal when account name is pressed', async () => { + jest.spyOn(navigationRef, 'isReady').mockImplementation(() => true) + jest.spyOn(navigationRef, 'navigate').mockImplementation(navigate) + + render(, { preloadedState }) + + const displayNameText = within(screen.getByTestId('account-header/display-name')).getByText(ACCOUNT.name) + + fireEvent.press(displayNameText, ON_PRESS_EVENT_PAYLOAD) + + await waitFor(() => { + expect(navigate).toHaveBeenCalledTimes(1) + expect(navigate).toHaveBeenCalledWith(ModalName.AccountSwitcher, undefined) + }) + }) + }) + + it('opens account switcher modal when account avatar is pressed', async () => { + jest.spyOn(navigationRef, 'isReady').mockImplementation(() => true) + jest.spyOn(navigationRef, 'navigate').mockImplementation(navigate) + + render(, { preloadedState }) + + const avatar = screen.getByTestId('account-icon') + + fireEvent.press(avatar, ON_PRESS_EVENT_PAYLOAD) + + await waitFor(() => { + expect(navigate).toHaveBeenCalledTimes(1) + expect(navigate).toHaveBeenCalledWith(ModalName.AccountSwitcher, undefined) + }) + }) + + it('opens settings screen when settings button is pressed', async () => { + jest.spyOn(navigationRef, 'isReady').mockImplementation(() => true) + jest.spyOn(navigationRef, 'navigate').mockImplementation(navigate) + render(, { preloadedState }) + + const settingsButton = getByGestureTestId(TestID.AccountHeaderSettings) + fireGestureHandler(settingsButton, [{ state: State.ACTIVE }]) + + await waitFor(() => { + expect(navigate).toHaveBeenCalledTimes(1) + expect(navigate).toHaveBeenCalledWith(MobileScreens.SettingsStack, { + screen: MobileScreens.Settings, + }) + }) + }) +}) diff --git a/apps/mobile/src/components/accounts/AccountHeader.tsx b/apps/mobile/src/components/accounts/AccountHeader.tsx new file mode 100644 index 00000000..a53ed403 --- /dev/null +++ b/apps/mobile/src/components/accounts/AccountHeader.tsx @@ -0,0 +1,207 @@ +import { SharedEventName } from '@uniswap/analytics-events' +import { FeatureFlags, useFeatureFlag } from '@universe/gating' +import React, { useCallback, useEffect } from 'react' +import { Gesture, GestureDetector, State } from 'react-native-gesture-handler' +import Animated, { runOnJS, useAnimatedStyle, useSharedValue, withDelay, withTiming } from 'react-native-reanimated' +import { useDispatch } from 'react-redux' +import { navigate } from 'src/app/navigation/rootNavigation' +import { openModal } from 'src/features/modals/modalSlice' +import { removePendingSession } from 'src/features/walletConnect/walletConnectSlice' +import { Flex, Text, TouchableArea } from 'ui/src' +import { CopyAlt, ScanHome, SettingsHome } from 'ui/src/components/icons' +import { ScannerModalState } from 'uniswap/src/components/ReceiveQRCode/constants' +import { AccountIcon } from 'uniswap/src/features/accounts/AccountIcon' +import { AccountType, DisplayNameType } from 'uniswap/src/features/accounts/types' +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 { MobileUserPropertyName, setUserProperty } from 'uniswap/src/features/telemetry/user' +import { TestID } from 'uniswap/src/test/fixtures/testIDs' +import { MobileScreens } from 'uniswap/src/types/screens/mobile' +import { sanitizeAddressText } from 'uniswap/src/utils/addresses' +import { shortenAddress } from 'utilities/src/addresses' +import { setClipboard } from 'utilities/src/clipboard/clipboard' +import { isDevEnv } from 'utilities/src/environment/env' +import { AnimatedUnitagDisplayName } from 'wallet/src/components/accounts/AnimatedUnitagDisplayName' +import useIsFocused from 'wallet/src/features/focus/useIsFocused' +import { useActiveAccount, useActiveAccountAddress, useDisplayName } from 'wallet/src/features/wallet/hooks' + +// Value comes from https://uniswapteam.slack.com/archives/C083LU9SD9T/p1733425965373019?thread_ts=1733362029.171999&cid=C083LU9SD9T +const SCAN_ICON_ACTIVE_SCALE = 0.72 + +const RotatingSettingsIcon = ({ onPressSettings }: { onPressSettings(): void }): JSX.Element => { + const isScreenFocused = useIsFocused() + const pressProgress = useSharedValue(0) + + useEffect(() => { + if (isScreenFocused) { + pressProgress.value = withDelay(50, withTiming(0)) + } + // oxlint-disable-next-line react/exhaustive-deps -- biome-parity: oxlint is stricter here + }, [isScreenFocused]) + + const tap = Gesture.Tap() + .withTestId(TestID.AccountHeaderSettings) + .hitSlop(20) + .shouldCancelWhenOutside(true) + .onBegin(() => { + pressProgress.value = withTiming(1) + }) + .onFinalize(({ state }) => { + if (state === State.FAILED) { + pressProgress.value = withTiming(0) + } else if (state === State.END) { + runOnJS(onPressSettings)() + } + }) + + const animatedStyle = useAnimatedStyle(() => { + return { + transform: [{ rotate: `${pressProgress.value * 120}deg` }, { scale: 1 - pressProgress.value / 10 }], + opacity: 1 - pressProgress.value / 2, + justifyContent: 'center', + } + }) + + return ( + + + + + + ) +} + +export function AccountHeader(): JSX.Element { + const activeAddress = useActiveAccountAddress() + const account = useActiveAccount() + const dispatch = useDispatch() + + const displayName = useDisplayName(activeAddress) + + // Log ENS and Unitag ownership for user usage stats + useEffect(() => { + switch (displayName?.type) { + case DisplayNameType.ENS: + setUserProperty(MobileUserPropertyName.HasLoadedENS, true) + return + case DisplayNameType.Unitag: + setUserProperty(MobileUserPropertyName.HasLoadedUnitag, true) + return + default: + return + } + }, [displayName?.type]) + + const onPressAccountHeader = useCallback(() => { + navigate(ModalName.AccountSwitcher) + }, []) + + const onPressSettings = (): void => { + navigate(MobileScreens.SettingsStack, { screen: MobileScreens.Settings }) + } + + const onPressCopyAddress = async (): Promise => { + if (activeAddress) { + await setClipboard(activeAddress) + dispatch( + pushNotification({ + type: AppNotificationType.Copied, + copyType: CopyNotificationType.Address, + }), + ) + sendAnalyticsEvent(SharedEventName.ELEMENT_CLICKED, { + element: ElementName.CopyAddress, + screen: MobileScreens.Home, + }) + } + } + const onPressScan = useCallback(async () => { + // in case we received a pending session from a previous scan after closing modal. + dispatch(removePendingSession()) + dispatch(openModal({ name: ModalName.WalletConnectScan, initialState: ScannerModalState.ScanQr })) + }, [dispatch]) + + const walletHasName = displayName && displayName.type !== DisplayNameType.Address + const iconSize = 52 + + const isBottomTabsEnabled = useFeatureFlag(FeatureFlags.BottomTabs) + + return ( + + {activeAddress && ( + + + + => { + if (isDevEnv()) { + navigate(ModalName.Experiments) + } + }} + onPress={onPressAccountHeader} + > + + + {walletHasName ? ( + + + + + + ) : ( + + + + {sanitizeAddressText(shortenAddress({ address: activeAddress }))} + + + + + )} + + + + + + + + + + )} + + ) +} diff --git a/apps/mobile/src/components/accounts/AccountList.graphql b/apps/mobile/src/components/accounts/AccountList.graphql new file mode 100644 index 00000000..6cb994db --- /dev/null +++ b/apps/mobile/src/components/accounts/AccountList.graphql @@ -0,0 +1,17 @@ +query AccountList( + $addresses: [String!]! + $valueModifiers: [PortfolioValueModifier!] + $chains: [Chain!] +) { + portfolios( + ownerAddresses: $addresses + chains: $chains + valueModifiers: $valueModifiers + ) { + id + ownerAddress + tokensTotalDenominatedValue { + value + } + } +} diff --git a/apps/mobile/src/components/accounts/AccountList.test.tsx b/apps/mobile/src/components/accounts/AccountList.test.tsx new file mode 100644 index 00000000..0f8215b8 --- /dev/null +++ b/apps/mobile/src/components/accounts/AccountList.test.tsx @@ -0,0 +1,125 @@ +import { AccountList } from 'src/components/accounts/AccountList' +import { cleanup, fireEvent, render, screen } from 'src/test/test-utils' +import { Locale } from 'uniswap/src/features/language/constants' +import { amounts, ON_PRESS_EVENT_PAYLOAD, portfolio } from 'uniswap/src/test/fixtures' +import { mockLocalizedFormatter } from 'uniswap/src/test/mocks' +import { createArray, queryResolvers } from 'uniswap/src/test/utils' +import { sanitizeAddressText } from 'uniswap/src/utils/addresses' +import { shortenAddress } from 'utilities/src/addresses' +import { NumberType } from 'utilities/src/format/types' +import { ACCOUNT, readOnlyAccount, signerMnemonicAccount } from 'wallet/src/test/fixtures' + +const tokensTotalDenominatedValue = amounts.md() +const { resolvers } = queryResolvers({ + portfolios: () => [portfolio({ tokensTotalDenominatedValue })], +}) + +const formatter = mockLocalizedFormatter(Locale.EnglishUnitedStates) + +const defaultProps = { + onPress: jest.fn(), + onClose: jest.fn(), +} + +// Skip entering animation of AccountIcon +jest.mock('react-native-reanimated', () => { + const Reanimated = require('react-native-reanimated/mock') + + Reanimated.Layout = { duration: (): object => ({}) } + + return Reanimated +}) + +describe(AccountList, () => { + it('renders without error', async () => { + const tree = render(, { + resolvers, + }) + + expect( + await screen.findByText( + formatter.formatNumberOrString({ + value: tokensTotalDenominatedValue.value, + type: NumberType.PortfolioBalance, + currencyCode: 'usd', + }), + ), + ).toBeDefined() + expect(tree.toJSON()).toMatchSnapshot() + }) + + it('handles press on card items', async () => { + const onPressSpy = jest.fn() + render(, { + resolvers, + }) + // go to success state + expect( + await screen.findByText( + formatter.formatNumberOrString({ + value: tokensTotalDenominatedValue.value, + type: NumberType.PortfolioBalance, + currencyCode: 'usd', + }), + ), + ).toBeDefined() + + fireEvent.press(screen.getByTestId(`account-item/${ACCOUNT.address}`), ON_PRESS_EVENT_PAYLOAD) + + expect(onPressSpy).toHaveBeenCalledTimes(1) + }) + + describe('signer accounts', () => { + it('renders signer accounts section if there are signer accounts', () => { + const signerAccounts = createArray(3, signerMnemonicAccount) + render(, { + resolvers, + }) + + signerAccounts.forEach((account) => { + const address = sanitizeAddressText(shortenAddress({ address: account.address, chars: 6 })) + if (address) { + expect(screen.queryByText(address)).toBeTruthy() + } + }) + cleanup() + }) + + it('does not render signer accounts section if there are no signer accounts', () => { + render(, { + resolvers, + }) + + expect(screen.queryByText('Your other wallets')).toBeFalsy() + cleanup() + }) + }) + + describe('view only accounts', () => { + it('renders view only accounts section if there are view only accounts', () => { + const viewOnlyAccounts = createArray(3, readOnlyAccount) + render(, { + resolvers, + }) + + expect(screen.queryByText('View-only wallets')).toBeTruthy() + + viewOnlyAccounts.forEach((account) => { + const address = sanitizeAddressText(shortenAddress({ address: account.address, chars: 6 })) + if (address) { + expect(screen.queryByText(address)).toBeTruthy() + } + }) + cleanup() + }) + + it('does not render view only accounts section if there are no view only accounts', () => { + render(, { + resolvers, + }) + + expect(screen.queryByText('View-only wallets')).toBeFalsy() + cleanup() + }) + }) +}) diff --git a/apps/mobile/src/components/accounts/AccountList.tsx b/apps/mobile/src/components/accounts/AccountList.tsx new file mode 100644 index 00000000..ba81fdda --- /dev/null +++ b/apps/mobile/src/components/accounts/AccountList.tsx @@ -0,0 +1,206 @@ +import { useMutation } from '@tanstack/react-query' +import { isNonPollingRequestInFlight } from '@universe/api' +import { LinearGradient } from 'expo-linear-gradient' +import { ComponentProps, default as React, useCallback, useEffect, useMemo, useRef } from 'react' +import { useTranslation } from 'react-i18next' +import { StyleSheet } from 'react-native' +import { FlatList } from 'react-native-gesture-handler' +import { AccountCardItem } from 'src/components/accounts/AccountCardItem' +import { Flex, Text, useSporeColors } from 'ui/src' +import { opacify, spacing } from 'ui/src/theme' +import { PollingInterval } from 'uniswap/src/constants/misc' +import { AccountType } from 'uniswap/src/features/accounts/types' +import { logger } from 'utilities/src/logger/logger' +import { useEvent } from 'utilities/src/react/hooks' +import { useAccountListData } from 'wallet/src/features/accounts/useAccountListData' +import { Account } from 'wallet/src/features/wallet/accounts/types' + +type AccountListProps = Pick, 'onPress'> & { + accounts: Account[] + isVisible?: boolean + onClose: () => void +} + +type AccountWithPortfolioValue = { + account: Account + isPortfolioValueLoading: boolean + portfolioValue: number | undefined +} + +const ViewOnlyHeaderContent = (): JSX.Element => { + const { t } = useTranslation() + return ( + + + {t('account.wallet.header.viewOnly')} + + + ) +} + +enum AccountListItemType { + SignerAccount = 0, + ViewOnlyHeader = 1, + ViewOnlyAccount = 2, +} + +type AccountListItem = + | { type: AccountListItemType.SignerAccount; account: AccountWithPortfolioValue } + | { type: AccountListItemType.ViewOnlyHeader } + | { type: AccountListItemType.ViewOnlyAccount; account: AccountWithPortfolioValue } + +export function AccountList({ accounts, onPress, isVisible, onClose }: AccountListProps): JSX.Element { + const colors = useSporeColors() + const addresses = useMemo(() => accounts.map((a) => a.address), [accounts]) + const hasPollingRun = useRef(false) + + const { data, networkStatus, refetch, startPolling, stopPolling } = useAccountListData({ + addresses, + notifyOnNetworkStatusChange: true, + }) + + // Only poll account total values when the account list is visible + const controlPolling = useCallback(async () => { + if (hasPollingRun.current) { + return + } + + if (isVisible) { + refetch() + startPolling(PollingInterval.Fast) + } else { + stopPolling() + } + }, [isVisible, refetch, startPolling, stopPolling]) + + const controlPollingMutation = useMutation({ + mutationFn: controlPolling, + onSettled: () => { + hasPollingRun.current = true + }, + onError: (error) => { + logger.error(error, { + tags: { file: 'AccountList', function: 'controlPolling' }, + }) + }, + }) + + const controlPollingEvent = useEvent(controlPollingMutation.mutate) + + useEffect(() => { + controlPollingEvent() + }, [controlPollingEvent]) + + const isPortfolioValueLoading = isNonPollingRequestInFlight(networkStatus) + + const accountsWithPortfolioValue: AccountWithPortfolioValue[] = useMemo(() => { + return accounts.map((account, i) => { + return { + account, + isPortfolioValueLoading, + portfolioValue: data?.portfolios?.[i]?.tokensTotalDenominatedValue?.value, + } + }) + }, [accounts, data, isPortfolioValueLoading]) + + const signerAccounts = useMemo(() => { + return accountsWithPortfolioValue.filter((account) => account.account.type === AccountType.SignerMnemonic) + }, [accountsWithPortfolioValue]) + + const hasSignerAccounts = signerAccounts.length > 0 + + const viewOnlyAccounts = useMemo(() => { + return accountsWithPortfolioValue.filter((account) => account.account.type === AccountType.Readonly) + }, [accountsWithPortfolioValue]) + + const hasViewOnlyAccounts = viewOnlyAccounts.length > 0 + + const renderAccountCardItem = useCallback( + (item: AccountWithPortfolioValue): JSX.Element => ( + + ), + [onPress, onClose], + ) + + const accountsToRender = useMemo(() => { + const items: AccountListItem[] = [] + + if (hasSignerAccounts) { + items.push(...signerAccounts.map((account) => ({ type: AccountListItemType.SignerAccount, account }))) + } + + if (hasViewOnlyAccounts) { + items.push({ type: AccountListItemType.ViewOnlyHeader }) + items.push(...viewOnlyAccounts.map((account) => ({ type: AccountListItemType.ViewOnlyAccount, account }))) + } + + return items + }, [hasSignerAccounts, hasViewOnlyAccounts, signerAccounts, viewOnlyAccounts]) + + const renderItem = useCallback( + // oxlint-disable-next-line consistent-return + ({ item }: { item: AccountListItem }) => { + switch (item.type) { + case AccountListItemType.ViewOnlyHeader: + return + case AccountListItemType.SignerAccount: + case AccountListItemType.ViewOnlyAccount: + return renderAccountCardItem(item.account) + } + }, + [renderAccountCardItem], + ) + + const keyExtractor = useCallback( + (item: AccountListItem, index: number) => + item.type === AccountListItemType.SignerAccount || item.type === AccountListItemType.ViewOnlyAccount + ? item.account.account.address + : item.type.toString() + index, + [], + ) + + return ( + + {/* TODO(MOB-646): attempt to switch gradients to react-native-svg#LinearGradient and avoid new clear color */} + + + + + ) +} + +const ListSheet = StyleSheet.create({ + bottomGradient: { + bottom: 0, + height: spacing.spacing16, + left: 0, + position: 'absolute', + width: '100%', + }, + topGradient: { + height: spacing.spacing16, + left: 0, + position: 'absolute', + top: 0, + width: '100%', + zIndex: 1, + }, +}) diff --git a/apps/mobile/src/components/accounts/__snapshots__/AccountCardItem.test.tsx.snap b/apps/mobile/src/components/accounts/__snapshots__/AccountCardItem.test.tsx.snap new file mode 100644 index 00000000..df7bc813 --- /dev/null +++ b/apps/mobile/src/components/accounts/__snapshots__/AccountCardItem.test.tsx.snap @@ -0,0 +1,344 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`AccountCardItem renders correctly 1`] = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0x​82D56A...373Fa6 + + + + + + + + $100.00 + + + + +`; diff --git a/apps/mobile/src/components/accounts/__snapshots__/AccountHeader.test.tsx.snap b/apps/mobile/src/components/accounts/__snapshots__/AccountHeader.test.tsx.snap new file mode 100644 index 00000000..3cb87e97 --- /dev/null +++ b/apps/mobile/src/components/accounts/__snapshots__/AccountHeader.test.tsx.snap @@ -0,0 +1,730 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`AccountHeader renders correctly 1`] = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + Test Account + + + + + + 0x​82D5...3Fa6 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; diff --git a/apps/mobile/src/components/accounts/__snapshots__/AccountList.test.tsx.snap b/apps/mobile/src/components/accounts/__snapshots__/AccountList.test.tsx.snap new file mode 100644 index 00000000..b913096b --- /dev/null +++ b/apps/mobile/src/components/accounts/__snapshots__/AccountList.test.tsx.snap @@ -0,0 +1,377 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`AccountList renders without error 1`] = ` + + ExpoLinearGradient + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0x​82D56A...373Fa6 + + + + + + + + $55.00 + + + + + + + + ExpoLinearGradient + +`; diff --git a/apps/mobile/src/components/activity/ActivityContent.tsx b/apps/mobile/src/components/activity/ActivityContent.tsx new file mode 100644 index 00000000..e7ac49f3 --- /dev/null +++ b/apps/mobile/src/components/activity/ActivityContent.tsx @@ -0,0 +1,181 @@ +import type { LegendListRef } from '@legendapp/list' +import { LegendList } from '@legendapp/list' +import { useScrollToTop } from '@react-navigation/native' +import { FeatureFlags, useFeatureFlag } from '@universe/gating' +import type { ForwardedRef } from 'react' +import { forwardRef, memo, useMemo, useRef, useState } from 'react' +import type { FlatList } from 'react-native' +import { RefreshControl } from 'react-native' +import type Animated from 'react-native-reanimated' +import { useDispatch } from 'react-redux' +import { useAdaptiveFooter } from 'src/components/home/hooks' +import { AnimatedBottomSheetFlatList, AnimatedFlatList } from 'src/components/layout/AnimatedFlatList' +import type { TabProps } from 'src/components/layout/TabHelpers' +import { TAB_BAR_HEIGHT } from 'src/components/layout/TabHelpers' +import { useBiometricAppSettings } from 'src/features/biometrics/useBiometricAppSettings' +import { useBiometricPrompt } from 'src/features/biometricsSettings/hooks' +import { openModal } from 'src/features/modals/modalSlice' +import { removePendingSession } from 'src/features/walletConnect/walletConnectSlice' +import { Flex, Loader, useSporeColors } from 'ui/src' +import { ScannerModalState } from 'uniswap/src/components/ReceiveQRCode/constants' +import { ModalName } from 'uniswap/src/features/telemetry/constants' +import { useAppInsets } from 'uniswap/src/hooks/useAppInsets' +import { TestID } from 'uniswap/src/test/fixtures/testIDs' +import { DDRumManualTiming } from 'utilities/src/logger/datadog/datadogEvents' +import { usePerformanceLogger } from 'utilities/src/logger/usePerformanceLogger' +import { isAndroid } from 'utilities/src/platform' +import { useEvent } from 'utilities/src/react/hooks' +import { useActivityDataWallet } from 'wallet/src/features/activity/useActivityDataWallet' + +const ESTIMATED_ITEM_SIZE = 92 +const AMOUNT_TO_DRAW = 18 +const ON_END_REACHED_THRESHOLD = 0.1 // trigger onEndReached at 10% of visible length + +export const ActivityContent = memo( + forwardRef, TabProps>(function ActivityTabInner( + { + owner, + containerProps, + scrollHandler, + headerHeight, + isExternalProfile = false, + renderedInModal = false, + refreshing, + onRefresh, + }, + ref, + ) { + const dispatch = useDispatch() + const colors = useSporeColors() + const insets = useAppInsets() + + const isBottomTabsEnabled = useFeatureFlag(FeatureFlags.BottomTabs) + + const { trigger: biometricsTrigger } = useBiometricPrompt() + const { requiredForTransactions: requiresBiometrics } = useBiometricAppSettings() + + const { onContentSizeChange, adaptiveFooter } = useAdaptiveFooter(containerProps?.contentContainerStyle) + + const onPressReceive = useEvent((): void => { + // in case we received a pending session from a previous scan after closing modal + dispatch(removePendingSession()) + dispatch(openModal({ name: ModalName.WalletConnectScan, initialState: ScannerModalState.WalletQr })) + }) + + const { + maybeEmptyComponent, + renderActivityItem, + sectionData, + keyExtractor, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + refetch, + } = useActivityDataWallet({ + evmOwner: owner, + authTrigger: requiresBiometrics ? biometricsTrigger : undefined, + isExternalProfile, + emptyComponentStyle: containerProps?.emptyComponentStyle, + onPressEmptyState: onPressReceive, + }) + + usePerformanceLogger(DDRumManualTiming.RenderActivityTabList, []) + + const [isRefreshing, setIsRefreshing] = useState(false) + + const handleRefresh = useEvent(async () => { + setIsRefreshing(true) + try { + onRefresh?.() + await refetch() + } finally { + setIsRefreshing(false) + } + }) + + const refreshingAll = refreshing ?? isRefreshing + + const refreshControl = useMemo(() => { + const progressViewOffset = isBottomTabsEnabled + ? undefined + : insets.top + (isAndroid && headerHeight ? headerHeight + TAB_BAR_HEIGHT : 0) + + return ( + + ) + }, [isBottomTabsEnabled, insets.top, headerHeight, refreshingAll, colors.neutral3, handleRefresh]) + + const List = renderedInModal ? AnimatedBottomSheetFlatList : AnimatedFlatList + + const legendListRef = useRef(null) + useScrollToTop(legendListRef) + + return ( + + {isBottomTabsEnabled ? ( + + {isFetchingNextPage && } + {adaptiveFooter} + + ) + } + contentContainerStyle={containerProps?.contentContainerStyle} + refreshControl={refreshControl} + refreshing={refreshingAll} + onContentSizeChange={onContentSizeChange} + onEndReached={hasNextPage && !isFetchingNextPage ? fetchNextPage : undefined} + onEndReachedThreshold={ON_END_REACHED_THRESHOLD} + /> + ) : ( + >} + initialNumToRender={10} + keyExtractor={keyExtractor} + maxToRenderPerBatch={10} + refreshControl={refreshControl} + refreshing={refreshingAll} + renderItem={renderActivityItem} + showsVerticalScrollIndicator={false} + // `sectionData` will be either an array of transactions or an array of loading skeletons + data={sectionData} + estimatedItemSize={ESTIMATED_ITEM_SIZE} + ListEmptyComponent={maybeEmptyComponent} + // we add a footer to cover any possible space, so user can scroll the top menu all the way to the top + ListFooterComponent={ + isExternalProfile ? null : ( + + {isFetchingNextPage && } + {adaptiveFooter} + + ) + } + onScroll={scrollHandler} + onContentSizeChange={onContentSizeChange} + onEndReached={hasNextPage && !isFetchingNextPage ? fetchNextPage : undefined} + onEndReachedThreshold={ON_END_REACHED_THRESHOLD} + {...containerProps} + /> + )} + + ) + }), +) diff --git a/apps/mobile/src/components/banners/BottomBanner.tsx b/apps/mobile/src/components/banners/BottomBanner.tsx new file mode 100644 index 00000000..bebee383 --- /dev/null +++ b/apps/mobile/src/components/banners/BottomBanner.tsx @@ -0,0 +1,64 @@ +import React from 'react' +import { FadeIn, FadeOut, useAnimatedStyle, withTiming } from 'react-native-reanimated' +import { ColorTokens, Flex, Text, TouchableArea } from 'ui/src' +import { X } from 'ui/src/components/icons/X' +import { AnimatedFlex } from 'ui/src/components/layout/AnimatedFlex' + +export const BANNER_HEIGHT = 45 + +type BottomBannerProps = { + text: string + icon?: JSX.Element + backgroundColor?: ColorTokens + translateY?: number + onDismiss?: () => void +} + +export function BottomBanner({ text, icon, backgroundColor, translateY, onDismiss }: BottomBannerProps): JSX.Element { + const animatedStyle = useAnimatedStyle(() => ({ + transform: [ + { + // need to check for undefined since 0 is falsy + translateY: withTiming(translateY !== undefined ? -1 * translateY : -1 * BANNER_HEIGHT, { + duration: 200, + }), + }, + ], + })) + + return ( + + {icon} + + {text} + {onDismiss && ( + + + + )} + + + ) +} diff --git a/apps/mobile/src/components/buttons/BackButton.test.tsx b/apps/mobile/src/components/buttons/BackButton.test.tsx new file mode 100644 index 00000000..b2282cdf --- /dev/null +++ b/apps/mobile/src/components/buttons/BackButton.test.tsx @@ -0,0 +1,34 @@ +import React from 'react' +import { BackButton } from 'src/components/buttons/BackButton' +import { fireEvent, render, screen } from 'src/test/test-utils' +import { ON_PRESS_EVENT_PAYLOAD } from 'uniswap/src/test/fixtures' + +const mockedGoBack = jest.fn() +jest.mock('@react-navigation/native', () => { + const actualNav = jest.requireActual('@react-navigation/native') + return { + ...actualNav, + useNavigation: (): void => ({ + ...actualNav.useNavigation, + goBack: mockedGoBack, + }), + } +}) + +describe(BackButton, () => { + it('renders without error', async () => { + const tree = render() + + expect(tree).toMatchSnapshot() + expect(await screen.findByText('Back')).toBeDefined() + }) + + it('calls goBack', async () => { + render() + + const button = await screen.findByText('Back') + fireEvent.press(button, ON_PRESS_EVENT_PAYLOAD) + + expect(mockedGoBack).toHaveBeenCalledTimes(1) + }) +}) diff --git a/apps/mobile/src/components/buttons/BackButton.tsx b/apps/mobile/src/components/buttons/BackButton.tsx new file mode 100644 index 00000000..3a9024f1 --- /dev/null +++ b/apps/mobile/src/components/buttons/BackButton.tsx @@ -0,0 +1,35 @@ +import { useNavigation } from '@react-navigation/native' +import React from 'react' +import { BackButtonView } from 'src/components/layout/BackButtonView' +import { ColorTokens, TouchableArea, TouchableAreaProps } from 'ui/src' +import { IconSizeTokens } from 'ui/src/theme' +import { TestID } from 'uniswap/src/test/fixtures/testIDs' + +type Props = { + size?: IconSizeTokens + color?: ColorTokens + showButtonLabel?: boolean + onPressBack?: () => void +} & TouchableAreaProps + +export function BackButton({ onPressBack, size, color, showButtonLabel, ...rest }: Props): JSX.Element { + const navigation = useNavigation() + + const goBack = onPressBack + ? onPressBack + : (): void => { + navigation.goBack() + } + return ( + + + + ) +} diff --git a/apps/mobile/src/components/buttons/CloseButton.test.tsx b/apps/mobile/src/components/buttons/CloseButton.test.tsx new file mode 100644 index 00000000..9a2c70cc --- /dev/null +++ b/apps/mobile/src/components/buttons/CloseButton.test.tsx @@ -0,0 +1,21 @@ +import { CloseButton } from 'src/components/buttons/CloseButton' +import { fireEvent, render } from 'src/test/test-utils' +import { ON_PRESS_EVENT_PAYLOAD } from 'uniswap/src/test/fixtures' + +describe(CloseButton, () => { + it('renders without error', () => { + const tree = render() + + expect(tree).toMatchSnapshot() + }) + + it('calls onPress when pressed', async () => { + const onPress = jest.fn() + const { getByTestId } = render() + + const button = getByTestId('buttons/close-button') + fireEvent.press(button, ON_PRESS_EVENT_PAYLOAD) + + expect(onPress).toHaveBeenCalledTimes(1) + }) +}) diff --git a/apps/mobile/src/components/buttons/CloseButton.tsx b/apps/mobile/src/components/buttons/CloseButton.tsx new file mode 100644 index 00000000..4663e49f --- /dev/null +++ b/apps/mobile/src/components/buttons/CloseButton.tsx @@ -0,0 +1,18 @@ +import React from 'react' +import { ColorTokens, IconProps, TouchableArea, TouchableAreaProps } from 'ui/src' +import { X } from 'ui/src/components/icons' + +type Props = { + onPress: () => void + size?: IconProps['size'] + strokeWidth?: number + color?: ColorTokens +} & TouchableAreaProps + +export function CloseButton({ onPress, size, strokeWidth, color, ...rest }: Props): JSX.Element { + return ( + + + + ) +} diff --git a/apps/mobile/src/components/buttons/__snapshots__/BackButton.test.tsx.snap b/apps/mobile/src/components/buttons/__snapshots__/BackButton.test.tsx.snap new file mode 100644 index 00000000..d217b47b --- /dev/null +++ b/apps/mobile/src/components/buttons/__snapshots__/BackButton.test.tsx.snap @@ -0,0 +1,213 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`BackButton renders without error 1`] = ` + + + + + + + + + + + Back + + + +`; diff --git a/apps/mobile/src/components/buttons/__snapshots__/CloseButton.test.tsx.snap b/apps/mobile/src/components/buttons/__snapshots__/CloseButton.test.tsx.snap new file mode 100644 index 00000000..30a7b052 --- /dev/null +++ b/apps/mobile/src/components/buttons/__snapshots__/CloseButton.test.tsx.snap @@ -0,0 +1,124 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CloseButton renders without error 1`] = ` + + + + + + + +`; diff --git a/apps/mobile/src/components/buttons/utils.ts b/apps/mobile/src/components/buttons/utils.ts new file mode 100644 index 00000000..ef47d409 --- /dev/null +++ b/apps/mobile/src/components/buttons/utils.ts @@ -0,0 +1,9 @@ +import { WithSpringConfig, withSequence, withSpring } from 'react-native-reanimated' + +export function pulseAnimation( + activeScale: number, + spingAnimationConfig: WithSpringConfig = { damping: 1, stiffness: 200 }, +): number { + 'worklet' + return withSequence(withSpring(activeScale, spingAnimationConfig), withSpring(1, spingAnimationConfig)) +} diff --git a/apps/mobile/src/components/carousel/Carousel.tsx b/apps/mobile/src/components/carousel/Carousel.tsx new file mode 100644 index 00000000..b667dac7 --- /dev/null +++ b/apps/mobile/src/components/carousel/Carousel.tsx @@ -0,0 +1,75 @@ +import React, { ComponentProps, createContext, ReactNode, useCallback, useRef } from 'react' +import { ListRenderItemInfo } from 'react-native' +import Animated, { useAnimatedScrollHandler, useSharedValue } from 'react-native-reanimated' +import { AnimatedIndicator } from 'src/components/carousel/Indicator' +import { AnimatedFlatList } from 'src/components/layout/AnimatedFlatList' +import { Flex } from 'ui/src' +import { useDeviceDimensions } from 'ui/src/hooks/useDeviceDimensions' + +interface CarouselContextProps { + current: number + goToNext: () => void + goToPrev: () => void +} + +// Allows child components to control the carousel +export const CarouselContext = createContext({ + goToNext: () => undefined, + goToPrev: () => undefined, + current: 0, +}) + +type CarouselProps = { + slides: JSX.Element[] +} & Pick, 'scrollEnabled'> + +export function Carousel({ slides, ...flatListProps }: CarouselProps): JSX.Element { + const scroll = useSharedValue(0) + const { fullWidth } = useDeviceDimensions() + const myRef = useRef>(null) + + const scrollHandler = useAnimatedScrollHandler((event) => (scroll.value = event.contentOffset.x), [scroll]) + + const goToNext = useCallback(() => { + // @ts-expect-error https://github.com/software-mansion/react-native-reanimated/issues/2976 + myRef.current?._listRef._scrollRef.scrollTo({ + x: Math.ceil(scroll.value / fullWidth + 0.5) * fullWidth, + }) + // oxlint-disable-next-line react/exhaustive-deps -- biome-parity: oxlint is stricter here + }, [fullWidth]) + + const goToPrev = useCallback(() => { + // @ts-expect-error https://github.com/software-mansion/react-native-reanimated/issues/2976 + myRef.current?._listRef._scrollRef.scrollTo({ + x: Math.floor(scroll.value / fullWidth - 0.5) * fullWidth, + }) + // oxlint-disable-next-line react/exhaustive-deps -- biome-parity: oxlint is stricter here + }, [fullWidth]) + + return ( + + + + ): JSX.Element => ( + + {item} + + )} + scrollEnabled={true} + scrollEventThrottle={32} + showsHorizontalScrollIndicator={false} + onScroll={scrollHandler} + /> + + + ) +} + +const key = (_: JSX.Element, index: number): string => index.toString() diff --git a/apps/mobile/src/components/carousel/Indicator.tsx b/apps/mobile/src/components/carousel/Indicator.tsx new file mode 100644 index 00000000..a9467016 --- /dev/null +++ b/apps/mobile/src/components/carousel/Indicator.tsx @@ -0,0 +1,42 @@ +import React from 'react' +import { Extrapolate, interpolate, SharedValue, useAnimatedStyle } from 'react-native-reanimated' +import { Flex } from 'ui/src' +import { AnimatedFlex } from 'ui/src/components/layout/AnimatedFlex' +import { useDeviceDimensions } from 'ui/src/hooks/useDeviceDimensions' + +export function AnimatedIndicator({ + scroll, + stepCount, +}: { + scroll: SharedValue + stepCount: number +}): JSX.Element { + return ( + + {[...Array(stepCount)].map((_, i) => ( + + ))} + + ) +} + +function AnimatedIndicatorPill({ index, scroll }: { index: number; scroll: SharedValue }): JSX.Element { + const { fullWidth } = useDeviceDimensions() + const style = useAnimatedStyle(() => { + const inputRange = [(index - 1) * fullWidth, index * fullWidth, (index + 1) * fullWidth] + return { + opacity: interpolate(scroll.value, inputRange, [0.2, 1, 0.2], Extrapolate.CLAMP), + } + }) + + return ( + + ) +} diff --git a/apps/mobile/src/components/charts/DotGrid.tsx b/apps/mobile/src/components/charts/DotGrid.tsx new file mode 100644 index 00000000..148db091 --- /dev/null +++ b/apps/mobile/src/components/charts/DotGrid.tsx @@ -0,0 +1,41 @@ +import { memo } from 'react' +import { StyleSheet } from 'react-native' +import Svg, { Line } from 'react-native-svg' +import { useSporeColors } from 'ui/src' +import { opacify } from 'ui/src/theme' + +const ROW_SPACING = 17 +const COL_SPACING = 30 +const DOT_SIZE = 1.5 + +export const DotGrid = memo(function DotGrid({ + width, + height, + opacity = 1, +}: { + width: number + height: number + opacity?: number +}): JSX.Element { + const colors = useSporeColors() + const numRows = Math.floor(height / ROW_SPACING) + 1 + const dotColor = opacify(25, colors.neutral3.val) + + return ( + + {Array.from({ length: numRows }, (_, i) => ( + + ))} + + ) +}) diff --git a/apps/mobile/src/components/education/SeedPhrase.tsx b/apps/mobile/src/components/education/SeedPhrase.tsx new file mode 100644 index 00000000..0d7b25d1 --- /dev/null +++ b/apps/mobile/src/components/education/SeedPhrase.tsx @@ -0,0 +1,126 @@ +import React, { ComponentProps, ReactNode, useCallback, useContext, useMemo } from 'react' +import { Trans, useTranslation } from 'react-i18next' +import { Gesture, GestureDetector } from 'react-native-gesture-handler' +import { runOnJS } from 'react-native-reanimated' +import { OnboardingStackBaseParams, useOnboardingStackNavigation } from 'src/app/navigation/types' +import { CloseButton } from 'src/components/buttons/CloseButton' +import { CarouselContext } from 'src/components/carousel/Carousel' +import { Flex, Text } from 'ui/src' +import { useDeviceDimensions } from 'ui/src/hooks/useDeviceDimensions' +import { getCloudProviderName } from 'uniswap/src/utils/cloud-backup/getCloudProviderName' + +// oxlint-disable-next-line no-unused-vars -- biome-parity: oxlint is stricter here +function Page({ text, params }: { text: ReactNode; params: OnboardingStackBaseParams }): JSX.Element { + const { t } = useTranslation() + const { fullWidth } = useDeviceDimensions() + const { goToPrev, goToNext } = useContext(CarouselContext) + const navigation = useOnboardingStackNavigation() + + const onDismiss = useCallback((): void => { + navigation.goBack() + }, [navigation]) + + const slideChangeGesture = useMemo( + () => + Gesture.Tap().onEnd(({ absoluteX }) => { + if (absoluteX < fullWidth * 0.33) { + runOnJS(goToPrev)() + } else { + runOnJS(goToNext)() + } + }), + [goToPrev, goToNext, fullWidth], + ) + + const dismissGesture = useMemo( + () => + Gesture.Tap().onEnd(() => { + runOnJS(onDismiss)() + }), + [onDismiss], + ) + + return ( + + + + + + {t('onboarding.tooltip.recoveryPhrase.trigger')} + + + + + + + + {text} + + + + + ) +} + +const highlightComponent = + +const cloudProviderName = getCloudProviderName() + +const pageContentList = [ + , + , + , + , + , + , + , + , + , +] + +export const SeedPhraseEducationContent = (params: OnboardingStackBaseParams): JSX.Element[] => { + return pageContentList.map((content, i) => ( + {content}} /> + )) +} + +function CustomHeadingText(props: ComponentProps): JSX.Element { + return +} diff --git a/apps/mobile/src/components/experiments/SeedPhraseAndPrivateKeysDevSection.tsx b/apps/mobile/src/components/experiments/SeedPhraseAndPrivateKeysDevSection.tsx new file mode 100644 index 00000000..2d979328 --- /dev/null +++ b/apps/mobile/src/components/experiments/SeedPhraseAndPrivateKeysDevSection.tsx @@ -0,0 +1,64 @@ +import React from 'react' +import { Alert } from 'react-native' +import { Accordion, Flex } from 'ui/src' +import { GatingButton } from 'uniswap/src/components/gating/GatingButton' +import { AccordionHeader } from 'uniswap/src/components/gating/GatingOverrides' +import { TestID } from 'uniswap/src/test/fixtures/testIDs' +import { Keyring } from 'wallet/src/features/wallet/Keyring/Keyring' + +export function SeedPhraseAndPrivateKeysDevSection(): JSX.Element { + return ( + + + + + Delete Seed Phrase (Irreversible) + + + Delete Private Keys (Irreversible) + + + + ) +} + +function onDeleteSeedPhrase(): void { + alertHelper({ + title: 'Delete Seed Phrases', + message: 'Are you sure you want to delete all seed phrases? This action cannot be undone.', + onPress: async () => { + const mnemonicIds = await Keyring.getMnemonicIds() + for (const mnemonicId of mnemonicIds) { + await Keyring.removeMnemonic(mnemonicId) + } + }, + }) +} + +function onDeletePrivateKeys(): void { + alertHelper({ + title: 'Delete Private Keys', + message: 'Are you sure you want to delete all private keys? This action cannot be undone.', + onPress: async () => { + const addresses = await Keyring.getAddressesForStoredPrivateKeys() + for (const address of addresses) { + await Keyring.removePrivateKey(address) + } + }, + }) +} + +function alertHelper({ + title, + message, + onPress, +}: { + title: string + message: string + onPress: () => Promise +}): void { + Alert.alert(title, message, [ + { text: 'Cancel', style: 'cancel' }, + { text: 'Delete', style: 'destructive', onPress }, + ]) +} diff --git a/apps/mobile/src/components/experiments/ServerOverrides.tsx b/apps/mobile/src/components/experiments/ServerOverrides.tsx new file mode 100644 index 00000000..a5e468da --- /dev/null +++ b/apps/mobile/src/components/experiments/ServerOverrides.tsx @@ -0,0 +1,86 @@ +import React, { useState } from 'react' +import { useDispatch, useSelector } from 'react-redux' +import { selectCustomEndpoint } from 'src/features/tweaks/selectors' +import { setCustomEndpoint } from 'src/features/tweaks/slice' +import { Accordion, Flex, Text } from 'ui/src' +import { GatingButton } from 'uniswap/src/components/gating/GatingButton' +import { AccordionHeader } from 'uniswap/src/components/gating/GatingOverrides' +import { TextInput } from 'uniswap/src/components/input/TextInput' +import { pushNotification } from 'uniswap/src/features/notifications/slice/slice' +import { AppNotificationType } from 'uniswap/src/features/notifications/slice/types' + +export function ServerOverrides(): JSX.Element { + const dispatch = useDispatch() + const customEndpoint = useSelector(selectCustomEndpoint) + + const [url, setUrl] = useState(customEndpoint?.url || '') + const [key, setKey] = useState(customEndpoint?.key || '') + + const clearEndpoint = (): void => { + dispatch(setCustomEndpoint({})) + setUrl('') + setKey('') + dispatch( + pushNotification({ + type: AppNotificationType.Success, + title: 'Custom endpoint cleared', + hideDelay: 3000, + }), + ) + } + + const setEndpoint = (): void => { + if (url && key) { + dispatch( + setCustomEndpoint({ + customEndpoint: { url, key }, + }), + ) + dispatch( + pushNotification({ + type: AppNotificationType.Success, + title: `Custom endpoint configured successfully (${url})`, + hideDelay: 3000, + }), + ) + } else { + clearEndpoint() + } + } + + return ( + + + + + + + + You will need to restart the application to pick up any changes in this section. Beware of client side + caching! + + + + + URL + + + + + Key + + + + + + Clear + + Set + + + + + + + ) +} diff --git a/apps/mobile/src/components/explore/ExploreSections/ExploreSections.tsx b/apps/mobile/src/components/explore/ExploreSections/ExploreSections.tsx new file mode 100644 index 00000000..e284ccf8 --- /dev/null +++ b/apps/mobile/src/components/explore/ExploreSections/ExploreSections.tsx @@ -0,0 +1,447 @@ +import { LegendList, LegendListRef } from '@legendapp/list' +import { useScrollToTop } from '@react-navigation/native' +import { + TokenRankingsResponse, + TokenRankingsStat, + TokenStats, +} from '@uniswap/client-explore/dist/uniswap/explore/v1/service_pb' +import { ALL_NETWORKS_ARG } from '@universe/api' +import { FeatureFlags, useFeatureFlag } from '@universe/gating' +import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { + type NativeScrollEvent, + type NativeSyntheticEvent, + ScrollView, + StyleSheet, + useWindowDimensions, +} from 'react-native' +import { FlatList } from 'react-native-gesture-handler' +import { AnimatedRef, useAnimatedRef } from 'react-native-reanimated' +import Sortable from 'react-native-sortables' +import { useDispatch, useSelector } from 'react-redux' +import { ESTIMATED_BOTTOM_TABS_HEIGHT } from 'src/app/navigation/tabs/CustomTabBar/constants' +import { ExploreScreenParams } from 'src/app/navigation/types' +import { FavoritesSection } from 'src/components/explore/ExploreSections/FavoritesSection' +import { NetworkPills, NetworkPillsProps } from 'src/components/explore/ExploreSections/NetworkPillsRow' +import { SortButton } from 'src/components/explore/SortButton' +import { TokenItem } from 'src/components/explore/TokenItem' +import { TokenItemData } from 'src/components/explore/TokenItemData' +import { getTokenMetadataDisplayType } from 'src/features/explore/utils' +import { Flex, Loader, Text } from 'ui/src' +import { AnimatedBottomSheetFlashList } from 'ui/src/components/AnimatedFlashList/AnimatedFlashList' +import { NoTokens } from 'ui/src/components/icons' +import { spacing } from 'ui/src/theme' +import { BaseCard } from 'uniswap/src/components/BaseCard/BaseCard' +import { useTokenRankingsQuery } from 'uniswap/src/data/rest/tokenRankings' +import type { UniverseChainId } from 'uniswap/src/features/chains/types' +import { fromGraphQLChain } from 'uniswap/src/features/chains/utils' +import { MobileEventName } from 'uniswap/src/features/telemetry/constants' +import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { useAppInsets } from 'uniswap/src/hooks/useAppInsets' +import { buildCurrencyId, buildNativeCurrencyId } from 'uniswap/src/utils/currencyId' +import { DDRumManualTiming } from 'utilities/src/logger/datadog/datadogEvents' +import { usePerformanceLogger } from 'utilities/src/logger/usePerformanceLogger' +import { useEvent } from 'utilities/src/react/hooks' +import { useInitialLoadingState } from 'utilities/src/react/useInitialLoadingState' +import { selectTokensOrderBy } from 'wallet/src/features/wallet/selectors' +import { setTokensOrderBy } from 'wallet/src/features/wallet/slice' +import { ExploreOrderBy, TokenMetadataDisplayType } from 'wallet/src/features/wallet/types' + +const TOKEN_ITEM_SIZE = 68 +const AMOUNT_TO_DRAW = 18 + +type TokenItemDataWithMetadata = { tokenItemData: TokenItemData; tokenMetadataDisplayType: TokenMetadataDisplayType } + +type ExploreSectionsProps = ExploreScreenParams & { + listRef: AnimatedRef + setIsAtTopOnScroll?: (isAtTop: boolean) => void +} + +const renderItem = ({ + item: { tokenItemData, tokenMetadataDisplayType }, + index, +}: { + item: TokenItemDataWithMetadata + index: number +}): JSX.Element => { + return ( + + ) +} + +function ExploreSectionsInner({ + listRef, + showFavorites = true, + orderByMetric, + chainId, + setIsAtTopOnScroll, +}: ExploreSectionsProps): JSX.Element { + const { t } = useTranslation() + const insets = useAppInsets() + const dimensions = useWindowDimensions() + const isBottomTabsEnabled = useFeatureFlag(FeatureFlags.BottomTabs) + + // Top tokens sorting + const { uiOrderBy, orderBy, onOrderByChange } = useOrderBy() + + // Network filtering + const [selectedNetwork, setSelectedNetwork] = useState(null) + + // Track scroll position for double-tap behavior + const handleScroll = useEvent((event: NativeSyntheticEvent) => { + if (!isBottomTabsEnabled || !setIsAtTopOnScroll) { + return + } + const yOffset = event.nativeEvent.contentOffset.y + const isAtTop = yOffset <= 0 + setIsAtTopOnScroll(isAtTop) + }) + + // Update selectedNetwork and orderBy when chainId prop changes (e.g., from deep links) + useEffect(() => { + setSelectedNetwork(chainId ?? null) + if (orderByMetric) { + onOrderByChange(orderByMetric) + } + }, [chainId, onOrderByChange, orderByMetric]) + + const { data, isLoading, error, refetch, isFetching } = useTokenRankingsQuery({ + chainId: selectedNetwork?.toString() ?? ALL_NETWORKS_ARG, + }) + const isInitialLoading = useInitialLoadingState(isLoading) + + const topTokenItems = useTokenItems(data, orderBy) + + usePerformanceLogger(DDRumManualTiming.RenderExploreSections, [selectedNetwork, orderBy]) + + // Need multiple refs until bottom tabs experiment is complete + const legendListRef = useRef(null) + const scrollRef = useAnimatedRef() + + useScrollToTop(listRef) + useScrollToTop(scrollRef) + + const onRetry = useCallback(async () => { + await refetch() + }, [refetch]) + + const onSelectNetwork = useCallback((network: UniverseChainId | null) => { + sendAnalyticsEvent(MobileEventName.ExploreNetworkSelected, { + networkChainId: network ?? 'all', + }) + setSelectedNetwork(network) + }, []) + + const hasAllData = !!data + const isLoadingOrFetching = isLoading || isFetching + const showFullScreenLoadingState = (!hasAllData && isLoadingOrFetching) || (!!error && isLoadingOrFetching) + + const contentContainerStyleWithoutBottomTabs = useMemo(() => { + return { + paddingBottom: insets.bottom, + } + }, [insets.bottom]) + + const contentContainerStyleWithBottomTabs = useMemo(() => { + return { + paddingBottom: ESTIMATED_BOTTOM_TABS_HEIGHT + spacing.spacing32 + insets.bottom, + } + }, [insets.bottom]) + + const dataWithBottomTabs = useMemo( + () => (showFullScreenLoadingState ? [] : topTokenItems), + [showFullScreenLoadingState, topTokenItems], + ) + + const listEmptyComponent = useMemo( + () => , + [showFullScreenLoadingState], + ) + + if (!hasAllData && error) { + return ( + + + + ) + } + + if (isBottomTabsEnabled) { + return ( + + + } + ListHeaderComponentStyle={styles.foreground} + contentContainerStyle={contentContainerStyleWithBottomTabs} + data={dataWithBottomTabs} + keyExtractor={tokenKey} + renderItem={renderItem} + showsHorizontalScrollIndicator={false} + showsVerticalScrollIndicator={false} + estimatedItemSize={TOKEN_ITEM_SIZE} + drawDistance={TOKEN_ITEM_SIZE * AMOUNT_TO_DRAW} + estimatedListSize={dimensions} + onScroll={handleScroll} + /> + + ) + } + + return ( + + + } + ListHeaderComponentStyle={styles.foreground} + contentContainerStyle={contentContainerStyleWithoutBottomTabs} + data={showFullScreenLoadingState ? undefined : topTokenItems} + keyExtractor={tokenKey} + scrollEventThrottle={16} + renderItem={renderItem} + showsHorizontalScrollIndicator={false} + showsVerticalScrollIndicator={false} + estimatedItemSize={TOKEN_ITEM_SIZE} + drawDistance={TOKEN_ITEM_SIZE * AMOUNT_TO_DRAW} + estimatedListSize={dimensions} + /> + + ) +} + +const tokenKey = (token: TokenItemDataWithMetadata, index: number): string => { + return `${ + token.tokenItemData.address + ? buildCurrencyId(token.tokenItemData.chainId, token.tokenItemData.address) + : buildNativeCurrencyId(token.tokenItemData.chainId) + }-${index}` +} + +function tokenRankingStatsToTokenItemData(tokenRankingStat: TokenRankingsStat): TokenItemData | null { + const formattedChain = fromGraphQLChain(tokenRankingStat.chain) + + if (!formattedChain) { + return null + } + + return { + name: tokenRankingStat.name ?? '', + logoUrl: tokenRankingStat.logo ?? '', + chainId: formattedChain, + address: tokenRankingStat.address, + symbol: tokenRankingStat.symbol ?? '', + price: tokenRankingStat.price?.value, + marketCap: tokenRankingStat.fullyDilutedValuation?.value, + pricePercentChange24h: tokenRankingStat.pricePercentChange1Day?.value, + volume24h: tokenRankingStat.volume1Day?.value, + totalValueLocked: tokenRankingStat.totalValueLocked?.value, + } +} + +const styles = StyleSheet.create({ + foreground: { + zIndex: 1, + }, +}) + +function getTokenMetadataDisplayTypeSafe(orderBy: ExploreOrderBy): TokenMetadataDisplayType | null { + try { + return getTokenMetadataDisplayType(orderBy) + } catch { + return null + } +} + +function processTokens( + tokens: TokenStats[], + tokenMetadataDisplayType: TokenMetadataDisplayType, +): TokenItemDataWithMetadata[] { + const validTokens = tokens.filter(Boolean) + const processedTokens: TokenItemDataWithMetadata[] = [] + + for (const token of validTokens) { + const tokenItemData = tokenRankingStatsToTokenItemData(token) + if (tokenItemData) { + processedTokens.push({ tokenItemData, tokenMetadataDisplayType }) + } + } + + return processedTokens +} + +function processTokenRankings( + tokenRankings: TokenRankingsResponse['tokenRankings'] | undefined, +): Partial> { + if (!tokenRankings) { + return {} as const + } + + const result: Record = {} + + for (const [orderByKey, rankings] of Object.entries(tokenRankings)) { + const tokenMetadataDisplayType = getTokenMetadataDisplayTypeSafe(orderByKey as ExploreOrderBy) + if (tokenMetadataDisplayType === null) { + continue + } + + const processedTokens = processTokens(rankings.tokens, tokenMetadataDisplayType) + + if (processedTokens.length > 0) { + result[orderByKey] = processedTokens + } + } + + return result +} + +function useTokenItems(data: TokenRankingsResponse | undefined, orderBy: ExploreOrderBy): TokenItemDataWithMetadata[] { + // process all the token rankings into a map of orderBy to token items (only do this once) + const allTokenItemsByOrderBy = useMemo(() => processTokenRankings(data?.tokenRankings), [data]) + // return the token items for the given orderBy, or empty array if the orderBy key doesn't exist + return useMemo(() => allTokenItemsByOrderBy[orderBy] ?? [], [allTokenItemsByOrderBy, orderBy]) +} + +type ListHeaderProps = { + listRef: AnimatedRef | AnimatedRef + orderBy: ExploreOrderBy + showLoading: boolean + showFavorites: boolean + onOrderByChange: (orderBy: ExploreOrderBy) => void +} + +const ListHeader = memo(function ListHeader({ + listRef, + orderBy, + showLoading, + showFavorites, + onOrderByChange, +}: ListHeaderProps): JSX.Element { + const { t } = useTranslation() + + return ( + + {showFavorites && } + + + {t('explore.tokens.top.title')} + + + + + + + ) +}) + +const ListHeaderComponent = ({ + listRef, + onSelectNetwork, + orderBy, + selectedNetwork, + showLoading, + showFavorites, + onOrderByChange, +}: ListHeaderProps & NetworkPillsProps): JSX.Element => { + return ( + <> + + + + ) +} + +const TokenListEmptyComponent = memo(function TokenListEmptyComponent({ + isLoading, +}: { + isLoading: boolean +}): JSX.Element { + const { t } = useTranslation() + + if (isLoading) { + return ( + + + + ) + } + + return ( + + } + title={t('explore.tokens.empty.title')} + /> + + ) +}) + +function useOrderBy(): { + uiOrderBy: ExploreOrderBy + orderBy: ExploreOrderBy + onOrderByChange: (orderBy: ExploreOrderBy) => void +} { + const dispatch = useDispatch() + const orderBy = useSelector(selectTokensOrderBy) + + // local state for immediate UI feedback + const [uiOrderBy, setUiOrderBy] = useState(orderBy) + + // When Redux orderBy changes, sync UI + useEffect(() => { + setUiOrderBy(orderBy) + }, [orderBy]) + + const onOrderByChange = useCallback( + (newTokensOrderBy: ExploreOrderBy) => { + setUiOrderBy(newTokensOrderBy) + requestAnimationFrame(() => { + dispatch(setTokensOrderBy({ newTokensOrderBy })) + }) + }, + [dispatch], + ) + + return { uiOrderBy, orderBy, onOrderByChange } +} + +export const ExploreSections = memo(ExploreSectionsInner) diff --git a/apps/mobile/src/components/explore/ExploreSections/FavoritesSection.tsx b/apps/mobile/src/components/explore/ExploreSections/FavoritesSection.tsx new file mode 100644 index 00000000..0e718c61 --- /dev/null +++ b/apps/mobile/src/components/explore/ExploreSections/FavoritesSection.tsx @@ -0,0 +1,29 @@ +import type { ScrollView } from 'react-native' +import type { FlatList } from 'react-native-gesture-handler' +import type { AnimatedRef } from 'react-native-reanimated' +import { useSelector } from 'react-redux' +import { FavoriteTokensGrid } from 'src/components/explore/FavoriteTokensGrid' +import { FavoriteWalletsGrid } from 'src/components/explore/FavoriteWalletsGrid' +import { Flex } from 'ui/src' +import { selectHasFavoriteTokens, selectHasWatchedWallets } from 'uniswap/src/features/favorites/selectors' + +type FavoritesSectionProps = { + showLoading: boolean + listRef: AnimatedRef | AnimatedRef +} + +export function FavoritesSection(props: FavoritesSectionProps): JSX.Element | null { + const hasFavoritedTokens = useSelector(selectHasFavoriteTokens) + const hasFavoritedWallets = useSelector(selectHasWatchedWallets) + + if (!hasFavoritedTokens && !hasFavoritedWallets) { + return null + } + + return ( + + {hasFavoritedTokens && } + {hasFavoritedWallets && } + + ) +} diff --git a/apps/mobile/src/components/explore/ExploreSections/NetworkPillsRow.tsx b/apps/mobile/src/components/explore/ExploreSections/NetworkPillsRow.tsx new file mode 100644 index 00000000..f1efdad8 --- /dev/null +++ b/apps/mobile/src/components/explore/ExploreSections/NetworkPillsRow.tsx @@ -0,0 +1,146 @@ +import { useTheme } from '@react-navigation/core' +import { memo, useCallback, useMemo, useRef } from 'react' +import { useTranslation } from 'react-i18next' +import { ViewStyle } from 'react-native' +import { FlatList } from 'react-native-gesture-handler' +import { useFlatListAutoScroll } from 'src/components/explore/hooks/useFlatListAutoScroll' +import { Flex, Text, TouchableArea, useSporeColors } from 'ui/src' +import { iconSizes, spacing } from 'ui/src/theme' +import { NetworkLogo } from 'uniswap/src/components/CurrencyLogo/NetworkLogo' +import { NetworkPill } from 'uniswap/src/components/network/NetworkPill' +import { useEnabledChains } from 'uniswap/src/features/chains/hooks/useEnabledChains' +import type { UniverseChainId } from 'uniswap/src/features/chains/types' +import { TestID } from 'uniswap/src/test/fixtures/testIDs' +import { useEvent } from 'utilities/src/react/hooks' + +const keyExtractor = (chainId: UniverseChainId): string => chainId.toString() + +const AllNetworksPill = memo(function AllNetworksPill({ + onPress, + selected, +}: { + onPress: () => void + selected: boolean +}): JSX.Element { + const { t } = useTranslation() + return ( + + + {t('common.all')} + + ) +}) + +const contentContainerStyle: ViewStyle = { + alignItems: 'center', + gap: spacing.spacing8, + paddingHorizontal: spacing.spacing8, +} + +const NetworkPillsRow = memo(function NetworkPillsRow({ + selectedNetwork, + onSelectNetwork, +}: { + selectedNetwork: UniverseChainId | null + onSelectNetwork: (chainId: UniverseChainId | null) => void +}): JSX.Element { + const colors = useSporeColors() + const theme = useTheme() + const { chains } = useEnabledChains() + const flatListRef = useRef>(null) + + // Auto-scroll to selected network when it changes + useFlatListAutoScroll({ + flatListRef, + selectedItem: selectedNetwork, + items: chains, + }) + + // oxlint-disable-next-line react/exhaustive-deps -- need theme dep for foregroundColor to change on theme change + const renderItemNetworkPills = useCallback( + ({ item }: { item: UniverseChainId }) => { + return ( + onSelectNetwork(item)}> + + + ) + }, + // oxlint-disable-next-line react/exhaustive-deps -- biome-parity: oxlint is stricter here + [colors.neutral1.val, onSelectNetwork, selectedNetwork, theme], + ) + + const ListHeaderComponent = useMemo(() => { + return onSelectNetwork(null)} /> + }, [selectedNetwork, onSelectNetwork]) + + const handleScrollToIndexFailed = useEvent( + (info: { index: number; highestMeasuredFrameIndex: number; averageItemLength: number }) => { + // Fallback to scroll to offset if scrollToIndex fails + const offset = info.averageItemLength * info.index + flatListRef.current?.scrollToOffset({ offset, animated: true }) + }, + ) + + return ( + + + + ) +}) + +export type NetworkPillsProps = { + selectedNetwork: UniverseChainId | null + onSelectNetwork: (chainId: UniverseChainId | null) => void +} + +export const NetworkPills = memo(function NetworkPills({ + selectedNetwork, + onSelectNetwork, +}: NetworkPillsProps): JSX.Element { + const handleOnSelectNetwork = useCallback( + (network: UniverseChainId | null) => { + setImmediate(() => onSelectNetwork(network)) + }, + [onSelectNetwork], + ) + + return +}) diff --git a/apps/mobile/src/components/explore/FavoriteHeaderRow.test.tsx b/apps/mobile/src/components/explore/FavoriteHeaderRow.test.tsx new file mode 100644 index 00000000..bf4246b0 --- /dev/null +++ b/apps/mobile/src/components/explore/FavoriteHeaderRow.test.tsx @@ -0,0 +1,81 @@ +import { FavoriteHeaderRow } from 'src/components/explore/FavoriteHeaderRow' +import { fireEvent, render } from 'src/test/test-utils' +import { ON_PRESS_EVENT_PAYLOAD } from 'uniswap/src/test/fixtures' +import { TestID } from 'uniswap/src/test/fixtures/testIDs' + +const defaultProps = { + title: 'Title', + editingTitle: 'Editing Title', + isEditing: false, + onPress: jest.fn(), +} + +describe(FavoriteHeaderRow, () => { + describe('when not editing', () => { + it('renders without error', () => { + const tree = render() + + expect(tree.toJSON()).toMatchSnapshot() + }) + + it('renders title', () => { + const { queryByText } = render() + + expect(queryByText(defaultProps.title)).toBeTruthy() + expect(queryByText(defaultProps.editingTitle)).toBeFalsy() + }) + + it('renders favorite button', () => { + const { queryByTestId } = render() + + const favoriteButton = queryByTestId(TestID.Edit) + const doneButton = queryByTestId(TestID.Done) + + expect(favoriteButton).toBeTruthy() + expect(doneButton).toBeFalsy() + }) + + it('calls onPress when favorite icon pressed', () => { + const { getByTestId } = render() + + const favoriteButton = getByTestId(TestID.Edit) + fireEvent.press(favoriteButton, ON_PRESS_EVENT_PAYLOAD) + + expect(defaultProps.onPress).toHaveBeenCalledTimes(1) + }) + }) + + describe('when editing', () => { + it('renders without error', () => { + const tree = render() + + expect(tree.toJSON()).toMatchSnapshot() + }) + + it('renders editingTitle', () => { + const { queryByText } = render() + + expect(queryByText(defaultProps.editingTitle)).toBeTruthy() + expect(queryByText(defaultProps.title)).toBeFalsy() + }) + + it('renders done button', () => { + const { queryByTestId } = render() + + const favoriteButton = queryByTestId(TestID.Edit) + const doneButton = queryByTestId(TestID.Done) + + expect(favoriteButton).toBeFalsy() + expect(doneButton).toBeTruthy() + }) + + it('calls onPress when done button pressed', () => { + const { getByTestId } = render() + + const doneButton = getByTestId(TestID.Done) + fireEvent.press(doneButton, ON_PRESS_EVENT_PAYLOAD) + + expect(defaultProps.onPress).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/apps/mobile/src/components/explore/FavoriteHeaderRow.tsx b/apps/mobile/src/components/explore/FavoriteHeaderRow.tsx new file mode 100644 index 00000000..4d476057 --- /dev/null +++ b/apps/mobile/src/components/explore/FavoriteHeaderRow.tsx @@ -0,0 +1,47 @@ +import { default as React } from 'react' +import { useTranslation } from 'react-i18next' +import { Flex, Text, TouchableArea } from 'ui/src' +import { Ellipsis } from 'ui/src/components/icons' +import { TestID } from 'uniswap/src/test/fixtures/testIDs' + +export function FavoriteHeaderRow({ + title, + editingTitle, + isEditing, + onPress, + disabled, +}: { + title: string + editingTitle: string + isEditing: boolean + disabled?: boolean + onPress: () => void +}): JSX.Element { + const { t } = useTranslation() + return ( + + + {isEditing ? editingTitle : title} + + {!isEditing ? ( + + + + ) : ( + + + {t('common.button.done')} + + + )} + + ) +} diff --git a/apps/mobile/src/components/explore/FavoriteTokenCard.test.tsx b/apps/mobile/src/components/explore/FavoriteTokenCard.test.tsx new file mode 100644 index 00000000..f86a41e4 --- /dev/null +++ b/apps/mobile/src/components/explore/FavoriteTokenCard.test.tsx @@ -0,0 +1,179 @@ +import configureMockStore from 'redux-mock-store' +import { thunk } from 'redux-thunk' +import FavoriteTokenCard, { FavoriteTokenCardProps } from 'src/components/explore/FavoriteTokenCard' +import { act, cleanup, fireEvent, render, waitFor } from 'src/test/test-utils' +import { FiatCurrency } from 'uniswap/src/features/fiatCurrency/constants' +import { Language } from 'uniswap/src/features/language/constants' +import { + amount, + ethToken, + ON_PRESS_EVENT_PAYLOAD, + SAMPLE_CURRENCY_ID_1, + tokenMarket, + tokenProject, + tokenProjectMarket, +} from 'uniswap/src/test/fixtures' +import { TestID } from 'uniswap/src/test/fixtures/testIDs' +import { queryResolvers } from 'uniswap/src/test/utils' +import { getSymbolDisplayText } from 'uniswap/src/utils/currency' + +const mockedNavigation = { + navigate: jest.fn(), +} + +jest.mock('@react-navigation/native', () => { + const actualNav = jest.requireActual('@react-navigation/native') + return { + ...actualNav, + // oxlint-disable-next-line typescript/explicit-function-return-type + useNavigation: () => mockedNavigation, + } +}) + +const mockStore = configureMockStore([thunk]) + +const favoriteToken = ethToken({ + project: { + ...tokenProject(), + markets: [ + { + ...tokenProjectMarket(), + price: amount({ value: 76543.21 }), + pricePercentChange24h: amount({ value: 6.54 }), + }, + ], + }, + market: tokenMarket({ + price: amount({ value: 12345.67 }), + pricePercentChange: amount({ value: 4.56 }), + }), +}) + +const touchableId = `${TestID.FavoriteTokenCardPrefix}${favoriteToken.symbol}` + +const defaultProps: FavoriteTokenCardProps = { + currencyId: SAMPLE_CURRENCY_ID_1, + setIsEditing: jest.fn(), + isEditing: false, +} + +const { resolvers } = queryResolvers({ + token: () => favoriteToken, +}) + +describe('FavoriteTokenCard', () => { + it('renders without error', async () => { + const tree = render() + + expect(tree).toMatchSnapshot() + cleanup() + }) + + describe('when token data is being fetched', () => { + it('renders loader', async () => { + const { queryByTestId } = render(, { resolvers }) + + const loaderPrice = queryByTestId('loader/favorite/price') + const loaderPriceChange = queryByTestId('loader/favorite/priceChange') + + expect(loaderPrice).toBeTruthy() + expect(loaderPriceChange).toBeTruthy() + + await waitFor(() => { + expect(queryByTestId(touchableId)).toBeTruthy() + }) + }) + }) + + describe('when token data is available', () => { + const cases = [ + { test: 'symbol', value: getSymbolDisplayText(favoriteToken.symbol)! }, + { test: 'price', value: '$76,543.21' }, + { test: 'relative price change', value: '6.54%' }, + ] + + it.each(cases)('renders correct $test', async ({ value }) => { + const { queryByText } = render(, { resolvers }) + + await waitFor(() => { + expect(queryByText(value)).toBeTruthy() + }) + }) + + it('falls back to token price if token project price is not available', async () => { + const { resolvers: modifiedResolvers } = queryResolvers({ + token: () => ({ + ...favoriteToken, + project: { ...favoriteToken.project, markets: [] }, + }), + }) + + const { queryByText } = render(, { resolvers: modifiedResolvers }) + + await waitFor(() => { + expect(queryByText('$12,345.67')).toBeTruthy() + expect(queryByText('4.56%')).toBeTruthy() + }) + }) + + it('navigates to the token details screen when pressed', async () => { + const { findByTestId } = render(, { resolvers }) + + const touchable = await findByTestId(`${TestID.FavoriteTokenCardPrefix}${favoriteToken.symbol}`) + act(() => { + fireEvent.press(touchable, ON_PRESS_EVENT_PAYLOAD) + }) + + expect(mockedNavigation.navigate).toHaveBeenCalledTimes(1) + expect(mockedNavigation.navigate).toHaveBeenCalledWith('TokenDetails', { + currencyId: SAMPLE_CURRENCY_ID_1, // passed in component props + }) + }) + + it('does not show remove button when not in edit mode', async () => { + const { findByTestId } = render(, { resolvers }) + + const removeButton = await findByTestId('explore/remove-button') + + await waitFor(() => { + expect(removeButton).toHaveAnimatedStyle({ opacity: 0 }) + }) + }) + }) + + describe('edit mode', () => { + it('shows remove button when in edit mode', async () => { + const { findByTestId } = render(, { + resolvers, + }) + + const removeButton = await findByTestId('explore/remove-button') + + await waitFor(() => { + expect(removeButton).toHaveAnimatedStyle({ opacity: 1 }) + }) + }) + + it('dispatches removeFavoriteToken action when remove button is pressed', async () => { + const store = mockStore({ + favorites: { tokens: [] }, + userSettings: { currentCurrency: FiatCurrency.UnitedStatesDollar, currentLanguage: Language.English }, + }) + const { findByTestId } = render(, { + resolvers, + store, + }) + + const removeButton = await findByTestId('explore/remove-button') + act(() => { + fireEvent.press(removeButton, ON_PRESS_EVENT_PAYLOAD) + }) + + const actions = store.getActions() + expect(actions).toContainEqual({ + type: 'favorites/removeFavoriteToken', + payload: { currencyId: SAMPLE_CURRENCY_ID_1 }, + }) + }) + }) +}) diff --git a/apps/mobile/src/components/explore/FavoriteTokenCard.tsx b/apps/mobile/src/components/explore/FavoriteTokenCard.tsx new file mode 100644 index 00000000..1de319a8 --- /dev/null +++ b/apps/mobile/src/components/explore/FavoriteTokenCard.tsx @@ -0,0 +1,216 @@ +import { GraphQLApi, isNonPollingRequestInFlight } from '@universe/api' +import React, { memo, useMemo } from 'react' +import type { StyleProp, ViewProps, ViewStyle } from 'react-native' +import ContextMenu from 'react-native-context-menu-view' +import { useDispatch } from 'react-redux' +import { useExploreTokenContextMenu } from 'src/components/explore/hooks' +import RemoveButton from 'src/components/explore/RemoveButton' +import { Loader } from 'src/components/loading/loaders' +import { useTokenDetailsNavigation } from 'src/components/TokenDetails/hooks' +import { usePollOnFocusOnly } from 'src/utils/hooks' +import { AnimatedTouchableArea, Flex, Text, useIsDarkMode, useShadowPropsShort, useSporeColors } from 'ui/src' +import { borderRadii, fonts, imageSizes } from 'ui/src/theme' +import { TokenLogo } from 'uniswap/src/components/CurrencyLogo/TokenLogo' +import { RelativeChange } from 'uniswap/src/components/RelativeChange/RelativeChange' +import { PollingInterval } from 'uniswap/src/constants/misc' +import { useEnabledChains } from 'uniswap/src/features/chains/hooks/useEnabledChains' +import { fromGraphQLChain } from 'uniswap/src/features/chains/utils' +import { currencyIdToContractInput } from 'uniswap/src/features/dataApi/utils/currencyIdToContractInput' +import { removeFavoriteToken } from 'uniswap/src/features/favorites/slice' +import { useLocalizationContext } from 'uniswap/src/features/language/LocalizationContext' +import { SectionName } from 'uniswap/src/features/telemetry/constants' +import { TestID } from 'uniswap/src/test/fixtures/testIDs' +import { getSymbolDisplayText } from 'uniswap/src/utils/currency' +import { NumberType } from 'utilities/src/format/types' +import { isIOS } from 'utilities/src/platform' +import { useEvent } from 'utilities/src/react/hooks' +import { noop } from 'utilities/src/react/noop' + +const ESTIMATED_FAVORITE_TOKEN_CARD_LOADER_HEIGHT = 116 + +const contextMenuStyle: StyleProp = { + borderRadius: borderRadii.rounded16, +} + +export type FavoriteTokenCardProps = { + currencyId: string + isEditing?: boolean + setIsEditing: (update: boolean) => void + showLoading?: boolean +} & ViewProps + +// oxlint-disable-next-line complexity -- biome-parity: oxlint is stricter here +function FavoriteTokenCard({ + currencyId, + isEditing, + setIsEditing, + showLoading, + ...rest +}: FavoriteTokenCardProps): JSX.Element { + const dispatch = useDispatch() + const { defaultChainId } = useEnabledChains() + const tokenDetailsNavigation = useTokenDetailsNavigation() + const { convertFiatAmountFormatted } = useLocalizationContext() + + const colors = useSporeColors() + const isDarkMode = useIsDarkMode() + + const { data, loading, networkStatus, startPolling, stopPolling } = GraphQLApi.useFavoriteTokenCardQuery({ + variables: currencyIdToContractInput(currencyId), + // Rely on cache for fast favoriting UX, and poll for updates. + fetchPolicy: 'cache-and-network', + returnPartialData: true, + }) + + usePollOnFocusOnly({ startPolling, stopPolling, pollingInterval: PollingInterval.Fast }) + + const token = data?.token + + // Mirror behavior in top tokens list, use first chain the token is on for the symbol + const chainId = fromGraphQLChain(token?.chain) ?? defaultChainId + + // Coingecko price is more accurate but lacks long tail tokens + // Uniswap price comes from Uniswap pools, which may be updated less frequently + const { price, pricePercentChange } = getCoingeckoPrice(token) ?? getUniswapPrice(token) + const priceFormatted = useMemo( + () => convertFiatAmountFormatted(price, NumberType.FiatTokenPrice), + [convertFiatAmountFormatted, price], + ) + + const onRemove = useEvent(() => { + if (currencyId) { + dispatch(removeFavoriteToken({ currencyId })) + } + }) + + const onEditFavorites = useEvent(() => { + setIsEditing(true) + }) + + const { menuActions, onContextMenuPress } = useExploreTokenContextMenu({ + chainId, + currencyId, + analyticsSection: SectionName.ExploreFavoriteTokensSection, + onEditFavorites, + tokenName: token?.name, + }) + + const onPress = useEvent(() => { + if (isEditing || !currencyId) { + return + } + tokenDetailsNavigation.preload(currencyId) + tokenDetailsNavigation.navigate(currencyId) + }) + + const shadowProps = useShadowPropsShort() + + const priceLoading = isNonPollingRequestInFlight(networkStatus) + + const symbolDisplayText = useMemo(() => getSymbolDisplayText(token?.symbol), [token?.symbol]) + + if (showLoading) { + return ( + + ) + } + + return ( + + + + + + + {symbolDisplayText} + + + + + {priceLoading ? ( + + ) : ( + + {priceFormatted} + + )} + {priceLoading ? ( + + ) : ( + + )} + + + + + ) +} + +function getCoingeckoPrice(token?: GraphQLApi.FavoriteTokenCardQuery['token']): { + price: number | undefined + pricePercentChange: number | undefined +} | null { + const market = token?.project?.markets?.[0] + if (!market?.price?.value || !market.pricePercentChange24h?.value) { + return null + } + + return { + price: market.price.value, + pricePercentChange: market.pricePercentChange24h.value, + } +} + +function getUniswapPrice(token?: GraphQLApi.FavoriteTokenCardQuery['token']): { + price: number | undefined + pricePercentChange: number | undefined +} { + return { + price: token?.market?.price?.value, + pricePercentChange: token?.market?.pricePercentChange?.value, + } +} + +export default memo(FavoriteTokenCard) diff --git a/apps/mobile/src/components/explore/FavoriteTokensGrid.tsx b/apps/mobile/src/components/explore/FavoriteTokensGrid.tsx new file mode 100644 index 00000000..73d6de94 --- /dev/null +++ b/apps/mobile/src/components/explore/FavoriteTokensGrid.tsx @@ -0,0 +1,127 @@ +import React, { useCallback, useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import type { ScrollView } from 'react-native' +import { FlatList } from 'react-native-gesture-handler' +import type { AnimatedRef } from 'react-native-reanimated' +import { FadeIn } from 'react-native-reanimated' +import type { SortableGridDragEndCallback, SortableGridRenderItem } from 'react-native-sortables' +import Sortable from 'react-native-sortables' +import { useDispatch, useSelector } from 'react-redux' +import { FavoriteHeaderRow } from 'src/components/explore/FavoriteHeaderRow' +import FavoriteTokenCard from 'src/components/explore/FavoriteTokenCard' +import { getTokenValue } from 'ui/src' +import { AnimatedFlex } from 'ui/src/components/layout/AnimatedFlex' +import { Flex } from 'ui/src/components/layout/Flex' +import { ExpandoRow } from 'uniswap/src/components/ExpandoRow/ExpandoRow' +import { selectFavoriteTokens } from 'uniswap/src/features/favorites/selectors' +import { setFavoriteTokens } from 'uniswap/src/features/favorites/slice' +import { useHapticFeedback } from 'uniswap/src/features/settings/useHapticFeedback/useHapticFeedback' + +const NUM_COLUMNS = 2 +const DEFAULT_TOKENS_TO_DISPLAY = 4 + +type FavoriteTokensGridProps = { + showLoading: boolean + listRef: AnimatedRef | AnimatedRef +} + +/** Renders the favorite tokens section on the Explore tab */ +export function FavoriteTokensGrid({ showLoading, listRef, ...rest }: FavoriteTokensGridProps): JSX.Element | null { + const { t } = useTranslation() + const { hapticFeedback } = useHapticFeedback() + const dispatch = useDispatch() + + const [isEditing, setIsEditing] = useState(false) + const [showAll, setShowAll] = useState(false) + const favoriteCurrencyIds = useSelector(selectFavoriteTokens) + + // Reset edit mode when there are no favorite tokens + useEffect(() => { + if (favoriteCurrencyIds.length === 0) { + setIsEditing(false) + } + }, [favoriteCurrencyIds.length]) + + // Automatically expand when entering edit mode + useEffect(() => { + if (isEditing) { + setShowAll(true) + } + }, [isEditing]) + + const handleDragStart = useCallback(async () => { + await hapticFeedback.light() + }, [hapticFeedback]) + + const hasMoreTokens = favoriteCurrencyIds.length > DEFAULT_TOKENS_TO_DISPLAY + const visibleTokens = + showAll || !hasMoreTokens ? favoriteCurrencyIds : favoriteCurrencyIds.slice(0, DEFAULT_TOKENS_TO_DISPLAY) + + const GRID_GAP = getTokenValue('$spacing8') + + const handleDragEnd = useCallback>( + async ({ data }) => { + await hapticFeedback.light() + if (showAll || !hasMoreTokens) { + dispatch(setFavoriteTokens({ currencyIds: data })) + } else { + // merge reordered visible tokens with hidden ones + const hiddenTokens = favoriteCurrencyIds.slice(DEFAULT_TOKENS_TO_DISPLAY) + dispatch(setFavoriteTokens({ currencyIds: [...data, ...hiddenTokens] })) + } + }, + [hapticFeedback, dispatch, showAll, favoriteCurrencyIds, hasMoreTokens], + ) + + const renderItem = useCallback>( + ({ item: currencyId }): JSX.Element => { + return ( + + ) + }, + [isEditing, showLoading], + ) + + return ( + + + setIsEditing(!isEditing)} + /> + + + + {hasMoreTokens && ( + setShowAll((value: boolean) => !value)} + /> + )} + + + + ) +} diff --git a/apps/mobile/src/components/explore/FavoriteWalletCard.test.tsx b/apps/mobile/src/components/explore/FavoriteWalletCard.test.tsx new file mode 100644 index 00000000..88db7a95 --- /dev/null +++ b/apps/mobile/src/components/explore/FavoriteWalletCard.test.tsx @@ -0,0 +1,177 @@ +import { UseQueryResult } from '@tanstack/react-query' +import type { UnitagAddressResponse } from '@universe/api' +import configureMockStore from 'redux-mock-store' +import { thunk } from 'redux-thunk' +import FavoriteWalletCard, { FavoriteWalletCardProps } from 'src/components/explore/FavoriteWalletCard' +import { preloadedMobileState } from 'src/test/fixtures' +import { fireEvent, render, waitFor } from 'src/test/test-utils' +import * as unitagHooks from 'uniswap/src/data/apiClients/unitagsApi/useUnitagsAddressQuery' +import * as ensHooks from 'uniswap/src/features/ens/api' +import { ON_PRESS_EVENT_PAYLOAD, SAMPLE_SEED_ADDRESS_1 } from 'uniswap/src/test/fixtures' +import { MobileScreens } from 'uniswap/src/types/screens/mobile' +import { sanitizeAddressText } from 'uniswap/src/utils/addresses' +import { shortenAddress } from 'utilities/src/addresses' +import { preloadedWalletReducerState, signerMnemonicAccount } from 'wallet/src/test/fixtures' + +const mockedNavigation = { + navigate: jest.fn(), +} + +jest.mock('@react-navigation/native', () => { + const actualNav = jest.requireActual('@react-navigation/native') + return { + ...actualNav, + // oxlint-disable-next-line typescript/explicit-function-return-type + useNavigation: () => mockedNavigation, + } +}) + +const mockStore = configureMockStore([thunk]) + +const defaultProps: FavoriteWalletCardProps = { + address: SAMPLE_SEED_ADDRESS_1, + isEditing: false, + setIsEditing: jest.fn(), +} + +describe('FavoriteWalletCard', () => { + it('renders without error', () => { + const tree = render() + + expect(tree).toMatchSnapshot() + }) + + describe('displayName', () => { + afterEach(() => { + jest.restoreAllMocks() + }) + + it('renders unitag name if available', () => { + jest.spyOn(unitagHooks, 'useUnitagsAddressQuery').mockReturnValue({ + data: { username: 'unitagname' }, + isLoading: false, + isFetching: false, + isPending: false, + error: null, + isError: false, + isLoadingError: false, + isRefetchError: false, + isSuccess: true, + status: 'success', + refetch: jest.fn(), + dataUpdatedAt: 0, + errorUpdatedAt: 0, + failureCount: 0, + failureReason: null, + fetchStatus: 'idle', + isPlaceholderData: false, + isRefetching: false, + isStale: false, + isInitialLoading: false, + isEnabled: true, + errorUpdateCount: 0, + isFetched: true, + isFetchedAfterMount: true, + isPaused: false, + promise: Promise.resolve({ username: 'unitagname' }), + } as UseQueryResult) + + const { queryByText } = render() + + expect(queryByText('unitagname')).toBeTruthy() + }) + + it('renders ens name if available', () => { + jest.spyOn(ensHooks, 'useENSName').mockReturnValue({ + data: 'ensname.eth', + isLoading: false, + error: null, + } as ReturnType) + + const { queryByText } = render() + + expect(queryByText('ensname.eth')).toBeTruthy() + }) + + it('renders local name if wallet name is set locally', () => { + const { queryByText } = render(, { + preloadedState: preloadedMobileState({ + wallet: preloadedWalletReducerState({ + account: signerMnemonicAccount({ + address: defaultProps.address, + name: 'Local account', + }), + }), + }), + }) + + expect(queryByText('Local account')).toBeTruthy() + }) + + it('renders wallet address in other cases', () => { + const { queryByText } = render() + + const displayedAddress = sanitizeAddressText(shortenAddress({ address: defaultProps.address }))! + + expect(queryByText(displayedAddress)).toBeTruthy() + }) + }) + + describe('when not editing', () => { + it('navigates to the wallet details screen when pressed', () => { + const { getByTestId } = render() + + const touchable = getByTestId('favorite-wallet-card') + fireEvent.press(touchable, ON_PRESS_EVENT_PAYLOAD) + + expect(mockedNavigation.navigate).toHaveBeenCalledWith(MobileScreens.ExternalProfile, { + address: defaultProps.address, + }) + }) + + it('does not display the remove button', async () => { + const { getByTestId } = render() + + const removeButton = getByTestId('explore/remove-button') + + await waitFor(() => { + expect(removeButton).toHaveAnimatedStyle({ opacity: 0 }) + }) + }) + }) + + describe('when editing', () => { + it('displays the remove button', async () => { + const { getByTestId } = render() + + const removeButton = getByTestId('explore/remove-button') + + await waitFor(() => { + expect(removeButton).toHaveAnimatedStyle({ opacity: 1 }) + }) + }) + + it('dispatches removeWatchedAddress when remove button is pressed', () => { + const store = mockStore({ + favorites: { tokens: [], watchedAddresses: [defaultProps.address] }, + wallet: { + accounts: { + [defaultProps.address]: signerMnemonicAccount({ address: defaultProps.address }), + }, + }, + userSettings: {}, + }) + const { getByTestId } = render(, { + store, + }) + + const removeButton = getByTestId('explore/remove-button') + fireEvent.press(removeButton, ON_PRESS_EVENT_PAYLOAD) + + expect(store.getActions()).toContainEqual({ + type: 'favorites/removeWatchedAddress', + payload: { address: defaultProps.address }, + }) + }) + }) +}) diff --git a/apps/mobile/src/components/explore/FavoriteWalletCard.tsx b/apps/mobile/src/components/explore/FavoriteWalletCard.tsx new file mode 100644 index 00000000..38d9d997 --- /dev/null +++ b/apps/mobile/src/components/explore/FavoriteWalletCard.tsx @@ -0,0 +1,108 @@ +import { memo, useCallback, useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { ViewProps } from 'react-native' +import ContextMenu from 'react-native-context-menu-view' +import { useDispatch } from 'react-redux' +import { useEagerExternalProfileNavigation } from 'src/app/navigation/hooks' +import RemoveButton from 'src/components/explore/RemoveButton' +import { Flex, TouchableArea, useIsDarkMode, useShadowPropsShort, useSporeColors } from 'ui/src' +import { borderRadii, iconSizes } from 'ui/src/theme' +import { DisplayNameText } from 'uniswap/src/components/accounts/DisplayNameText' +import { AccountIcon } from 'uniswap/src/features/accounts/AccountIcon' +import { DisplayNameType } from 'uniswap/src/features/accounts/types' +import { removeWatchedAddress } from 'uniswap/src/features/favorites/slice' +import { isIOS } from 'utilities/src/platform' +import { noop } from 'utilities/src/react/noop' +import { useDisplayName } from 'wallet/src/features/wallet/hooks' + +export type FavoriteWalletCardProps = { + address: Address + isEditing?: boolean + setIsEditing: (update: boolean) => void +} & ViewProps + +function FavoriteWalletCard({ address, isEditing, setIsEditing, ...rest }: FavoriteWalletCardProps): JSX.Element { + const { t } = useTranslation() + const dispatch = useDispatch() + const colors = useSporeColors() + const isDarkMode = useIsDarkMode() + + const { preload, navigate } = useEagerExternalProfileNavigation() + + const displayName = useDisplayName(address) + + const icon = useMemo(() => { + return + }, [address]) + + const onRemove = useCallback(() => { + dispatch(removeWatchedAddress({ address })) + }, [address, dispatch]) + + /// Options for long press context menu + const menuActions = useMemo(() => { + return [ + { title: t('explore.wallets.favorite.action.remove'), systemIcon: 'heart.slash.fill' }, + { title: t('explore.wallets.favorite.action.edit'), systemIcon: 'square.and.pencil' }, + ] + }, [t]) + + const shadowProps = useShadowPropsShort() + + return ( + { + // Emitted index based on order of menu action array + // remove favorite action + if (e.nativeEvent.index === 0) { + onRemove() + } + // Edit mode toggle action + if (e.nativeEvent.index === 1) { + setIsEditing(true) + } + }} + {...rest} + > + { + navigate(address) + }} + onPressIn={async (): Promise => { + await preload(address) + }} + {...shadowProps} + > + + + {icon} + + + + + + + ) +} + +export default memo(FavoriteWalletCard) diff --git a/apps/mobile/src/components/explore/FavoriteWalletsGrid.tsx b/apps/mobile/src/components/explore/FavoriteWalletsGrid.tsx new file mode 100644 index 00000000..68a85a22 --- /dev/null +++ b/apps/mobile/src/components/explore/FavoriteWalletsGrid.tsx @@ -0,0 +1,96 @@ +import { default as React, useCallback, useEffect, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import type { ScrollView } from 'react-native' +import { FlatList } from 'react-native-gesture-handler' +import type { AnimatedRef } from 'react-native-reanimated' +import { FadeIn } from 'react-native-reanimated' +import type { SortableGridDragEndCallback, SortableGridRenderItem } from 'react-native-sortables' +import Sortable from 'react-native-sortables' +import { useDispatch, useSelector } from 'react-redux' +import { FavoriteHeaderRow } from 'src/components/explore/FavoriteHeaderRow' +import FavoriteWalletCard from 'src/components/explore/FavoriteWalletCard' +import { Loader } from 'src/components/loading/loaders' +import { Flex } from 'ui/src' +import { AnimatedFlex } from 'ui/src/components/layout/AnimatedFlex' +import { selectWatchedAddressSet } from 'uniswap/src/features/favorites/selectors' +import { setFavoriteWallets } from 'uniswap/src/features/favorites/slice' + +const NUM_COLUMNS = 2 +const ITEM_FLEX = { flex: 1 / NUM_COLUMNS } + +type FavoriteWalletsGridProps = { + showLoading: boolean + listRef: AnimatedRef | AnimatedRef +} + +/** Renders the favorite wallets section on the Explore tab */ +export function FavoriteWalletsGrid({ showLoading, listRef, ...rest }: FavoriteWalletsGridProps): JSX.Element { + const { t } = useTranslation() + const dispatch = useDispatch() + + const [isEditing, setIsEditing] = useState(false) + const watchedWalletsSet = useSelector(selectWatchedAddressSet) + const watchedWalletsList = useMemo(() => Array.from(watchedWalletsSet), [watchedWalletsSet]) + + // Reset edit mode when there are no favorite wallets + useEffect(() => { + if (watchedWalletsSet.size === 0) { + setIsEditing(false) + } + }, [watchedWalletsSet.size]) + + const handleDragEnd = useCallback>( + ({ data }) => { + dispatch(setFavoriteWallets({ addresses: data })) + }, + [dispatch], + ) + + const renderItem = useCallback>( + ({ item: address }): JSX.Element => ( + + ), + [isEditing], + ) + + return ( + + + setIsEditing(!isEditing)} + /> + {showLoading ? ( + + ) : ( + + )} + + + ) +} + +function FavoriteWalletsGridLoader(): JSX.Element { + return ( + + + + + + + + + ) +} diff --git a/apps/mobile/src/components/explore/RemoveButton.test.tsx b/apps/mobile/src/components/explore/RemoveButton.test.tsx new file mode 100644 index 00000000..1f93e6bc --- /dev/null +++ b/apps/mobile/src/components/explore/RemoveButton.test.tsx @@ -0,0 +1,39 @@ +import RemoveButton from 'src/components/explore/RemoveButton' +import { fireEvent, render } from 'src/test/test-utils' +import { ON_PRESS_EVENT_PAYLOAD } from 'uniswap/src/test/fixtures' + +describe(RemoveButton, () => { + it('renders without error', () => { + const tree = render() + + expect(tree.toJSON()).toMatchSnapshot() + }) + + it('calls onPress when pressed', () => { + const onPress = jest.fn() + const { getByTestId } = render() + + const button = getByTestId('explore/remove-button') + fireEvent.press(button, ON_PRESS_EVENT_PAYLOAD) + + expect(onPress).toHaveBeenCalledTimes(1) + }) + + describe('visibility', () => { + it('renders with opacity 1 when visible', () => { + const { getByTestId } = render() + + const button = getByTestId('explore/remove-button') + + expect(button).toHaveAnimatedStyle({ opacity: 1 }) + }) + + it('renders with opacity 0 when not visible', () => { + const { getByTestId } = render() + + const button = getByTestId('explore/remove-button') + + expect(button).toHaveAnimatedStyle({ opacity: 0 }) + }) + }) +}) diff --git a/apps/mobile/src/components/explore/RemoveButton.tsx b/apps/mobile/src/components/explore/RemoveButton.tsx new file mode 100644 index 00000000..fab25b80 --- /dev/null +++ b/apps/mobile/src/components/explore/RemoveButton.tsx @@ -0,0 +1,26 @@ +import { AnimatedTouchableArea, Flex, TouchableAreaProps } from 'ui/src' +import { imageSizes } from 'ui/src/theme' + +type RemoveButtonProps = TouchableAreaProps & { + visible?: boolean +} + +export default function RemoveButton({ visible = true, ...rest }: RemoveButtonProps): JSX.Element { + return ( + + + + ) +} diff --git a/apps/mobile/src/components/explore/SortButton.test.tsx b/apps/mobile/src/components/explore/SortButton.test.tsx new file mode 100644 index 00000000..fd1141cb --- /dev/null +++ b/apps/mobile/src/components/explore/SortButton.test.tsx @@ -0,0 +1,58 @@ +import { CustomRankingType, RankingType } from '@universe/api' +import { SortButton } from 'src/components/explore/SortButton' +import { act, render } from 'src/test/test-utils' +import { ExploreOrderBy } from 'wallet/src/features/wallet/types' + +jest.mock('react-native-context-menu-view', () => { + // Use the actual implementation of `react-native-context-menu-view` as the mock implementation + // (we use mock just to get the props of the component in test) + return jest.fn(jest.requireActual('react-native-context-menu-view').default) +}) + +describe('SortButton', () => { + beforeEach(() => { + jest.useFakeTimers() + }) + + afterEach(() => { + jest.useRealTimers() + }) + + it('renders without error', async () => { + const tree = render( {}} />) + + await act(async () => { + jest.runAllTimers() + }) + + expect(tree).toMatchSnapshot() + }) + + const cases: Array<{ test: string; orderBy: ExploreOrderBy; label: string }> = [ + { test: 'volume', orderBy: RankingType.Volume, label: 'Volume' }, + { test: 'total value locked', orderBy: RankingType.TotalValueLocked, label: 'TVL' }, + { test: 'market cap', orderBy: RankingType.MarketCap, label: 'Market cap' }, + { + test: 'price increase', + orderBy: CustomRankingType.PricePercentChange1DayDesc, + label: 'Price increase', + }, + { + test: 'price decrease', + orderBy: CustomRankingType.PricePercentChange1DayAsc, + label: 'Price decrease', + }, + ] + + describe.each(cases)('when ordering by $test', ({ orderBy, label }) => { + it(`renders ${label} as the selected option`, async () => { + const { queryByText } = render( {}} />) + await act(async () => { + jest.runAllTimers() + }) + const selectedOption = queryByText(label) + + expect(selectedOption).toBeTruthy() + }) + }) +}) diff --git a/apps/mobile/src/components/explore/SortButton.tsx b/apps/mobile/src/components/explore/SortButton.tsx new file mode 100644 index 00000000..46c24b07 --- /dev/null +++ b/apps/mobile/src/components/explore/SortButton.tsx @@ -0,0 +1,140 @@ +import { CustomRankingType, RankingType } from '@universe/api' +import React, { memo, useCallback, useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { getTokensOrderByMenuLabel, getTokensOrderBySelectedLabel } from 'src/features/explore/utils' +import { Flex, Text } from 'ui/src' +import { Chart, ChartPie, ChartPyramid, CheckCircleFilled, TrendDown, TrendUp } from 'ui/src/components/icons' +import { ActionSheetDropdown } from 'uniswap/src/components/dropdowns/ActionSheetDropdown' +import { MenuItemProp } from 'uniswap/src/components/modals/ActionSheetModal' +import { MobileEventName } from 'uniswap/src/features/telemetry/constants' +import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { TestID } from 'uniswap/src/test/fixtures/testIDs' +import { logger } from 'utilities/src/logger/logger' +import { ExploreOrderBy } from 'wallet/src/features/wallet/types' + +const MIN_MENU_ITEM_WIDTH = 220 + +interface FilterGroupProps { + orderBy: ExploreOrderBy + onOrderByChange: (orderBy: ExploreOrderBy) => void +} + +interface MenuAction { + title: string + orderBy: ExploreOrderBy + icon: JSX.Element + active: boolean +} + +function SortButtonInner({ orderBy, onOrderByChange }: FilterGroupProps): JSX.Element { + const { t } = useTranslation() + + const menuActions = useMemo(() => { + return [ + { + title: getTokensOrderByMenuLabel(RankingType.Volume, t), + orderBy: RankingType.Volume, + icon: , + active: orderBy === RankingType.Volume, + }, + { + title: getTokensOrderByMenuLabel(RankingType.TotalValueLocked, t), + orderBy: RankingType.TotalValueLocked, + icon: , + active: orderBy === RankingType.TotalValueLocked, + }, + { + title: getTokensOrderByMenuLabel(RankingType.MarketCap, t), + orderBy: RankingType.MarketCap, + icon: , + active: orderBy === RankingType.MarketCap, + }, + { + title: getTokensOrderByMenuLabel(CustomRankingType.PricePercentChange1DayDesc, t), + orderBy: CustomRankingType.PricePercentChange1DayDesc, + icon: , + active: orderBy === CustomRankingType.PricePercentChange1DayDesc, + }, + { + title: getTokensOrderByMenuLabel(CustomRankingType.PricePercentChange1DayAsc, t), + orderBy: CustomRankingType.PricePercentChange1DayAsc, + icon: , + active: orderBy === CustomRankingType.PricePercentChange1DayAsc, + }, + ] + }, [t, orderBy]) + + const MenuItem = useCallback( + ({ label, icon, active, testID }: { label: string; icon: JSX.Element; active: boolean; testID?: string }) => { + return ( + + {icon} + {label} + {active && } + + ) + }, + [], + ) + + const handleOrderByChange = useCallback( + (newOrderBy: ExploreOrderBy) => { + onOrderByChange(newOrderBy) + sendAnalyticsEvent(MobileEventName.ExploreFilterSelected, { + filter_type: newOrderBy, + }) + }, + [onOrderByChange], + ) + + const options = useMemo(() => { + return menuActions.map((option, index) => { + return { + key: index.toString(), + onPress: (): void => { + const selectedMenuAction = menuActions[index] + if (!selectedMenuAction) { + logger.error(new Error('Unexpected context menu index selected'), { + tags: { file: 'SortButton', function: 'SortButtonContextMenu:onPress' }, + }) + return + } + handleOrderByChange(selectedMenuAction.orderBy) + }, + render: () => ( + + ), + } + }) + }, [MenuItem, menuActions, handleOrderByChange]) + + return ( + + + + {getTokensOrderBySelectedLabel(orderBy, t)} + + + + ) +} + +export const SortButton = memo(SortButtonInner) diff --git a/apps/mobile/src/components/explore/TokenItem.test.tsx b/apps/mobile/src/components/explore/TokenItem.test.tsx new file mode 100644 index 00000000..3138a330 --- /dev/null +++ b/apps/mobile/src/components/explore/TokenItem.test.tsx @@ -0,0 +1,141 @@ +import * as exploreHooks from 'src/components/explore/hooks' +import { TokenItem } from 'src/components/explore/TokenItem' +import * as tokenDetailsHooks from 'src/components/TokenDetails/hooks' +import { TOKEN_ITEM_DATA, tokenItemData } from 'src/test/fixtures' +import { fireEvent, render, within } from 'src/test/test-utils' +import { MobileEventName } from 'uniswap/src/features/telemetry/constants' +import { ON_PRESS_EVENT_PAYLOAD } from 'uniswap/src/test/fixtures' +import { buildCurrencyId } from 'uniswap/src/utils/currencyId' +import { TokenMetadataDisplayType } from 'wallet/src/features/wallet/types' + +describe('TokenItem', () => { + const mockedTokenDetailsNavigation = { + navigate: jest.fn(), + navigateWithPop: jest.fn(), + preload: jest.fn(), + } + + beforeAll(() => { + jest.spyOn(tokenDetailsHooks, 'useTokenDetailsNavigation').mockReturnValue(mockedTokenDetailsNavigation) + jest.spyOn(exploreHooks, 'useExploreTokenContextMenu').mockReturnValue({ + menuActions: [], + onContextMenuPress: jest.fn(), + }) + }) + + it('renders without error', () => { + const tree = render( + , + ) + + expect(tree).toMatchSnapshot() + }) + + it('renders correct token number based on index', () => { + const data = tokenItemData() + const { queryByText } = render( + , + ) + + expect(queryByText('2')).toBeTruthy() + }) + + it('renders proper token name', () => { + const data = tokenItemData() + const { queryByText } = render( + , + ) + + expect(queryByText(data.name)).toBeTruthy() + }) + + it('navigates to the token details screen when pressed', () => { + const data = tokenItemData() + const { getByTestId } = render( + , + ) + + fireEvent.press(getByTestId(`token-item-${data.name}`), ON_PRESS_EVENT_PAYLOAD) + + expect(mockedTokenDetailsNavigation.navigate).toHaveBeenCalledWith(buildCurrencyId(data.chainId, data.address)) + }) + + describe('token price', () => { + it('renders token price if it is provided', () => { + const data = tokenItemData({ price: 123.45 }) + const { getByTestId } = render( + , + ) + + const tokenPrice = getByTestId('token-item/price') + + expect(within(tokenPrice).queryByText('$123.45')).toBeTruthy() + expect(within(tokenPrice).queryByText('-')).toBeFalsy() + }) + + it('renders price placeholder if token price is not provided', () => { + const data = tokenItemData({ price: undefined }) + const { getByTestId } = render( + , + ) + + const tokenPrice = getByTestId('token-item/price') + + expect(within(tokenPrice).queryByText('-')).toBeTruthy() + }) + }) + + describe('token price change', () => { + it('renders token price change if it is provided', () => { + const data = tokenItemData({ pricePercentChange24h: 12.34 }) + const { getByTestId } = render( + , + ) + + const relativeChange = getByTestId('relative-change') + + expect(within(relativeChange).queryByText('12.34%')).toBeTruthy() + }) + + it('renders price change placeholder if token price change is not provided', () => { + const data = tokenItemData({ pricePercentChange24h: undefined }) + const { getByTestId } = render( + , + ) + + const relativeChange = getByTestId('relative-change') + + expect(within(relativeChange).queryByText('-')).toBeTruthy() + }) + }) + + describe('metadata subtitle', () => { + const data = tokenItemData({ + marketCap: 123.45, + volume24h: 234.56, + totalValueLocked: 345.67, + }) + + const cases = [ + { test: 'market cap', type: TokenMetadataDisplayType.MarketCap, expected: '$123.45 MCap' }, + { test: 'volume', type: TokenMetadataDisplayType.Volume, expected: '$234.56 Vol' }, + { test: 'total value locked', type: TokenMetadataDisplayType.TVL, expected: '$345.67 TVL' }, + { test: 'symbol', type: TokenMetadataDisplayType.Symbol, expected: data.symbol }, + ] + + it.each(cases)('renders $test metadata subtitle', ({ type, expected }) => { + const { getByTestId } = render( + , + ) + + const metadataSubtitle = getByTestId('token-item/metadata-subtitle') + + expect(within(metadataSubtitle).queryByText(expected)).toBeTruthy() + }) + }) +}) diff --git a/apps/mobile/src/components/explore/TokenItem.tsx b/apps/mobile/src/components/explore/TokenItem.tsx new file mode 100644 index 00000000..bbe4f778 --- /dev/null +++ b/apps/mobile/src/components/explore/TokenItem.tsx @@ -0,0 +1,150 @@ +import React, { memo, ReactNode, useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { LayoutChangeEvent, LayoutRectangle } from 'react-native' +import ContextMenu from 'react-native-context-menu-view' +import { useExploreTokenContextMenu } from 'src/components/explore/hooks' +import { TokenItemChart } from 'src/components/explore/TokenItemChart' +import { TokenItemData } from 'src/components/explore/TokenItemData' +import { useTokenDetailsNavigation } from 'src/components/TokenDetails/hooks' +import { TokenMetadata } from 'src/components/tokens/TokenMetadata' +import { Flex, FlexProps, Text, TouchableArea, useSporeColors } from 'ui/src' +import { AnimatedFlex } from 'ui/src/components/layout/AnimatedFlex' +import { spacing } from 'ui/src/theme' +import { TokenLogo } from 'uniswap/src/components/CurrencyLogo/TokenLogo' +import { RelativeChange } from 'uniswap/src/components/RelativeChange/RelativeChange' +import { useLocalizationContext } from 'uniswap/src/features/language/LocalizationContext' +import { MobileEventName, SectionName } from 'uniswap/src/features/telemetry/constants' +import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { + buildCurrencyId, + buildNativeCurrencyId, + currencyIdToAddress, + currencyIdToChain, +} from 'uniswap/src/utils/currencyId' +import { NumberType } from 'utilities/src/format/types' +import { useEvent } from 'utilities/src/react/hooks' +import { noop } from 'utilities/src/react/noop' +import { TokenMetadataDisplayType } from 'wallet/src/features/wallet/types' + +interface TokenItemProps { + tokenItemData: TokenItemData + index: number + eventName: MobileEventName.ExploreTokenItemSelected | MobileEventName.HomeExploreTokenItemSelected + metadataDisplayType?: TokenMetadataDisplayType + containerProps?: FlexProps + hideNumberedList?: boolean + priceWrapperProps?: FlexProps + showChart?: boolean + overlay?: ReactNode + onPriceWrapperLayout?: (layout: LayoutRectangle) => void +} + +export const TokenItem = memo(function TokenItemInner({ + tokenItemData, + index, + metadataDisplayType, + containerProps, + eventName, + hideNumberedList, + priceWrapperProps, + showChart, + overlay, + onPriceWrapperLayout, +}: TokenItemProps) { + const { t } = useTranslation() + const tokenDetailsNavigation = useTokenDetailsNavigation() + const { convertFiatAmountFormatted } = useLocalizationContext() + const colors = useSporeColors() + + const { + name, + logoUrl, + chainId, + address, + symbol, + price, + marketCap, + pricePercentChange24h, + volume24h, + totalValueLocked, + } = tokenItemData + const _currencyId = address ? buildCurrencyId(chainId, address) : buildNativeCurrencyId(chainId) + const marketCapFormatted = convertFiatAmountFormatted(marketCap, NumberType.FiatTokenDetails) + const volume24hFormatted = convertFiatAmountFormatted(volume24h, NumberType.FiatTokenDetails) + const totalValueLockedFormatted = convertFiatAmountFormatted(totalValueLocked, NumberType.FiatTokenDetails) + + const metadataSubtitle = useMemo((): string | undefined => { + switch (metadataDisplayType) { + case TokenMetadataDisplayType.MarketCap: + return t('explore.tokens.metadata.marketCap', { number: marketCapFormatted }) + case TokenMetadataDisplayType.Volume: + return t('explore.tokens.metadata.volume', { number: volume24hFormatted }) + case TokenMetadataDisplayType.TVL: + return t('explore.tokens.metadata.totalValueLocked', { + number: totalValueLockedFormatted, + }) + case TokenMetadataDisplayType.Symbol: + return symbol + default: + return undefined + } + }, [metadataDisplayType, marketCapFormatted, volume24hFormatted, totalValueLockedFormatted, symbol, t]) + + const onLayout = useEvent((e: LayoutChangeEvent): void => { + onPriceWrapperLayout?.(e.nativeEvent.layout) + }) + + const onPress = useEvent((): void => { + tokenDetailsNavigation.preload(_currencyId) + tokenDetailsNavigation.navigate(_currencyId) + sendAnalyticsEvent(eventName, { + address: currencyIdToAddress(_currencyId), + chain: currencyIdToChain(_currencyId) as number, + name: tokenItemData.name, + position: index + 1, + }) + }) + + const { menuActions, onContextMenuPress } = useExploreTokenContextMenu({ + chainId, + currencyId: _currencyId, + analyticsSection: SectionName.ExploreTopTokensSection, + }) + + return ( + + + {overlay} + + + {!hideNumberedList && ( + + + {index + 1} + + + )} + + + + + {name} + + + {metadataSubtitle} + + + {showChart && } + + + + {convertFiatAmountFormatted(price, NumberType.FiatTokenPrice)} + + + + + + + + ) +}) diff --git a/apps/mobile/src/components/explore/TokenItemChart.tsx b/apps/mobile/src/components/explore/TokenItemChart.tsx new file mode 100644 index 00000000..af9def11 --- /dev/null +++ b/apps/mobile/src/components/explore/TokenItemChart.tsx @@ -0,0 +1,63 @@ +import { curveNatural } from 'd3-shape' +import { useMemo } from 'react' +import { LineChart, LineChartProvider } from 'react-native-wagmi-charts' +import { TokenItemData } from 'src/components/explore/TokenItemData' +import { useTokenPriceHistory } from 'src/components/PriceExplorer/usePriceHistory' +import { useExtractedTokenColor, useSporeColors } from 'ui/src' +import { useLocalizationContext } from 'uniswap/src/features/language/LocalizationContext' +import { buildCurrencyId, buildNativeCurrencyId } from 'uniswap/src/utils/currencyId' + +// Used to divide the number of data points for a smoother charts +// Necessary because graphql query does not support a time resolution parameter +const DATA_REDUCTION_FACTOR = 10 + +export function TokenItemChart({ + tokenItemData, + height, + width, +}: { + tokenItemData: TokenItemData + height: number + width: number +}): JSX.Element | null { + const { convertFiatAmount } = useLocalizationContext() + const conversionRate = convertFiatAmount(1).amount + const colors = useSporeColors() + + const currencyId = tokenItemData.address + ? buildCurrencyId(tokenItemData.chainId, tokenItemData.address) + : buildNativeCurrencyId(tokenItemData.chainId) + const { data } = useTokenPriceHistory({ currencyId }) + const { tokenColor } = useExtractedTokenColor({ + imageUrl: tokenItemData.logoUrl, + tokenName: tokenItemData.symbol, + backgroundColor: colors.surface1.val, + defaultColor: colors.neutral3.val, + }) + + const convertedPriceHistory = useMemo( + () => + data?.priceHistory + ?.filter((_, index) => index % DATA_REDUCTION_FACTOR === 0) + .map((point) => { + return { ...point, value: point.value * conversionRate } + }), + [data, conversionRate], + ) + + if (!convertedPriceHistory || !convertedPriceHistory.length) { + return null + } + + return ( + + + + + + ) +} diff --git a/apps/mobile/src/components/explore/TokenItemData.ts b/apps/mobile/src/components/explore/TokenItemData.ts new file mode 100644 index 00000000..ad530709 --- /dev/null +++ b/apps/mobile/src/components/explore/TokenItemData.ts @@ -0,0 +1,14 @@ +import { UniverseChainId } from 'uniswap/src/features/chains/types' + +export type TokenItemData = { + name: string + logoUrl: string + chainId: UniverseChainId + address: Address | null + symbol: string + price?: number + marketCap?: number + pricePercentChange24h?: number + volume24h?: number + totalValueLocked?: number +} diff --git a/apps/mobile/src/components/explore/__snapshots__/FavoriteHeaderRow.test.tsx.snap b/apps/mobile/src/components/explore/__snapshots__/FavoriteHeaderRow.test.tsx.snap new file mode 100644 index 00000000..da2b0618 --- /dev/null +++ b/apps/mobile/src/components/explore/__snapshots__/FavoriteHeaderRow.test.tsx.snap @@ -0,0 +1,339 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`FavoriteHeaderRow when editing renders without error 1`] = ` + + + Editing Title + + + + Done + + + +`; + +exports[`FavoriteHeaderRow when not editing renders without error 1`] = ` + + + Title + + + + + + + + + + + +`; diff --git a/apps/mobile/src/components/explore/__snapshots__/FavoriteTokenCard.test.tsx.snap b/apps/mobile/src/components/explore/__snapshots__/FavoriteTokenCard.test.tsx.snap new file mode 100644 index 00000000..41fc51fc --- /dev/null +++ b/apps/mobile/src/components/explore/__snapshots__/FavoriteTokenCard.test.tsx.snap @@ -0,0 +1,380 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`FavoriteTokenCard renders without error 1`] = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; diff --git a/apps/mobile/src/components/explore/__snapshots__/FavoriteWalletCard.test.tsx.snap b/apps/mobile/src/components/explore/__snapshots__/FavoriteWalletCard.test.tsx.snap new file mode 100644 index 00000000..517e0ab6 --- /dev/null +++ b/apps/mobile/src/components/explore/__snapshots__/FavoriteWalletCard.test.tsx.snap @@ -0,0 +1,368 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`FavoriteWalletCard renders without error 1`] = ` + + + + + + + + + + + + + + + + + + + + + + 0x​82D5...3Fa6 + + + + + + + + + +`; diff --git a/apps/mobile/src/components/explore/__snapshots__/RemoveButton.test.tsx.snap b/apps/mobile/src/components/explore/__snapshots__/RemoveButton.test.tsx.snap new file mode 100644 index 00000000..4a2fe8f5 --- /dev/null +++ b/apps/mobile/src/components/explore/__snapshots__/RemoveButton.test.tsx.snap @@ -0,0 +1,93 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`RemoveButton renders without error 1`] = ` + + + +`; diff --git a/apps/mobile/src/components/explore/__snapshots__/SortButton.test.tsx.snap b/apps/mobile/src/components/explore/__snapshots__/SortButton.test.tsx.snap new file mode 100644 index 00000000..b1965e84 --- /dev/null +++ b/apps/mobile/src/components/explore/__snapshots__/SortButton.test.tsx.snap @@ -0,0 +1,256 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SortButton renders without error 1`] = ` +[ + >>>>>> upstream/main + } + } + testID="explore-sort-button" + > + + + + Volume + + + + + + + + + + + , + , +] +`; diff --git a/apps/mobile/src/components/explore/__snapshots__/TokenItem.test.tsx.snap b/apps/mobile/src/components/explore/__snapshots__/TokenItem.test.tsx.snap new file mode 100644 index 00000000..52813b1f --- /dev/null +++ b/apps/mobile/src/components/explore/__snapshots__/TokenItem.test.tsx.snap @@ -0,0 +1,315 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`TokenItem renders without error 1`] = ` + + + + + + + 1 + + + + + + + + + + tkn + + + + + + + + - + + + + + - + + + + + + + + + +`; diff --git a/apps/mobile/src/components/explore/hooks.test.ts b/apps/mobile/src/components/explore/hooks.test.ts new file mode 100644 index 00000000..7fe3d12d --- /dev/null +++ b/apps/mobile/src/components/explore/hooks.test.ts @@ -0,0 +1,241 @@ +import { GraphQLApi } from '@universe/api' +import { NativeSyntheticEvent, Share } from 'react-native' +import { ContextMenuAction, ContextMenuOnPressNativeEvent } from 'react-native-context-menu-view' +import configureMockStore from 'redux-mock-store' +import { thunk } from 'redux-thunk' +import { navigate } from 'src/app/navigation/rootNavigation' +import { useExploreTokenContextMenu } from 'src/components/explore/hooks' +import { renderHookWithProviders } from 'src/test/render' +import { AssetType } from 'uniswap/src/entities/assets' +import { FavoritesState } from 'uniswap/src/features/favorites/slice' +import { ModalName, SectionName } from 'uniswap/src/features/telemetry/constants' +import { SAMPLE_SEED_ADDRESS_1 } from 'uniswap/src/test/fixtures' +import { CurrencyField } from 'uniswap/src/types/currency' +import { cleanup } from 'wallet/src/test/test-utils' + +jest.mock('src/app/navigation/rootNavigation', () => ({ + navigate: jest.fn(), +})) + +const mockNavigate = navigate as jest.MockedFunction + +const tokenId = SAMPLE_SEED_ADDRESS_1 +const currencyId = `1-${tokenId}` + +const resolvers: GraphQLApi.Resolvers = { + Token: { + id: () => tokenId, + }, +} + +const mockStore = configureMockStore([thunk]) + +describe(useExploreTokenContextMenu, () => { + const tokenMenuParams = { + currencyId, + chainId: 1, + analyticsSection: SectionName.CurrencyInputPanel, + } + + beforeEach(() => { + mockNavigate.mockClear() + }) + + describe('editing favorite tokens', () => { + it('renders proper context menu items when onEditFavorites is not provided', async () => { + const { result } = renderHookWithProviders(() => useExploreTokenContextMenu(tokenMenuParams), { resolvers }) + + expect(result.current.menuActions).toEqual([ + expect.objectContaining({ + title: 'Favorite token', + onPress: expect.any(Function), + }), + expect.objectContaining({ + title: 'Swap', + onPress: expect.any(Function), + }), + expect.objectContaining({ + title: 'Receive', + onPress: expect.any(Function), + }), + expect.objectContaining({ + title: 'Share', + onPress: expect.any(Function), + }), + ]) + cleanup() + }) + + it('renders proper context menu items when onEditFavorites is provided', async () => { + const onEditFavorites = jest.fn() + const { result } = renderHookWithProviders( + () => useExploreTokenContextMenu({ ...tokenMenuParams, onEditFavorites }), + { resolvers }, + ) + + expect(result.current.menuActions).toEqual([ + expect.objectContaining({ + title: 'Favorite token', + onPress: expect.any(Function), + }), + expect.objectContaining({ + title: 'Edit favorites', + onPress: onEditFavorites, + }), + expect.objectContaining({ + title: 'Swap', + onPress: expect.any(Function), + }), + expect.objectContaining({ + title: 'Receive', + onPress: expect.any(Function), + }), + ]) + cleanup() + }) + + it('calls onEditFavorites when edit favorites is pressed', async () => { + const onEditFavorites = jest.fn() + const { result } = renderHookWithProviders( + () => useExploreTokenContextMenu({ ...tokenMenuParams, onEditFavorites }), + { resolvers }, + ) + + const editFavoritesActionIndex = result.current.menuActions.findIndex( + (action: ContextMenuAction) => action.title === 'Edit favorites', + ) + result.current.onContextMenuPress({ + nativeEvent: { index: editFavoritesActionIndex }, + } as NativeSyntheticEvent) + + expect(onEditFavorites).toHaveBeenCalledTimes(1) + cleanup() + }) + }) + + describe('adding / removing favorite tokens', () => { + it('renders proper context menu items when token is favorited', async () => { + const { result } = renderHookWithProviders(() => useExploreTokenContextMenu(tokenMenuParams), { + preloadedState: { + favorites: { tokens: [tokenMenuParams.currencyId.toLowerCase()] } as FavoritesState, + }, + resolvers, + }) + + expect(result.current.menuActions).toEqual([ + expect.objectContaining({ + title: 'Remove favorite', + onPress: expect.any(Function), + }), + expect.objectContaining({ + title: 'Swap', + onPress: expect.any(Function), + }), + expect.objectContaining({ + title: 'Receive', + onPress: expect.any(Function), + }), + expect.objectContaining({ + title: 'Share', + onPress: expect.any(Function), + }), + ]) + cleanup() + }) + + it("dispatches add to favorites redux action when 'Favorite token' is pressed", async () => { + const store = mockStore({ favorites: { tokens: [] }, appearance: { theme: 'system' }, userSettings: {} }) + const { result } = renderHookWithProviders(() => useExploreTokenContextMenu(tokenMenuParams), { + resolvers, + store, + }) + + const favoriteTokenActionIndex = result.current.menuActions.findIndex( + (action: ContextMenuAction) => action.title === 'Favorite token', + ) + result.current.onContextMenuPress({ + nativeEvent: { index: favoriteTokenActionIndex }, + } as NativeSyntheticEvent) + + const dispatchedActions = store.getActions() + expect(dispatchedActions).toContainEqual({ + type: 'favorites/addFavoriteToken', + payload: { currencyId: tokenMenuParams.currencyId.toLowerCase() }, + }) + cleanup() + }) + + it("dispatches remove from favorites redux action when 'Remove favorite' is pressed", async () => { + const store = mockStore({ + favorites: { tokens: [tokenMenuParams.currencyId.toLowerCase()] }, + appearance: { theme: 'system' }, + userSettings: {}, + }) + const { result } = renderHookWithProviders(() => useExploreTokenContextMenu(tokenMenuParams), { + resolvers, + store, + }) + + const removeFavoriteTokenActionIndex = result.current.menuActions.findIndex( + (action: ContextMenuAction) => action.title === 'Remove favorite', + ) + result.current.onContextMenuPress({ + nativeEvent: { index: removeFavoriteTokenActionIndex }, + } as NativeSyntheticEvent) + + const dispatchedActions = store.getActions() + expect(dispatchedActions).toContainEqual({ + type: 'favorites/removeFavoriteToken', + payload: { currencyId: tokenMenuParams.currencyId.toLowerCase() }, + }) + cleanup() + }) + }) + + it('calls navigate with correct parameters when swap is pressed', async () => { + const store = mockStore({ + favorites: { tokens: [] }, + selectedAppearanceSettings: { theme: 'system' }, + userSettings: {}, + }) + const { result } = renderHookWithProviders(() => useExploreTokenContextMenu(tokenMenuParams), { + store, + resolvers, + }) + + const swapActionIndex = result.current.menuActions.findIndex((action: ContextMenuAction) => action.title === 'Swap') + result.current.onContextMenuPress({ + nativeEvent: { index: swapActionIndex }, + } as NativeSyntheticEvent) + + expect(mockNavigate).toHaveBeenCalledWith(ModalName.Swap, { + exactAmountToken: '', + exactCurrencyField: CurrencyField.INPUT, + [CurrencyField.INPUT]: null, + [CurrencyField.OUTPUT]: { + chainId: 1, + address: tokenId, + type: AssetType.Currency, + }, + }) + cleanup() + }) + + it('opens share modal when share is pressed', async () => { + const { result } = renderHookWithProviders(() => useExploreTokenContextMenu(tokenMenuParams), { + resolvers, + }) + + jest.spyOn(Share, 'share') + + const shareActionIndex = result.current.menuActions.findIndex( + (action: ContextMenuAction) => action.title === 'Share', + ) + result.current.onContextMenuPress({ + nativeEvent: { index: shareActionIndex }, + } as NativeSyntheticEvent) + + expect(Share.share).toHaveBeenCalledTimes(1) + cleanup() + }) +}) diff --git a/apps/mobile/src/components/explore/hooks.ts b/apps/mobile/src/components/explore/hooks.ts new file mode 100644 index 00000000..6744c168 --- /dev/null +++ b/apps/mobile/src/components/explore/hooks.ts @@ -0,0 +1,143 @@ +import { SharedEventName } from '@uniswap/analytics-events' +import { useCallback, useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import type { NativeSyntheticEvent } from 'react-native' +import type { ContextMenuAction, ContextMenuOnPressNativeEvent } from 'react-native-context-menu-view' +import type { SharedValue, StyleProps } from 'react-native-reanimated' +import { interpolate, useAnimatedStyle } from 'react-native-reanimated' +import { useDispatch } from 'react-redux' +import { navigate } from 'src/app/navigation/rootNavigation' +import { openModal } from 'src/features/modals/modalSlice' +import { ScannerModalState } from 'uniswap/src/components/ReceiveQRCode/constants' +import { AssetType } from 'uniswap/src/entities/assets' +import type { UniverseChainId } from 'uniswap/src/features/chains/types' +import { useSelectHasTokenFavorited } from 'uniswap/src/features/favorites/useSelectHasTokenFavorited' +import { useToggleFavoriteCallback } from 'uniswap/src/features/favorites/useToggleFavoriteCallback' +import type { SectionName } from 'uniswap/src/features/telemetry/constants' +import { ElementName, ModalName } from 'uniswap/src/features/telemetry/constants' +import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import type { TransactionState } from 'uniswap/src/features/transactions/types/transactionState' +import type { CurrencyId } from 'uniswap/src/types/currency' +import { CurrencyField } from 'uniswap/src/types/currency' +import { currencyIdToAddress } from 'uniswap/src/utils/currencyId' +import { useEvent } from 'utilities/src/react/hooks' +import { useWalletNavigation } from 'wallet/src/contexts/WalletNavigationContext' + +interface TokenMenuParams { + currencyId: CurrencyId + chainId: UniverseChainId + analyticsSection: SectionName + // token, which are in favorite section would have it defined + onEditFavorites?: () => void + tokenName?: string +} + +// Provide context menu related data for token +export function useExploreTokenContextMenu({ + currencyId, + chainId, + analyticsSection, + onEditFavorites, + tokenName, +}: TokenMenuParams): { + menuActions: Array void }> + onContextMenuPress: (e: NativeSyntheticEvent) => void +} { + const { t } = useTranslation() + const isFavorited = useSelectHasTokenFavorited(currencyId) + const dispatch = useDispatch() + + const { handleShareToken } = useWalletNavigation() + + // `address` is undefined for native currencies, so we want to extract it from + // currencyId, where we have hardcoded addresses for native currencies + const currencyAddress = currencyIdToAddress(currencyId) + + const onPressReceive = useEvent(() => + dispatch(openModal({ name: ModalName.WalletConnectScan, initialState: ScannerModalState.WalletQr })), + ) + + const onPressShare = useEvent(async () => { + handleShareToken({ currencyId }) + }) + + const toggleFavoriteToken = useToggleFavoriteCallback({ id: currencyId, tokenName, isFavoriteToken: isFavorited }) + + const onPressSwap = useEvent(() => { + const swapFormState: TransactionState = { + exactCurrencyField: CurrencyField.INPUT, + exactAmountToken: '', + [CurrencyField.INPUT]: null, + [CurrencyField.OUTPUT]: { + chainId, + address: currencyAddress, + type: AssetType.Currency, + }, + } + navigate(ModalName.Swap, swapFormState) + sendAnalyticsEvent(SharedEventName.ELEMENT_CLICKED, { + element: ElementName.Swap, + section: analyticsSection, + }) + }) + + const onPressToggleFavorite = useEvent(() => { + toggleFavoriteToken() + }) + + const menuActions = useMemo( + () => [ + { + title: isFavorited ? t('explore.tokens.favorite.action.remove') : t('explore.tokens.favorite.action.add'), + systemIcon: isFavorited ? 'heart.slash.fill' : 'heart.fill', + onPress: onPressToggleFavorite, + }, + ...(onEditFavorites + ? [ + { + title: t('explore.tokens.favorite.action.edit'), + systemIcon: 'square.and.pencil', + onPress: onEditFavorites, + }, + ] + : []), + { title: t('common.button.swap'), systemIcon: 'arrow.2.squarepath', onPress: onPressSwap }, + { + title: t('common.button.receive'), + systemIcon: 'qrcode', + onPress: onPressReceive, + }, + ...(!onEditFavorites + ? [ + { + title: t('common.button.share'), + systemIcon: 'square.and.arrow.up', + onPress: onPressShare, + }, + ] + : []), + ], + [isFavorited, t, onPressToggleFavorite, onEditFavorites, onPressSwap, onPressReceive, onPressShare], + ) + + const onContextMenuPress = useCallback( + async (e: NativeSyntheticEvent): Promise => { + await menuActions[e.nativeEvent.index]?.onPress?.() + }, + [menuActions], + ) + + return { menuActions, onContextMenuPress } +} + +export function useAnimatedCardDragStyle( + pressProgress: SharedValue, + dragActivationProgress: SharedValue, +): StyleProps { + return useAnimatedStyle(() => ({ + opacity: + pressProgress.value >= dragActivationProgress.value + ? 1 + : interpolate(dragActivationProgress.value, [0, 1], [1, 0.5]), + })) +} diff --git a/apps/mobile/src/components/explore/hooks/useFlatListAutoScroll.ts b/apps/mobile/src/components/explore/hooks/useFlatListAutoScroll.ts new file mode 100644 index 00000000..a6fb460d --- /dev/null +++ b/apps/mobile/src/components/explore/hooks/useFlatListAutoScroll.ts @@ -0,0 +1,52 @@ +import { useEffect } from 'react' +import { FlatList } from 'react-native-gesture-handler' + +interface UseFlatListAutoScrollOptions { + flatListRef: React.RefObject | null> + selectedItem: T | null + items: T[] + scrollDelay?: number +} + +/** + * Custom hook to handle auto-scrolling in a FlatList based on a selected item + * @param options - Configuration options for the auto-scroll behavior + * @param options.flatListRef - Reference to the FlatList component + * @param options.selectedItem - The currently selected item (null for "All" or first item) + * @param options.items - Array of items to find the selected item's index + * @param options.scrollDelay - Delay in ms before scrolling (default: 100) + */ +export function useFlatListAutoScroll(options: UseFlatListAutoScrollOptions): void { + const { flatListRef, selectedItem, items, scrollDelay = 100 } = options + + useEffect(() => { + let timeoutId: NodeJS.Timeout | number | undefined + + if (flatListRef.current) { + // If selectedItem is null (All/First option), scroll to the beginning + if (selectedItem === null) { + flatListRef.current.scrollToOffset({ offset: 0, animated: true }) + } else { + // Find the index of the selected item in the items array + const selectedIndex = items.findIndex((item) => item === selectedItem) + if (selectedIndex !== -1) { + // Use a small delay to ensure the FlatList is ready for scrolling + timeoutId = setTimeout(() => { + flatListRef.current?.scrollToIndex({ + index: selectedIndex, + animated: true, + viewPosition: 0.5, // Center the item in the viewport + }) + }, scrollDelay) + } + } + } + + // Return cleanup function + return () => { + if (timeoutId) { + clearTimeout(timeoutId) + } + } + }, [selectedItem, items, flatListRef, scrollDelay]) +} diff --git a/apps/mobile/src/components/explore/search/ExploreScreenSearchResultsList.tsx b/apps/mobile/src/components/explore/search/ExploreScreenSearchResultsList.tsx new file mode 100644 index 00000000..beaa3241 --- /dev/null +++ b/apps/mobile/src/components/explore/search/ExploreScreenSearchResultsList.tsx @@ -0,0 +1,122 @@ +import { FeatureFlags, useFeatureFlag } from '@universe/gating' +import { memo, useCallback, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { KeyboardAvoidingView } from 'react-native-keyboard-controller' +import { ESTIMATED_BOTTOM_TABS_HEIGHT } from 'src/app/navigation/tabs/CustomTabBar/constants' +import { Flex, flexStyles, Text, TouchableArea } from 'ui/src' +import { spacing } from 'ui/src/theme' +import type { UniverseChainId } from 'uniswap/src/features/chains/types' +import { SearchModalNoQueryList } from 'uniswap/src/features/search/SearchModal/SearchModalNoQueryList' +import { SearchModalResultsList } from 'uniswap/src/features/search/SearchModal/SearchModalResultsList' +import { MOBILE_SEARCH_TABS, SearchTab } from 'uniswap/src/features/search/SearchModal/types' +import { ElementName, SectionName } from 'uniswap/src/features/telemetry/constants' +import Trace from 'uniswap/src/features/telemetry/Trace' +import { useAppInsets } from 'uniswap/src/hooks/useAppInsets' +import { useDebounce } from 'utilities/src/time/timing' + +const MobileSearchTab = ({ + tab, + setActiveTab, + activeTab, + getTabLabel, +}: { + tab: SearchTab + setActiveTab: (tab: SearchTab) => void + activeTab: SearchTab + getTabLabel: (tab: SearchTab) => string +}): JSX.Element => { + const handleOnPress = useCallback(() => { + setActiveTab(tab) + }, [setActiveTab, tab]) + + return ( + + + + {getTabLabel(tab)} + + + + ) +} + +export const ExploreScreenSearchResultsList = memo(function ExploreScreenSearchResultsListInner({ + searchQuery, + parsedSearchQuery, + chainFilter, + parsedChainFilter, +}: { + searchQuery: string + parsedSearchQuery: string | null + chainFilter: UniverseChainId | null + parsedChainFilter: UniverseChainId | null +}): JSX.Element { + const debouncedSearchQuery = useDebounce(searchQuery) + const debouncedParsedSearchQuery = useDebounce(parsedSearchQuery) + const { t } = useTranslation() + const [activeTab, setActiveTab] = useState(SearchTab.All) + const insets = useAppInsets() + const isBottomTabsEnabled = useFeatureFlag(FeatureFlags.BottomTabs) + + const getTabLabel = useCallback( + // So that the linter errors if someone adds a new tab without updating the switch statement + // oxlint-disable-next-line consistent-return + (tab: SearchTab): string => { + switch (tab) { + case SearchTab.All: + return t('common.all') + case SearchTab.Tokens: + return t('common.tokens') + case SearchTab.Pools: + return t('common.pools') + case SearchTab.Wallets: + return t('explore.search.section.wallets') + } + }, + [t], + ) + + const contentContainerStyle = useMemo( + () => ({ + paddingBottom: (isBottomTabsEnabled ? ESTIMATED_BOTTOM_TABS_HEIGHT + spacing.spacing32 : 0) + insets.bottom, + }), + [insets.bottom, isBottomTabsEnabled], + ) + + return ( + + + + {MOBILE_SEARCH_TABS.map((tab) => ( + + ))} + + {searchQuery && searchQuery.length > 0 ? ( + + ) : ( + + )} + + + ) +}) diff --git a/apps/mobile/src/components/fiatOnRamp/CtaButton.tsx b/apps/mobile/src/components/fiatOnRamp/CtaButton.tsx new file mode 100644 index 00000000..c88cf842 --- /dev/null +++ b/apps/mobile/src/components/fiatOnRamp/CtaButton.tsx @@ -0,0 +1,41 @@ +import React from 'react' +import { useTranslation } from 'react-i18next' +import { Button, SpinningLoader, useIsShortMobileDevice } from 'ui/src' +import { InfoCircleFilled } from 'ui/src/components/icons/InfoCircleFilled' + +interface FiatOnRampCtaButtonProps { + onPress: () => void + isLoading?: boolean + eligible: boolean + disabled: boolean + continueButtonText: string +} + +export function FiatOnRampCtaButton({ + continueButtonText, + isLoading, + eligible, + disabled, + onPress, +}: FiatOnRampCtaButtonProps): JSX.Element { + const { t } = useTranslation() + const isShortMobileDevice = useIsShortMobileDevice() + const buttonAvailable = eligible || isLoading + const continueText = eligible ? continueButtonText : t('fiatOnRamp.error.unsupported') + return ( + + ) +} diff --git a/apps/mobile/src/components/forceUpgrade/ForceUpgradeModal.tsx b/apps/mobile/src/components/forceUpgrade/ForceUpgradeModal.tsx new file mode 100644 index 00000000..cf889786 --- /dev/null +++ b/apps/mobile/src/components/forceUpgrade/ForceUpgradeModal.tsx @@ -0,0 +1,57 @@ +import { FeatureFlags, useFeatureFlag } from '@universe/gating' +import { getIsNotificationServiceLocalOverrideEnabled } from '@universe/notifications' +import React from 'react' +import { useTranslation } from 'react-i18next' +import { BackButtonView } from 'src/components/layout/BackButtonView' +import { SeedPhraseDisplay } from 'src/components/mnemonic/SeedPhraseDisplay' +import { Flex, Text, TouchableArea } from 'ui/src' +import { ForceUpgrade } from 'wallet/src/features/forceUpgrade/ForceUpgrade' + +const BACK_BUTTON_SIZE = 24 +const BACK_BUTTON_SIZE_TOKEN = '$icon.24' + +/** + * Seed phrase modal content for force upgrade flow. + * Exported for reuse in notification-driven force upgrade. + */ +export function SeedPhraseModalContent({ + mnemonicId, + onDismiss, +}: { + mnemonicId: string + onDismiss: () => void +}): JSX.Element { + const { t } = useTranslation() + return ( + + + + + + {t('forceUpgrade.label.recoveryPhrase')} + + + + + ) +} + +/** + * Force upgrade modal for the legacy modal system. + * + * When the notification service feature flag is enabled, force upgrade is handled + * by the notification system instead, so this component returns null. + */ +export function ForceUpgradeModal(): JSX.Element | null { + const isNotificationServiceEnabledFlag = useFeatureFlag(FeatureFlags.NotificationService) + const isNotificationServiceEnabled = + getIsNotificationServiceLocalOverrideEnabled() || isNotificationServiceEnabledFlag + + // When notification service is enabled, force upgrade is handled by + // createForceUpgradeNotificationDataSource instead + if (isNotificationServiceEnabled) { + return null + } + + return +} diff --git a/apps/mobile/src/components/home/FiatOnRampActionModal.test.tsx b/apps/mobile/src/components/home/FiatOnRampActionModal.test.tsx new file mode 100644 index 00000000..d201f6a6 --- /dev/null +++ b/apps/mobile/src/components/home/FiatOnRampActionModal.test.tsx @@ -0,0 +1,137 @@ +import { fireEvent } from '@testing-library/react-native' +import React from 'react' +import { navigate } from 'src/app/navigation/rootNavigation' +import { AppStackScreenProp } from 'src/app/navigation/types' +import { FiatOnRampActionModal } from 'src/components/home/FiatOnRampActionModal' +import { preloadedMobileState } from 'src/test/fixtures' +import { renderWithProviders } from 'src/test/render' +import { ModalName } from 'uniswap/src/features/telemetry/constants' +import { ON_PRESS_EVENT_PAYLOAD } from 'uniswap/src/test/fixtures' + +const mockOnClose = jest.fn() + +jest.mock('src/components/modals/useReactNavigationModal', () => ({ + useReactNavigationModal: (): { onClose: jest.Mock; preventCloseRef: { current: boolean } } => ({ + onClose: mockOnClose, + preventCloseRef: { current: false }, + }), +})) + +jest.mock('src/app/navigation/rootNavigation', () => ({ + navigate: jest.fn(), +})) + +jest.mock('@universe/gating', () => ({ + ...jest.requireActual('@universe/gating'), + useFeatureFlag: jest.fn().mockReturnValue(false), + useFeatureFlagWithLoading: jest.fn().mockReturnValue({ value: false, isLoading: false }), + useFeatureFlagWithExposureLoggingDisabled: jest.fn().mockReturnValue(false), +})) + +const mockDispatch = jest.fn() +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useDispatch: (): jest.Mock => mockDispatch, +})) + +function createProps(entry: 'onramp' | 'offramp'): AppStackScreenProp { + return { + navigation: { + navigate: jest.fn(), + goBack: jest.fn(), + } as unknown as AppStackScreenProp['navigation'], + route: { + key: 'fiat-on-ramp-action', + name: ModalName.FiatOnRampAction, + params: { entry }, + }, + } +} + +describe('FiatOnRampActionModal', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + describe('rendering', () => { + it('should render Buy variant with correct text', () => { + const { getByText } = renderWithProviders(, { + preloadedState: preloadedMobileState({}), + }) + + expect(getByText('Buy with cash')).toBeTruthy() + expect(getByText('Use a debit card or bank account')).toBeTruthy() + expect(getByText('Swap tokens')).toBeTruthy() + expect(getByText('Trade using your existing balance')).toBeTruthy() + }) + + it('should render Sell variant with correct text', () => { + const { getByText } = renderWithProviders(, { + preloadedState: preloadedMobileState({}), + }) + + expect(getByText('Sell for cash')).toBeTruthy() + expect(getByText('Withdraw to a debit card or bank')).toBeTruthy() + expect(getByText('Swap tokens')).toBeTruthy() + expect(getByText('Trade using your existing balance')).toBeTruthy() + }) + }) + + describe('Buy variant behavior', () => { + it('should close modal and open FiatOnRampAggregator when pressing buy with cash', () => { + const { getByText } = renderWithProviders(, { + preloadedState: preloadedMobileState({}), + }) + + fireEvent.press(getByText('Buy with cash'), ON_PRESS_EVENT_PAYLOAD) + + expect(mockOnClose).toHaveBeenCalledTimes(1) + expect(mockDispatch).toHaveBeenCalledWith( + expect.objectContaining({ + payload: expect.objectContaining({ + name: ModalName.FiatOnRampAggregator, + }), + }), + ) + // Should NOT have isOfframp in the payload for onramp + expect(mockDispatch).not.toHaveBeenCalledWith( + expect.objectContaining({ + payload: expect.objectContaining({ + initialState: expect.objectContaining({ isOfframp: true }), + }), + }), + ) + }) + + it('should close modal and navigate to Swap when pressing swap tokens', () => { + const { getByText } = renderWithProviders(, { + preloadedState: preloadedMobileState({}), + }) + + fireEvent.press(getByText('Swap tokens'), ON_PRESS_EVENT_PAYLOAD) + + expect(mockOnClose).toHaveBeenCalledTimes(1) + expect(navigate).toHaveBeenCalledWith(ModalName.Swap) + }) + }) + + describe('Sell variant behavior', () => { + it('should close modal and open FiatOnRampAggregator with isOfframp when pressing sell for cash', () => { + const { getByText } = renderWithProviders(, { + preloadedState: preloadedMobileState({}), + }) + + fireEvent.press(getByText('Sell for cash'), ON_PRESS_EVENT_PAYLOAD) + + expect(mockOnClose).toHaveBeenCalledTimes(1) + expect(mockDispatch).toHaveBeenCalledWith( + expect.objectContaining({ + payload: expect.objectContaining({ + name: ModalName.FiatOnRampAggregator, + initialState: { isOfframp: true }, + }), + }), + ) + }) + }) +}) diff --git a/apps/mobile/src/components/home/FiatOnRampActionModal.tsx b/apps/mobile/src/components/home/FiatOnRampActionModal.tsx new file mode 100644 index 00000000..097db2dc --- /dev/null +++ b/apps/mobile/src/components/home/FiatOnRampActionModal.tsx @@ -0,0 +1,108 @@ +import { FeatureFlags, useFeatureFlag } from '@universe/gating' +import { useCallback, useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { useDispatch } from 'react-redux' +import { navigate } from 'src/app/navigation/rootNavigation' +import { AppStackScreenProp } from 'src/app/navigation/types' +import { useReactNavigationModal } from 'src/components/modals/useReactNavigationModal' +import { openModal } from 'src/features/modals/modalSlice' +import { Flex, Text, TouchableArea } from 'ui/src' +import { GeneratedIcon } from 'ui/src/components/factories/createIcon' +import { ArrowUpCircle, Bank, CoinConvert } from 'ui/src/components/icons' +import { Modal } from 'uniswap/src/components/modals/Modal' +import { ElementName, ModalName } from 'uniswap/src/features/telemetry/constants' +import { Trace } from 'uniswap/src/features/telemetry/Trace' + +type ActionRow = { + Icon: GeneratedIcon + title: string + subtitle: string + elementName: ElementName + onPress: () => void +} + +export function FiatOnRampActionModal({ route }: AppStackScreenProp): JSX.Element { + const { entry } = route.params + const { t } = useTranslation() + const dispatch = useDispatch() + const { onClose } = useReactNavigationModal() + const isOfframp = entry === 'offramp' + const disableForKorea = useFeatureFlag(FeatureFlags.DisableFiatOnRampKorea) + + const onPressFiatOption = useCallback((): void => { + onClose() + if (disableForKorea) { + navigate(ModalName.KoreaCexTransferInfoModal) + return + } + dispatch( + openModal({ + name: ModalName.FiatOnRampAggregator, + ...(isOfframp && { initialState: { isOfframp: true } }), + }), + ) + }, [onClose, dispatch, isOfframp, disableForKorea]) + + const onPressSwap = useCallback((): void => { + onClose() + navigate(ModalName.Swap) + }, [onClose]) + + const actions: ActionRow[] = useMemo( + () => [ + isOfframp + ? { + Icon: ArrowUpCircle, + title: t('fiatOnRamp.action.sellForCash'), + subtitle: t('fiatOnRamp.action.sellForCash.description'), + elementName: ElementName.Sell, + onPress: onPressFiatOption, + } + : { + Icon: Bank, + title: t('fiatOnRamp.action.buyWithCash'), + subtitle: t('fiatOnRamp.action.buyWithCash.description'), + elementName: ElementName.Buy, + onPress: onPressFiatOption, + }, + { + Icon: CoinConvert, + title: t('fiatOnRamp.action.swapTokens'), + subtitle: t('fiatOnRamp.action.swapTokens.description'), + elementName: ElementName.Swap, + onPress: onPressSwap, + }, + ], + [isOfframp, t, onPressFiatOption, onPressSwap], + ) + + return ( + + + {actions.map(({ Icon, title, subtitle, elementName, onPress }) => ( + + + + + + + + {title} + + + {subtitle} + + + + + ))} + + + ) +} diff --git a/apps/mobile/src/components/home/HomeExploreTab.tsx b/apps/mobile/src/components/home/HomeExploreTab.tsx new file mode 100644 index 00000000..7dbf64e4 --- /dev/null +++ b/apps/mobile/src/components/home/HomeExploreTab.tsx @@ -0,0 +1,208 @@ +import { ReactNavigationPerformanceView } from '@shopify/react-native-performance-navigation' +import { GraphQLApi } from '@universe/api' +import { DynamicConfigs, HomeScreenExploreTokensConfigKey, useDynamicConfigValue } from '@universe/gating' +import { ForwardedRef, forwardRef, memo, useCallback, useEffect, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { FlatList, LayoutRectangle, RefreshControl } from 'react-native' +import Animated from 'react-native-reanimated' +import { useSelector } from 'react-redux' +import { TokenItem } from 'src/components/explore/TokenItem' +import { TokenItemData } from 'src/components/explore/TokenItemData' +import { useAdaptiveFooter } from 'src/components/home/hooks' +import { AnimatedFlatList } from 'src/components/layout/AnimatedFlatList' +import { TAB_BAR_HEIGHT, TabProps } from 'src/components/layout/TabHelpers' +import { AnimatePresence, Flex, LinearGradient, Text, useIsDarkMode, useSporeColors } from 'ui/src' +import { SwirlyArrowDown } from 'ui/src/components/icons' +import { spacing, zIndexes } from 'ui/src/theme' +import { fromGraphQLChain } from 'uniswap/src/features/chains/utils' +import { useAppFiatCurrency } from 'uniswap/src/features/fiatCurrency/hooks' +import { isContractInputArrayType } from 'uniswap/src/features/gating/typeGuards' +import { MobileEventName } from 'uniswap/src/features/telemetry/constants' +import { useAppInsets } from 'uniswap/src/hooks/useAppInsets' +import { MobileScreens } from 'uniswap/src/types/screens/mobile' +import { isAndroid } from 'utilities/src/platform' +import { selectHasUsedExplore } from 'wallet/src/features/behaviorHistory/selectors' +import { TokenMetadataDisplayType } from 'wallet/src/features/wallet/types' + +const ESTIMATED_ITEM_SIZE = 68 + +export const HomeExploreTab = memo( + forwardRef, TabProps>(function HomeExploreTabInner( + { containerProps, scrollHandler, headerHeight, refreshing, onRefresh }, + ref, + ) { + const isDarkMode = useIsDarkMode() + const colors = useSporeColors() + const insets = useAppInsets() + const appFiatCurrency = useAppFiatCurrency() + const [maxTokenPriceWrapperWidth, setMaxTokenPriceWrapperWidth] = useState(0) + + const ethChainId = useDynamicConfigValue({ + config: DynamicConfigs.HomeScreenExploreTokens, + key: HomeScreenExploreTokensConfigKey.EthChainId, + defaultValue: GraphQLApi.Chain.Ethereum, + customTypeGuard: (x): x is GraphQLApi.Chain => Object.values(GraphQLApi.Chain).includes(x as GraphQLApi.Chain), + }) + + const recommendedTokens = useDynamicConfigValue({ + config: DynamicConfigs.HomeScreenExploreTokens, + key: HomeScreenExploreTokensConfigKey.Tokens, + defaultValue: [], + customTypeGuard: isContractInputArrayType, + }) + + const { onContentSizeChange } = useAdaptiveFooter(containerProps?.contentContainerStyle) + + const { data } = GraphQLApi.useHomeScreenTokensQuery({ + variables: { contracts: recommendedTokens, chain: ethChainId }, + }) + const tokenDataList = useMemo( + () => + [data?.eth, ...(data?.tokens ?? [])] + .map((token) => gqlTokenToTokenItemData(token)) + .filter((tokenItemData): tokenItemData is TokenItemData => !!tokenItemData), + [data], + ) + + // oxlint-disable-next-line react/exhaustive-deps -- fiat currency causes price layout width to change but does not change token data + useEffect(() => { + setMaxTokenPriceWrapperWidth(0) + }, [appFiatCurrency]) + + const onTokenLayout = useCallback((layout: LayoutRectangle) => { + setMaxTokenPriceWrapperWidth((prev) => Math.max(prev, layout.width)) + }, []) + + const renderToken = useCallback( + ({ item, index }: { item: TokenItemData; index: number }) => { + const gradientColor = isDarkMode ? 'rgba(0, 0, 0, 0)' : 'rgba(255, 255, 255, 0)' + + // Used to position each row properly along the gradient to align with the overall layout + // Needed because can't apply single gradient to flat list as it's built for virtualization + const gradientYStart = -index + const gradientYEnd = tokenDataList.length - index + + return ( + + + + + } + priceWrapperProps={{ minWidth: maxTokenPriceWrapperWidth }} + tokenItemData={item} + onPriceWrapperLayout={onTokenLayout} + /> + + ) + }, + [isDarkMode, tokenDataList.length, maxTokenPriceWrapperWidth, onTokenLayout], + ) + + const refreshControl = useMemo(() => { + return ( + + ) + }, [refreshing, headerHeight, onRefresh, colors.neutral3, insets.top]) + + return ( + + + >} + ListFooterComponent={FooterElement} + data={tokenDataList} + estimatedItemSize={ESTIMATED_ITEM_SIZE} + initialNumToRender={20} + maxToRenderPerBatch={20} + refreshControl={refreshControl} + refreshing={refreshing} + renderItem={renderToken} + showsVerticalScrollIndicator={false} + updateCellsBatchingPeriod={10} + onContentSizeChange={onContentSizeChange} + onRefresh={onRefresh} + onScroll={scrollHandler} + {...containerProps} + /> + + + ) + }), +) + +function FooterElement(): JSX.Element { + const { t } = useTranslation() + const hasUsedExplore = useSelector(selectHasUsedExplore) + + return ( + + {!hasUsedExplore && ( + + + {t('home.explore.footer')} + + + + )} + + ) +} + +function gqlTokenToTokenItemData( + token: GraphQLApi.Maybe[0]>>, +): TokenItemData | null { + if (!token || !token.project) { + return null + } + + const { name, symbol, address, chain, project } = token + const { logoUrl, markets } = project + const tokenProjectMarket = markets?.[0] + + const chainId = fromGraphQLChain(chain) + + if (!chainId || !name || !symbol || !logoUrl) { + return null + } + + return { + chainId, + address: address ?? null, + name, + symbol, + logoUrl, + price: tokenProjectMarket?.price?.value, + pricePercentChange24h: tokenProjectMarket?.pricePercentChange24h?.value, + } satisfies TokenItemData +} diff --git a/apps/mobile/src/components/home/NftsTab.tsx b/apps/mobile/src/components/home/NftsTab.tsx new file mode 100644 index 00000000..5ccba77a --- /dev/null +++ b/apps/mobile/src/components/home/NftsTab.tsx @@ -0,0 +1,108 @@ +import { FlashList } from '@shopify/flash-list' +import React, { forwardRef, memo, useCallback, useMemo } from 'react' +import { RefreshControl } from 'react-native' +import { useAdaptiveFooter } from 'src/components/home/hooks' +import { TAB_BAR_HEIGHT, TabProps } from 'src/components/layout/TabHelpers' +import { Flex, useSporeColors } from 'ui/src' +import { NftsList } from 'uniswap/src/components/nfts/NftsList' +import { NftViewWithContextMenu } from 'uniswap/src/components/nfts/NftViewWithContextMenu' +import { useEnabledChains } from 'uniswap/src/features/chains/hooks/useEnabledChains' +import { fromGraphQLChain } from 'uniswap/src/features/chains/utils' +import { useNavigateToNftExplorerLink } from 'uniswap/src/features/nfts/hooks/useNavigateToNftExplorerLink' +import { NFTItem } from 'uniswap/src/features/nfts/types' +import { useAppInsets } from 'uniswap/src/hooks/useAppInsets' +import { TestID } from 'uniswap/src/test/fixtures/testIDs' +import { getOpenseaLink, openUri } from 'uniswap/src/utils/linking' +import { isAndroid } from 'utilities/src/platform' +import { useAccounts } from 'wallet/src/features/wallet/hooks' + +export const NftsTab = memo( + forwardRef, TabProps>(function NftsTabInner( + { + owner, + containerProps, + scrollHandler, + isExternalProfile = false, + refreshing, + onRefresh, + headerHeight = 0, + renderedInModal = false, + }, + ref, + ) { + const colors = useSporeColors() + const insets = useAppInsets() + const accounts = useAccounts() + const { defaultChainId } = useEnabledChains() + const navigateToNftExplorerLink = useNavigateToNftExplorerLink() + + const { onContentSizeChange, footerHeight, adaptiveFooter } = useAdaptiveFooter( + containerProps?.contentContainerStyle, + ) + + const renderNFTItem = useCallback( + (item: NFTItem, index: number) => { + const onPressNft = async (): Promise => { + const nftDetails = { + chainId: fromGraphQLChain(item.chain) ?? defaultChainId, + contractAddress: item.contractAddress ?? '', + tokenId: item.tokenId ?? '', + } + const openseaUrl = getOpenseaLink(nftDetails) + + if (openseaUrl) { + await openUri({ uri: openseaUrl }) + } else { + navigateToNftExplorerLink(nftDetails) + } + } + + return ( + + + + ) + }, + [owner, accounts, defaultChainId, navigateToNftExplorerLink], + ) + + const refreshControl = useMemo(() => { + return ( + + ) + }, [refreshing, headerHeight, onRefresh, colors.neutral3, insets.top]) + + return ( + + + + ) + }), +) diff --git a/apps/mobile/src/components/home/PortfolioChart/PortfolioChart.tsx b/apps/mobile/src/components/home/PortfolioChart/PortfolioChart.tsx new file mode 100644 index 00000000..364523e5 --- /dev/null +++ b/apps/mobile/src/components/home/PortfolioChart/PortfolioChart.tsx @@ -0,0 +1,179 @@ +import { ChartPeriod } from '@uniswap/client-data-api/dist/data/v1/api_pb' +import { LinearGradient } from 'expo-linear-gradient' +import { memo, useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { I18nManager, StyleSheet } from 'react-native' +import { DotGrid } from 'src/components/charts/DotGrid' +import { type ChartData, SparklineChart } from 'src/components/home/PortfolioChart/SparklineChart' +import { Loader } from 'src/components/loading/loaders' +import { Flex, Separator, Text, TouchableArea, useSporeColors } from 'ui/src' +import { useDeviceDimensions } from 'ui/src/hooks/useDeviceDimensions' +import { opacify } from 'ui/src/theme' +import { + CHART_PERIOD_OPTIONS, + chartPeriodToElementName, + chartPeriodToLabel, + chartPeriodToTestIdSuffix, +} from 'uniswap/src/features/portfolio/chartPeriod' +import { Trace } from 'uniswap/src/features/telemetry/Trace' +import { TestID } from 'uniswap/src/test/fixtures/testIDs' + +const EXPANDED_CHART_HEIGHT = 180 +const COLLAPSED_CHART_VISIBLE_HEIGHT = 70 +const COLLAPSED_CHART_TOP_OFFSET = -5 +const COLLAPSED_CHART_WIDTH = 100 +const GRADIENT_WIDTH = 40 +// Horizontal padding: 24px each side from contentHeader + wrapper padding layers +const CHART_HORIZONTAL_PADDING = 48 + +interface PortfolioChartProps { + data: ChartData + loading: boolean + chartColor: string + isExpanded: boolean + chartPeriod: ChartPeriod + onChartPeriodChange: (period: ChartPeriod) => void + onScrub?: (point: ChartData[number] | null) => void + isTotalValueMatch: boolean +} + +export const PortfolioChart = memo(function PortfolioChart({ + data, + loading, + chartColor, + isExpanded, + chartPeriod, + onChartPeriodChange, + onScrub, + isTotalValueMatch, +}: PortfolioChartProps): JSX.Element | null { + const { t } = useTranslation() + const colors = useSporeColors() + const { fullWidth } = useDeviceDimensions() + + const isRTL = I18nManager.isRTL + const chartWidth = fullWidth - CHART_HORIZONTAL_PADDING + + // Slice data to only the points visible in the collapsed chart width + const collapsedData = useMemo(() => { + if (data.length <= 2) { + return data + } + const visibleRatio = COLLAPSED_CHART_WIDTH / chartWidth + const visibleCount = Math.max(2, Math.ceil(data.length * visibleRatio)) + return data.slice(-visibleCount) + }, [data, chartWidth]) + + if (!isExpanded) { + return ( + + {loading && data.length === 0 ? ( + + ) : ( + <> + + + + + + )} + + + ) + } + + return ( + + {/* Chart area */} + + {loading && data.length === 0 ? ( + + ) : ( + <> + + + + + + )} + + + + {/* Time range selector */} + + {CHART_PERIOD_OPTIONS.map((period) => { + const isSelected = period === chartPeriod + const periodIdSuffix = chartPeriodToTestIdSuffix(period) + return ( + + onChartPeriodChange(period)}> + + + {chartPeriodToLabel(t, period)} + + + + + ) + })} + + + + ) +}) + +const styles = StyleSheet.create({ + leftGradient: { + bottom: 0, + left: 0, + position: 'absolute', + top: 0, + width: GRADIENT_WIDTH, + }, +}) diff --git a/apps/mobile/src/components/home/PortfolioChart/PortfolioOverview.tsx b/apps/mobile/src/components/home/PortfolioChart/PortfolioOverview.tsx new file mode 100644 index 00000000..05c9b374 --- /dev/null +++ b/apps/mobile/src/components/home/PortfolioChart/PortfolioOverview.tsx @@ -0,0 +1,207 @@ +import { useQueryClient } from '@tanstack/react-query' +import { SharedEventName } from '@uniswap/analytics-events' +import { ChartPeriod } from '@uniswap/client-data-api/dist/data/v1/api_pb' +import { useCallback, useEffect, useMemo, useState } from 'react' +import { navigate } from 'src/app/navigation/rootNavigation' +import { PortfolioChart } from 'src/components/home/PortfolioChart/PortfolioChart' +import { type ChartData } from 'src/components/home/PortfolioChart/SparklineChart' +import { usePortfolioChartData } from 'src/components/home/PortfolioChart/usePortfolioChartData' +import { PortfolioPerformance } from 'src/components/home/PortfolioPerformance' +import { Flex, TouchableArea } from 'ui/src' +import { useLayoutAnimationOnChange } from 'ui/src/animations/layout' +import { AnglesMaximize } from 'ui/src/components/icons/AnglesMaximize' +import { AnglesMinimize } from 'ui/src/components/icons/AnglesMinimize' +import { getPortfolioHistoricalValueChartQuery } from 'uniswap/src/data/rest/getPortfolioChart' +import { usePortfolioTotalValue } from 'uniswap/src/features/dataApi/balances/balancesRest' +import { CHART_PERIOD_OPTIONS } from 'uniswap/src/features/portfolio/chartPeriod' +import { PortfolioBalance } from 'uniswap/src/features/portfolio/PortfolioBalance/PortfolioBalance' +import { getPortfolioChartPercentChange } from 'uniswap/src/features/portfolio/portfolioChartPercentChange' +import { usePortfolioChartBalanceMismatch } from 'uniswap/src/features/portfolio/usePortfolioChartBalanceMismatch' +import { useHapticFeedback } from 'uniswap/src/features/settings/useHapticFeedback/useHapticFeedback' +import { ElementName, ModalName } from 'uniswap/src/features/telemetry/constants' +import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { TestID } from 'uniswap/src/test/fixtures/testIDs' + +interface PortfolioChartSectionProps { + evmAddress: string + chainIds: number[] + isPnLEnabled: boolean +} + +export function PortfolioOverview({ evmAddress, chainIds, isPnLEnabled }: PortfolioChartSectionProps): JSX.Element { + const [isChartExpanded, setIsChartExpanded] = useState(false) + const [chartPeriod, setChartPeriod] = useState(ChartPeriod.DAY) + const [chartScrubFiatValue, setChartScrubFiatValue] = useState(undefined) + const queryClient = useQueryClient() + const { hapticFeedback } = useHapticFeedback() + + const handleScrub = useCallback( + async (point: ChartData[number] | null) => { + if (point) { + await hapticFeedback.light() + } + setChartScrubFiatValue(point?.value) + }, + [hapticFeedback], + ) + + useEffect(() => { + if (!isChartExpanded) { + setChartScrubFiatValue(undefined) + } + }, [isChartExpanded]) + + const { + data: chartData, + loading: chartLoading, + chartColor, + } = usePortfolioChartData({ + evmAddress, + chartPeriod, + chainIds, + enabled: isPnLEnabled, + }) + + const chartPercentChange = useMemo(() => { + const firstPoint = chartData[0] + if (chartScrubFiatValue !== undefined && firstPoint !== undefined) { + return getPortfolioChartPercentChange([firstPoint.value, chartScrubFiatValue]) + } + return getPortfolioChartPercentChange(chartData.map((d) => d.value)) + }, [chartData, chartScrubFiatValue]) + + const lastChartValue = useMemo(() => { + if (chartData.length === 0) { + return undefined + } + return chartData[chartData.length - 1]?.value + }, [chartData]) + + const { data: portfolioData } = usePortfolioTotalValue({ + evmAddress, + chainIds, + }) + + const { isTotalValueMatch } = usePortfolioChartBalanceMismatch({ + lastChartValue, + portfolioTotalBalanceUSD: portfolioData?.balanceUSD, + }) + + const canShowChart = isPnLEnabled && chartData.length > 0 + + useLayoutAnimationOnChange(isChartExpanded) + + useEffect(() => { + // Only collapse when we definitively have no data (not during a loading/refetch transition). + // Without this guard, changing the chart period triggers a refetch that temporarily empties + // chartData, which would incorrectly collapse the expanded chart. + if (!canShowChart && !chartLoading) { + setIsChartExpanded(false) + } + }, [canShowChart, chartLoading]) + + // Prefetch all chart periods when expanded so switching is instant + useEffect(() => { + if (!isChartExpanded || !evmAddress) { + return + } + for (const period of CHART_PERIOD_OPTIONS) { + if (period === chartPeriod) { + continue + } + queryClient + .prefetchQuery( + getPortfolioHistoricalValueChartQuery({ + input: { evmAddress, chartPeriod: period, chainIds }, + }), + ) + .catch(() => undefined) + } + }, [isChartExpanded, evmAddress, chartPeriod, chainIds, queryClient]) + + const toggleChartExpanded = useCallback(() => { + sendAnalyticsEvent(SharedEventName.ELEMENT_CLICKED, { + element: ElementName.PortfolioChart, + }) + setIsChartExpanded((prev) => !prev) + }, []) + + const openReportModal = useCallback(() => { + navigate(ModalName.ReportPortfolioData, {}) + }, []) + + const chartToggleIcon = useMemo((): JSX.Element | undefined => { + if (!canShowChart) { + return undefined + } + const Icon = isChartExpanded ? AnglesMinimize : AnglesMaximize + return ( + + + + ) + }, [canShowChart, isChartExpanded]) + + return ( + <> + + + {canShowChart && !isChartExpanded ? ( + + + + + + + ) : ( + + )} + + + {canShowChart && isChartExpanded && ( + + + + + + + )} + + ) +} diff --git a/apps/mobile/src/components/home/PortfolioChart/SparklineChart.tsx b/apps/mobile/src/components/home/PortfolioChart/SparklineChart.tsx new file mode 100644 index 00000000..8064d91d --- /dev/null +++ b/apps/mobile/src/components/home/PortfolioChart/SparklineChart.tsx @@ -0,0 +1,303 @@ +import { memo, useCallback, useEffect, useId, useMemo } from 'react' +import { + GestureEvent, + LongPressGestureHandler, + LongPressGestureHandlerEventPayload, +} from 'react-native-gesture-handler' +import Animated, { + runOnJS, + SharedValue, + useAnimatedGestureHandler, + useAnimatedProps, + useAnimatedReaction, + useDerivedValue, + useSharedValue, + withRepeat, + withTiming, +} from 'react-native-reanimated' +import Svg, { + Circle, + ClipPath, + Defs, + G, + Line, + Path, + Rect, + Stop, + LinearGradient as SvgLinearGradient, +} from 'react-native-svg' +import { + computeChartPaths, + findNearestIndex, + getYForX, + parseSvgPath, +} from 'src/components/home/PortfolioChart/sparklineUtils' + +type ChartPoint = { timestamp: number; value: number } +export type ChartData = ChartPoint[] + +const AnimatedCircle = Animated.createAnimatedComponent(Circle) +const AnimatedLine = Animated.createAnimatedComponent(Line) +const AnimatedRect = Animated.createAnimatedComponent(Rect) + +const STROKE_WIDTH = 1.5 +const DOT_RADIUS = 5 +const PULSE_MAX_RADIUS = 12 +const PULSE_DURATION_MS = 2000 +const SCRUB_DOT_RADIUS = 4 +const SCRUB_LINE_WIDTH = 1 +const SCRUB_ACTIVATION_DELAY_MS = 150 +const SCRUB_MAX_DISTANCE = 999999 +const INACTIVE_LINE_OPACITY = 0.2 +const INACTIVE_AREA_OPACITY = 0.35 + +interface SparklineChartProps { + data: ChartData + width: number + height: number + color: string + yGutter?: number + showDot?: boolean + dotStrokeColor?: string + interactive?: boolean + onScrub?: (point: ChartPoint | null) => void +} + +export const SparklineChart = memo(function SparklineChart({ + data, + width, + height, + color, + yGutter = 0, + showDot = false, + dotStrokeColor, + interactive = false, + onScrub, +}: SparklineChartProps): JSX.Element | null { + const gradientId = `sparkline-gradient-${useId()}` + const clipPathId = `sparkline-clip-${useId()}` + // When showing the dot, reserve right padding so the pulse circle isn't clipped + const rightPadding = showDot ? PULSE_MAX_RADIUS : 0 + const dataWidth = Math.max(width - rightPadding, 1) + + const scrubX = useSharedValue(-1) + const scrubIndex = useSharedValue(-1) + const scrubActive = useSharedValue(false) + + const { linePath, areaPath, lastPoint, timestamps } = useMemo( + () => computeChartPaths({ data, dataWidth, height, yGutter }), + [data, dataWidth, height, yGutter], + ) + + const parsedSegments = useMemo(() => (linePath ? parseSvgPath(linePath) : null), [linePath]) + + const scrubY = useDerivedValue(() => { + if (!parsedSegments || scrubX.value < 0) { + return 0 + } + + return getYForX(parsedSegments, Math.min(scrubX.value, dataWidth)) ?? 0 + }) + + const handleScrubIndexChange = useCallback( + (index: number) => { + onScrub?.(data[index] ?? null) + }, + [data, onScrub], + ) + + const handleScrubEnd = useCallback(() => { + onScrub?.(null) + }, [onScrub]) + + useAnimatedReaction( + () => scrubIndex.value, + (currentIndex, previousIndex) => { + if (currentIndex === previousIndex || currentIndex < 0 || currentIndex >= data.length) { + return + } + + runOnJS(handleScrubIndexChange)(currentIndex) + }, + [data.length, handleScrubIndexChange], + ) + + const onGestureEvent = useAnimatedGestureHandler>( + { + onActive: ({ x }) => { + if (data.length < 2 || !timestamps) { + return + } + + const clampedX = Math.max(0, Math.min(x, dataWidth)) + scrubActive.value = true + scrubX.value = clampedX + + scrubIndex.value = findNearestIndex({ timestamps, normalizedX: clampedX / dataWidth }) + }, + onEnd: () => { + scrubActive.value = false + scrubX.value = -1 + scrubIndex.value = -1 + runOnJS(handleScrubEnd)() + }, + onFail: () => { + scrubActive.value = false + scrubX.value = -1 + scrubIndex.value = -1 + runOnJS(handleScrubEnd)() + }, + onCancel: () => { + scrubActive.value = false + scrubX.value = -1 + scrubIndex.value = -1 + runOnJS(handleScrubEnd)() + }, + }, + [data.length, dataWidth, timestamps, handleScrubEnd, scrubActive, scrubIndex, scrubX], + ) + + const clipRectProps = useAnimatedProps(() => ({ + height, + width: interactive && scrubActive.value ? scrubX.value : width, + x: 0, + y: 0, + })) + + const scrubLineProps = useAnimatedProps(() => ({ + x1: scrubX.value, + y1: 0, + x2: scrubX.value, + y2: height, + opacity: scrubActive.value ? 1 : 0, + })) + + const scrubDotProps = useAnimatedProps(() => ({ + cx: scrubX.value, + cy: scrubY.value, + opacity: scrubActive.value ? 1 : 0, + })) + + const liveDotProps = useAnimatedProps(() => ({ + opacity: scrubActive.value ? 0 : 1, + })) + + if (!linePath || !areaPath) { + return null + } + + const chartContent = ( + + + + + + + {interactive && ( + + + + )} + + {interactive && ( + <> + + + + )} + + + + {showDot && lastPoint && ( + <> + + {interactive ? ( + + ) : ( + + )} + + )} + + {interactive && ( + <> + + + + )} + + ) + + if (interactive) { + return ( + + {chartContent} + + ) + } + + return chartContent +}) + +const PulseDot = memo(function PulseDot({ + cx, + cy, + color, + hidden, +}: { + cx: number + cy: number + color: string + hidden?: SharedValue +}): JSX.Element { + const progress = useSharedValue(0) + + useEffect(() => { + progress.value = withRepeat(withTiming(1, { duration: PULSE_DURATION_MS }), -1, false) + // oxlint-disable-next-line react-hooks/exhaustive-deps -- progress is a stable Reanimated SharedValue + }, []) + + const animatedProps = useAnimatedProps(() => ({ + r: DOT_RADIUS + progress.value * (PULSE_MAX_RADIUS - DOT_RADIUS), + opacity: hidden?.value ? 0 : 0.4 * (1 - progress.value), + })) + + return +}) diff --git a/apps/mobile/src/components/home/PortfolioChart/sparklineUtils.test.ts b/apps/mobile/src/components/home/PortfolioChart/sparklineUtils.test.ts new file mode 100644 index 00000000..210d3be6 --- /dev/null +++ b/apps/mobile/src/components/home/PortfolioChart/sparklineUtils.test.ts @@ -0,0 +1,197 @@ +import { + computeChartPaths, + findNearestIndex, + getYForX, + parseSvgPath, +} from 'src/components/home/PortfolioChart/sparklineUtils' + +describe('findNearestIndex', () => { + it('returns 0 for normalizedX at the start', () => { + const timestamps = { minT: 100, rangeT: 900, values: [100, 400, 700, 1000] } + expect(findNearestIndex({ timestamps, normalizedX: 0 })).toBe(0) + }) + + it('returns last index for normalizedX at the end', () => { + const timestamps = { minT: 100, rangeT: 900, values: [100, 400, 700, 1000] } + expect(findNearestIndex({ timestamps, normalizedX: 1 })).toBe(3) + }) + + it('handles evenly spaced timestamps', () => { + const timestamps = { minT: 0, rangeT: 300, values: [0, 100, 200, 300] } + // normalizedX 0.5 → timestamp 150, nearest is index 1 (100) or 2 (200) + const result = findNearestIndex({ timestamps, normalizedX: 0.5 }) + expect(result).toBe(1) // 150 is closer to 100 than 200? No: |150-100|=50, |150-200|=50 → tie goes to first found + }) + + it('correctly handles unevenly spaced timestamps — the original bug', () => { + // Data clustered at the start: timestamps 0, 10, 20, 30, then a big gap to 1000 + const timestamps = { minT: 0, rangeT: 1000, values: [0, 10, 20, 30, 1000] } + + // Scrubbing at 50% of the chart width → timestamp 500 + // Linear mapping would give index Math.round(0.5 * 4) = 2 (timestamp 20) — WRONG + // Timestamp mapping should give index 4 (timestamp 1000) since 500 is closest to 1000 + // |500-0|=500, |500-10|=490, |500-20|=480, |500-30|=470, |500-1000|=500 + // Actually closest is index 3 (timestamp 30) with distance 470 + expect(findNearestIndex({ timestamps, normalizedX: 0.5 })).toBe(3) + }) + + it('handles data clustered at the end', () => { + // Big gap then clustered: 0, then 970, 980, 990, 1000 + const timestamps = { minT: 0, rangeT: 1000, values: [0, 970, 980, 990, 1000] } + + // Scrubbing at 10% → timestamp 100 + // Linear mapping would give index Math.round(0.1 * 4) = 0 — happens to be correct here + // But at 50% → timestamp 500, linear gives index 2 (timestamp 980) — WRONG + // Correct: closest to 500 is index 0 (timestamp 0, dist 500) or index 1 (timestamp 970, dist 470) + expect(findNearestIndex({ timestamps, normalizedX: 0.5 })).toBe(1) + }) + + it('handles single-element values array', () => { + const timestamps = { minT: 100, rangeT: 1, values: [100] } + expect(findNearestIndex({ timestamps, normalizedX: 0.5 })).toBe(0) + }) + + it('handles two-element values array at midpoint', () => { + const timestamps = { minT: 0, rangeT: 100, values: [0, 100] } + // normalizedX 0.3 → timestamp 30, closer to 0 than 100 + expect(findNearestIndex({ timestamps, normalizedX: 0.3 })).toBe(0) + // normalizedX 0.7 → timestamp 70, closer to 100 than 0 + expect(findNearestIndex({ timestamps, normalizedX: 0.7 })).toBe(1) + }) +}) + +describe('computeChartPaths', () => { + const makeData = (points: [number, number][]): { timestamp: number; value: number }[] => + points.map(([timestamp, value]) => ({ timestamp, value })) + + it('returns nulls for fewer than 2 data points', () => { + const result = computeChartPaths({ data: [{ timestamp: 1, value: 10 }], dataWidth: 100, height: 50, yGutter: 0 }) + expect(result.linePath).toBeNull() + expect(result.areaPath).toBeNull() + expect(result.lastPoint).toBeNull() + expect(result.timestamps).toBeNull() + }) + + it('returns nulls for empty data', () => { + const result = computeChartPaths({ data: [], dataWidth: 100, height: 50, yGutter: 0 }) + expect(result.linePath).toBeNull() + }) + + it('generates valid SVG paths for valid data', () => { + const data = makeData([ + [0, 10], + [50, 20], + [100, 15], + ]) + const result = computeChartPaths({ data, dataWidth: 200, height: 100, yGutter: 0 }) + + expect(result.linePath).toBeTruthy() + expect(result.linePath).toMatch(/^M/) // SVG path starts with M (moveTo) + expect(result.areaPath).toBeTruthy() + expect(result.areaPath).toMatch(/^M/) + }) + + it('computes lastPoint at the correct scaled position', () => { + const data = makeData([ + [0, 0], + [100, 100], + ]) + const result = computeChartPaths({ data, dataWidth: 200, height: 100, yGutter: 0 }) + + // Last point timestamp=100 → scaleX = ((100-0)/100) * 200 = 200 + // Last point value=100 → scaleY = 0 + ((100-100)/100) * 100 = 0 (top of chart) + expect(result.lastPoint).toEqual({ x: 200, y: 0 }) + }) + + it('computes lastPoint with yGutter', () => { + const data = makeData([ + [0, 0], + [100, 100], + ]) + const result = computeChartPaths({ data, dataWidth: 200, height: 100, yGutter: 10 }) + + // scaleY = 10 + ((100-100)/100) * (100-20) = 10 + expect(result.lastPoint).toEqual({ x: 200, y: 10 }) + }) + + it('returns timestamp metadata for scrub index mapping', () => { + const data = makeData([ + [10, 1], + [50, 2], + [90, 3], + ]) + const result = computeChartPaths({ data, dataWidth: 100, height: 50, yGutter: 0 }) + + expect(result.timestamps).toEqual({ + minT: 10, + rangeT: 80, + values: [10, 50, 90], + }) + }) + + it('handles constant timestamps gracefully (rangeT defaults to 1)', () => { + const data = makeData([ + [50, 10], + [50, 20], + ]) + const result = computeChartPaths({ data, dataWidth: 100, height: 50, yGutter: 0 }) + + expect(result.timestamps?.rangeT).toBe(1) + expect(result.linePath).toBeTruthy() + }) + + it('handles constant values gracefully (rangeV defaults to 1)', () => { + const data = makeData([ + [0, 50], + [100, 50], + ]) + const result = computeChartPaths({ data, dataWidth: 100, height: 50, yGutter: 0 }) + + expect(result.linePath).toBeTruthy() + expect(result.lastPoint).toBeTruthy() + }) +}) + +describe('parseSvgPath', () => { + it('parses M + C commands', () => { + const segments = parseSvgPath('M0,10C0,10,48.333,29.333,50,30C51.667,30.667,100,50,100,50') + expect(segments).toHaveLength(2) + expect(segments[0]?.p0x).toBe(0) + expect(segments[0]?.p0y).toBe(10) + expect(segments[0]?.p3x).toBe(50) + expect(segments[0]?.p3y).toBe(30) + expect(segments[1]?.p0x).toBe(50) + expect(segments[1]?.p3x).toBe(100) + }) + + it('parses M + L commands (2-point dataset from d3 curveCardinal)', () => { + const segments = parseSvgPath('M0,10L100,50') + expect(segments).toHaveLength(1) + expect(segments[0]?.p0x).toBe(0) + expect(segments[0]?.p0y).toBe(10) + expect(segments[0]?.p3x).toBe(100) + expect(segments[0]?.p3y).toBe(50) + // Control points should be on the line at 1/3 and 2/3 + expect(segments[0]?.p1x).toBeCloseTo(100 / 3) + expect(segments[0]?.p1y).toBeCloseTo(10 + 40 / 3) + }) + + it('returns empty array for empty path', () => { + expect(parseSvgPath('')).toEqual([]) + }) +}) + +describe('getYForX', () => { + it('interpolates Y for a straight line promoted from L command', () => { + const segments = parseSvgPath('M0,0L100,100') + // Midpoint should be ~50 + const y = getYForX(segments, 50) + expect(y).not.toBeNull() + expect(y!).toBeCloseTo(50, 0) + }) + + it('returns null for x outside all segments', () => { + const segments = parseSvgPath('M10,0L20,100') + expect(getYForX(segments, 500)).toBeNull() + }) +}) diff --git a/apps/mobile/src/components/home/PortfolioChart/sparklineUtils.ts b/apps/mobile/src/components/home/PortfolioChart/sparklineUtils.ts new file mode 100644 index 00000000..0032b74d --- /dev/null +++ b/apps/mobile/src/components/home/PortfolioChart/sparklineUtils.ts @@ -0,0 +1,266 @@ +import { area, curveCardinal, line } from 'd3-shape' + +type ChartPoint = { timestamp: number; value: number } + +const CURVE = curveCardinal.tension(0.9) + +interface ChartPathsResult { + linePath: string | null + areaPath: string | null + lastPoint: { x: number; y: number } | null + timestamps: { minT: number; rangeT: number; values: number[] } | null +} + +export function computeChartPaths({ + data, + dataWidth, + height, + yGutter, +}: { + data: ChartPoint[] + dataWidth: number + height: number + yGutter: number +}): ChartPathsResult { + if (data.length < 2) { + return { linePath: null, areaPath: null, lastPoint: null, timestamps: null } + } + + const first = data[0] + if (!first) { + return { linePath: null, areaPath: null, lastPoint: null, timestamps: null } + } + + let minT = first.timestamp + let maxT = minT + let minV = first.value + let maxV = minV + for (let i = 1; i < data.length; i++) { + const point = data[i] + if (!point) { + continue + } + const { timestamp, value } = point + if (timestamp < minT) { + minT = timestamp + } + if (timestamp > maxT) { + maxT = timestamp + } + if (value < minV) { + minV = value + } + if (value > maxV) { + maxV = value + } + } + + const rangeT = maxT - minT || 1 + const rangeV = maxV - minV || 1 + + const scaleX = (t: number): number => ((t - minT) / rangeT) * dataWidth + const scaleY = (v: number): number => yGutter + ((maxV - v) / rangeV) * (height - yGutter * 2) + + const lineGenerator = line() + .x((d) => scaleX(d.timestamp)) + .y((d) => scaleY(d.value)) + .curve(CURVE) + + const areaGenerator = area() + .x((d) => scaleX(d.timestamp)) + .y0(height) + .y1((d) => scaleY(d.value)) + .curve(CURVE) + + const last = data[data.length - 1] + + return { + linePath: lineGenerator(data), + areaPath: areaGenerator(data), + lastPoint: last ? { x: scaleX(last.timestamp), y: scaleY(last.value) } : null, + timestamps: { minT, rangeT, values: data.map((d) => d.timestamp) }, + } +} + +// ---------- SVG cubic Bézier path utilities (replaces react-native-redash) ---------- + +interface CubicSegment { + p0x: number + p0y: number + p1x: number + p1y: number + p2x: number + p2y: number + p3x: number + p3y: number +} + +function parseCommandNums(cmd: string): number[] { + return ( + cmd + .slice(1) + .match(/-?\d+\.?\d*/g) + ?.map(Number) ?? [] + ) +} + +function parseLineSegments(nums: number[], cursor: { x: number; y: number }): CubicSegment[] { + const segments: CubicSegment[] = [] + for (let i = 0; i + 1 < nums.length; i += 2) { + const endX = nums[i] ?? 0 + const endY = nums[i + 1] ?? 0 + // Promote to cubic Bézier with control points at 1/3 and 2/3 along the line + segments.push({ + p0x: cursor.x, + p0y: cursor.y, + p1x: cursor.x + (endX - cursor.x) / 3, + p1y: cursor.y + (endY - cursor.y) / 3, + p2x: cursor.x + (2 * (endX - cursor.x)) / 3, + p2y: cursor.y + (2 * (endY - cursor.y)) / 3, + p3x: endX, + p3y: endY, + }) + cursor.x = endX + cursor.y = endY + } + return segments +} + +function parseCubicSegments(nums: number[], cursor: { x: number; y: number }): CubicSegment[] { + const segments: CubicSegment[] = [] + for (let i = 0; i + 5 < nums.length; i += 6) { + segments.push({ + p0x: cursor.x, + p0y: cursor.y, + p1x: nums[i] ?? 0, + p1y: nums[i + 1] ?? 0, + p2x: nums[i + 2] ?? 0, + p2y: nums[i + 3] ?? 0, + p3x: nums[i + 4] ?? 0, + p3y: nums[i + 5] ?? 0, + }) + cursor.x = nums[i + 4] ?? 0 + cursor.y = nums[i + 5] ?? 0 + } + return segments +} + +/** + * Parses an SVG path string into cubic Bézier segments. + * Handles M (moveTo), C (cubic Bézier), and L (lineTo) commands from d3 curveCardinal. + * d3 emits L commands for 2-point datasets and C commands for 3+ points. + */ +export function parseSvgPath(d: string): CubicSegment[] { + const commands = d.match(/[MLCZ][^MLCZ]*/gi) + if (!commands) { + return [] + } + + const segments: CubicSegment[] = [] + const cursor = { x: 0, y: 0 } + + for (const cmd of commands) { + const type = cmd[0]?.toUpperCase() + const nums = parseCommandNums(cmd) + + switch (type) { + case 'M': + cursor.x = nums[0] ?? 0 + cursor.y = nums[1] ?? 0 + break + case 'L': + segments.push(...parseLineSegments(nums, cursor)) + break + case 'C': + segments.push(...parseCubicSegments(nums, cursor)) + break + } + } + + return segments +} + +const NEWTON_ITERATIONS = 8 +const NEWTON_EPSILON = 1e-4 + +/** Evaluates a cubic Bézier at parameter t for one axis. p = [p0, p1, p2, p3]. */ +function cubicBezier({ t, p }: { t: number; p: readonly [number, number, number, number] }): number { + 'worklet' + const u = 1 - t + return u * u * u * p[0] + 3 * u * u * t * p[1] + 3 * u * t * t * p[2] + t * t * t * p[3] +} + +/** Derivative of cubic Bézier at parameter t for one axis. p = [p0, p1, p2, p3]. */ +function cubicBezierDeriv({ t, p }: { t: number; p: readonly [number, number, number, number] }): number { + 'worklet' + const u = 1 - t + return 3 * u * u * (p[1] - p[0]) + 6 * u * t * (p[2] - p[1]) + 3 * t * t * (p[3] - p[2]) +} + +/** + * Given parsed cubic Bézier segments and an x coordinate, returns the corresponding y value. + * Uses Newton's method to solve for t where B_x(t) = x, then evaluates B_y(t). + */ +export function getYForX(segments: CubicSegment[], x: number): number | null { + 'worklet' + for (let i = 0; i < segments.length; i++) { + const seg = segments[i] + if (!seg) { + continue + } + + const minX = Math.min(seg.p0x, seg.p3x) + const maxX = Math.max(seg.p0x, seg.p3x) + if (x < minX - 1 || x > maxX + 1) { + continue + } + + const px: [number, number, number, number] = [seg.p0x, seg.p1x, seg.p2x, seg.p3x] + const py: [number, number, number, number] = [seg.p0y, seg.p1y, seg.p2y, seg.p3y] + + let t = (x - seg.p0x) / (seg.p3x - seg.p0x || 1) + t = Math.max(0, Math.min(1, t)) + + for (let j = 0; j < NEWTON_ITERATIONS; j++) { + const dx = cubicBezier({ t, p: px }) - x + if (Math.abs(dx) < NEWTON_EPSILON) { + break + } + const dxdt = cubicBezierDeriv({ t, p: px }) + if (Math.abs(dxdt) < 1e-8) { + break + } + t -= dx / dxdt + t = Math.max(0, Math.min(1, t)) + } + + return cubicBezier({ t, p: py }) + } + + return null +} + +/** + * Maps a normalized X position (0–1) to the nearest data point index + * using actual timestamp values, correctly handling uneven time spacing. + */ +export function findNearestIndex({ + timestamps, + normalizedX, +}: { + timestamps: { minT: number; rangeT: number; values: number[] } + normalizedX: number +}): number { + 'worklet' + const scrubTimestamp = timestamps.minT + normalizedX * timestamps.rangeT + let nearestIndex = 0 + let minDist = Math.abs((timestamps.values[0] ?? 0) - scrubTimestamp) + for (let i = 1; i < timestamps.values.length; i++) { + const dist = Math.abs((timestamps.values[i] ?? 0) - scrubTimestamp) + if (dist < minDist) { + minDist = dist + nearestIndex = i + } + } + return nearestIndex +} diff --git a/apps/mobile/src/components/home/PortfolioChart/usePortfolioChartData.ts b/apps/mobile/src/components/home/PortfolioChart/usePortfolioChartData.ts new file mode 100644 index 00000000..1d612219 --- /dev/null +++ b/apps/mobile/src/components/home/PortfolioChart/usePortfolioChartData.ts @@ -0,0 +1,71 @@ +import { ChartPeriod } from '@uniswap/client-data-api/dist/data/v1/api_pb' +import { useEffect, useMemo } from 'react' +import { type ChartData } from 'src/components/home/PortfolioChart/SparklineChart' +import { useSporeColors } from 'ui/src' +import { useGetPortfolioHistoricalValueChartQuery } from 'uniswap/src/data/rest/getPortfolioChart' +import { logger } from 'utilities/src/logger/logger' + +export function usePortfolioChartData({ + evmAddress, + chartPeriod, + chainIds, + enabled = true, +}: { + evmAddress?: string + chartPeriod: ChartPeriod + chainIds?: number[] + enabled?: boolean +}): { + data: ChartData + loading: boolean + error: Error | null + chartColor: string +} { + const colors = useSporeColors() + + const { + data: chartResponse, + isPending, + isFetching, + error, + } = useGetPortfolioHistoricalValueChartQuery({ + input: { + evmAddress, + chartPeriod, + chainIds, + }, + enabled: enabled && !!evmAddress, + }) + + useEffect(() => { + if (error) { + logger.warn('usePortfolioChartData', 'usePortfolioChartData', 'Portfolio chart query failed', { + evmAddress, + chartPeriod, + }) + } + }, [error, evmAddress, chartPeriod]) + + const data = useMemo(() => { + if (!chartResponse?.points || chartResponse.points.length === 0) { + return [] + } + + return chartResponse.points.map((point) => ({ + // API returns timestamp as bigint in seconds + timestamp: Number(point.timestamp), + value: point.value, + })) + }, [chartResponse?.points]) + + const first = data[0] + const last = data[data.length - 1] + const chartColor = !first || !last || last.value >= first.value ? colors.statusSuccess.val : colors.statusCritical.val + + return { + data, + loading: isPending || isFetching, + error: error ?? null, + chartColor, + } +} diff --git a/apps/mobile/src/components/home/PortfolioPerformance.tsx b/apps/mobile/src/components/home/PortfolioPerformance.tsx new file mode 100644 index 00000000..fc89e7ba --- /dev/null +++ b/apps/mobile/src/components/home/PortfolioPerformance.tsx @@ -0,0 +1,106 @@ +import { memo, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Flex, Text, TouchableArea } from 'ui/src' +import { RotatableChevron } from 'ui/src/components/icons/RotatableChevron' +import { ActionSheetDropdown } from 'uniswap/src/components/dropdowns/ActionSheetDropdown' +import { MenuItemProp } from 'uniswap/src/components/modals/ActionSheetModal' +import { + getProfitLossPeriodLabel, + getProfitLossSince, + PROFIT_LOSS_PERIODS, + ProfitLossPeriod, +} from 'uniswap/src/components/WalletProfitLoss/utils' +import { WalletProfitLoss } from 'uniswap/src/components/WalletProfitLoss/WalletProfitLoss' +import { useGetWalletProfitLossQuery } from 'uniswap/src/data/rest/getWalletProfitLoss' +import { TestID } from 'uniswap/src/test/fixtures/testIDs' + +interface PortfolioPerformanceProps { + evmAddress: string + chainIds: number[] + onReport?: () => void +} + +export const PortfolioPerformance = memo(function PortfolioPerformance({ + evmAddress, + chainIds, + onReport, +}: PortfolioPerformanceProps): JSX.Element | null { + const { t } = useTranslation() + const [selectedPeriod, setSelectedPeriod] = useState(ProfitLossPeriod.ALL) + + const since = useMemo(() => getProfitLossSince(selectedPeriod), [selectedPeriod]) + + const { data, isPending, isError } = useGetWalletProfitLossQuery({ + input: { + evmAddress, + chainIds, + since, + }, + }) + + const profitLoss = isError ? undefined : data?.profitLoss + + const options = useMemo( + () => + PROFIT_LOSS_PERIODS.map((period: ProfitLossPeriod) => ({ + key: period, + onPress: () => setSelectedPeriod(period), + render: () => ( + + + {getProfitLossPeriodLabel(period, t)} + + + ), + })), + [selectedPeriod, t], + ) + + const periodSelector = useMemo( + () => ( + + + + {getProfitLossPeriodLabel(selectedPeriod, t)} + + + + + ), + [options, selectedPeriod, t], + ) + + // Hide on error or when initial load returns no data. + // Don't hide during refetch (period change) — show loading state instead. + if (isError || (!profitLoss && !isPending)) { + return null + } + + return ( + + + {onReport && ( + + {t('reporting.portfolio.report.link')} + + )} + + ) +}) diff --git a/apps/mobile/src/components/home/TokensTab.tsx b/apps/mobile/src/components/home/TokensTab.tsx new file mode 100644 index 00000000..394085ab --- /dev/null +++ b/apps/mobile/src/components/home/TokensTab.tsx @@ -0,0 +1,125 @@ +import { useStartProfiler } from '@shopify/react-native-performance' +import { FeatureFlags, useFeatureFlag } from '@universe/gating' +import React, { forwardRef, memo, useCallback, useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { FlatList } from 'react-native' +import { useDispatch } from 'react-redux' +import { navigate } from 'src/app/navigation/rootNavigation' +import { TabProps } from 'src/components/layout/TabHelpers' +import { TokenBalanceList } from 'src/components/TokenBalanceList/TokenBalanceList' +import { useTokenDetailsNavigation } from 'src/components/TokenDetails/hooks' +import { useOpenReceiveModal } from 'src/features/modals/hooks/useOpenReceiveModal' +import { openModal } from 'src/features/modals/modalSlice' +import { Flex } from 'ui/src' +import { NoTokens } from 'ui/src/components/icons' +import { BaseCard } from 'uniswap/src/components/BaseCard/BaseCard' +import { PortfolioEmptyState } from 'uniswap/src/components/portfolio/PortfolioEmptyState' +import { ScannerModalState } from 'uniswap/src/components/ReceiveQRCode/constants' +import { TokenBalanceListRow } from 'uniswap/src/features/portfolio/types' +import { ModalName } from 'uniswap/src/features/telemetry/constants' +import { CurrencyId } from 'uniswap/src/types/currency' +import { MobileScreens } from 'uniswap/src/types/screens/mobile' +import { usePortfolioEmptyStateBackground } from 'wallet/src/components/portfolio/empty' + +// ignore ref type + +export const TokensTab = memo( + forwardRef, TabProps & { isExternalProfile?: boolean }>(function TokensTabInner( + { + owner, + containerProps, + scrollHandler, + isExternalProfile = false, + renderedInModal = false, + onRefresh, + refreshing, + headerHeight, + testID, + }, + ref, + ) { + const { t } = useTranslation() + const dispatch = useDispatch() + const tokenDetailsNavigation = useTokenDetailsNavigation() + const startProfilerTimer = useStartProfiler() + const onPressReceive = useOpenReceiveModal() + const backgroundImageWrapperCallback = usePortfolioEmptyStateBackground() + + const disableForKorea = useFeatureFlag(FeatureFlags.DisableFiatOnRampKorea) + + const onPressToken = useCallback( + (currencyId: CurrencyId): void => { + startProfilerTimer({ source: MobileScreens.Home }) + tokenDetailsNavigation.navigate(currencyId) + }, + [startProfilerTimer, tokenDetailsNavigation], + ) + + const onPressAction = useCallback((): void => { + dispatch(openModal({ name: ModalName.WalletConnectScan, initialState: ScannerModalState.WalletQr })) + }, [dispatch]) + + const onPressBuy = useCallback(() => { + disableForKorea + ? navigate(ModalName.KoreaCexTransferInfoModal) + : dispatch( + openModal({ + name: ModalName.FiatOnRampAggregator, + }), + ) + }, [disableForKorea, dispatch]) + + const onPressImport = useCallback(() => { + navigate(ModalName.AccountSwitcher) + }, []) + + const renderEmpty = useMemo((): JSX.Element => { + // Show different empty state on external profile pages + return isExternalProfile ? ( + + } + title={t('home.tokens.empty.title')} + onPress={onPressAction} + /> + + ) : ( + + ) + }, [ + isExternalProfile, + containerProps?.emptyComponentStyle, + t, + onPressAction, + onPressBuy, + onPressImport, + onPressReceive, + backgroundImageWrapperCallback, + ]) + + return ( + + + + ) + }), +) diff --git a/apps/mobile/src/components/home/hooks.tsx b/apps/mobile/src/components/home/hooks.tsx new file mode 100644 index 00000000..32993884 --- /dev/null +++ b/apps/mobile/src/components/home/hooks.tsx @@ -0,0 +1,76 @@ +import { FeatureFlags, useFeatureFlag } from '@universe/gating' +import { useCallback, useEffect, useMemo } from 'react' +import { StyleProp, ViewStyle } from 'react-native' +import Animated, { SharedValue, useAnimatedStyle, useSharedValue } from 'react-native-reanimated' +import { ESTIMATED_BOTTOM_TABS_HEIGHT } from 'src/app/navigation/tabs/CustomTabBar/constants' +import { TAB_BAR_HEIGHT } from 'src/components/layout/TabHelpers' +import { useDeviceDimensions } from 'ui/src/hooks/useDeviceDimensions' +import { useAppInsets } from 'uniswap/src/hooks/useAppInsets' +import { useActiveAccount } from 'wallet/src/features/wallet/hooks' + +export function useAdaptiveFooter(contentContainerStyle?: StyleProp): { + onContentSizeChange?: (w: number, h: number) => void + footerHeight: SharedValue + adaptiveFooter: JSX.Element +} { + const { fullHeight } = useDeviceDimensions() + const insets = useAppInsets() + const isBottomTabsEnabled = useFeatureFlag(FeatureFlags.BottomTabs) + + const HEIGHT_TO_SUBTRACT = isBottomTabsEnabled ? ESTIMATED_BOTTOM_TABS_HEIGHT : TAB_BAR_HEIGHT + // Content is rendered under the navigation bar but not under the status bar + const maxContentHeight = fullHeight - insets.top + // Use maxContentHeight as the initial value to properly position the TabBar + // while changing tabs when data is loading (before the onContentSizeChange + // was called and appropriate footer height was calculated) + const footerHeight = useSharedValue(maxContentHeight) + const activeAccount = useActiveAccount() + + const onContentSizeChange = useCallback( + (_: number, contentHeight: number) => { + if (!contentContainerStyle) { + return + } + // The height of the footer added to the list can be calculated from + // the following equation (for collapsed tab bar): + // maxContentHeight = HEIGHT_TO_SUBTRACT + + footerHeight + paddingBottom + // + // To get the we need to subtract padding already + // added to the content container style and the footer if it's already + // been rendered: + // = contentHeight - paddingTop - paddingBottom - footerHeight + // + // The resulting equation is: + // footerHeight = maxContentHeight - - HEIGHT_TO_SUBTRACT - paddingBottom + // = maxContentHeight - (contentHeight - paddingTop - paddingBottom - footerHeight) - HEIGHT_TO_SUBTRACT - paddingBottom + // = maxContentHeight + paddingTop + footerHeight - (contentHeight + HEIGHT_TO_SUBTRACT) + const paddingTopProp = (contentContainerStyle as ViewStyle).paddingTop + const paddingTop = typeof paddingTopProp === 'number' ? paddingTopProp : 0 + const calculatedFooterHeight = + maxContentHeight + paddingTop + footerHeight.value - (contentHeight + HEIGHT_TO_SUBTRACT) + + footerHeight.value = Math.max(0, calculatedFooterHeight) + }, + // oxlint-disable-next-line react/exhaustive-deps -- biome-parity: oxlint is stricter here + [contentContainerStyle, maxContentHeight, HEIGHT_TO_SUBTRACT], + ) + + // oxlint-disable-next-line react/exhaustive-deps -- we want to recalculate this when activeAccount changes + useEffect(() => { + // Reset footer height to the initial value when the active account changes + // (the fullHeight value is used for the same reason as the initial value) + footerHeight.value = fullHeight + }, [activeAccount, footerHeight, fullHeight]) + + const footerStyle = useAnimatedStyle(() => ({ + height: footerHeight.value, + })) + + const adaptiveFooter = useMemo(() => , [footerStyle]) + + return { + onContentSizeChange: contentContainerStyle ? onContentSizeChange : undefined, + footerHeight, + adaptiveFooter, + } +} diff --git a/apps/mobile/src/components/home/introCards/FundWalletModal.tsx b/apps/mobile/src/components/home/introCards/FundWalletModal.tsx new file mode 100644 index 00000000..0f0451b2 --- /dev/null +++ b/apps/mobile/src/components/home/introCards/FundWalletModal.tsx @@ -0,0 +1,180 @@ +import { FeatureFlags, useFeatureFlag } from '@universe/gating' +import React, { type PropsWithChildren, useCallback, useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { FlatList } from 'react-native' +import { useDispatch } from 'react-redux' +import { navigate } from 'src/app/navigation/rootNavigation' +import { useReactNavigationModal } from 'src/components/modals/useReactNavigationModal' +import { useOpenReceiveModal } from 'src/features/modals/hooks/useOpenReceiveModal' +import { openModal } from 'src/features/modals/modalSlice' +import { Flex, UniversalImage, useShadowPropsShort } from 'ui/src' +import { ArrowDownCircle, Buy } from 'ui/src/components/icons' +import { UniversalImageResizeMode } from 'ui/src/components/UniversalImage/types' +import { borderRadii, iconSizes, spacing } from 'ui/src/theme' +import { ActionCard, type ActionCardItem } from 'uniswap/src/components/misc/ActionCard' +import { Modal } from 'uniswap/src/components/modals/Modal' +import { useCexTransferProviders } from 'uniswap/src/features/fiatOnRamp/useCexTransferProviders' +import { ElementName, ModalName } from 'uniswap/src/features/telemetry/constants' +import { usePortfolioEmptyStateBackground } from 'wallet/src/components/portfolio/empty' + +export function FundWalletModal(): JSX.Element { + const shadowProps = useShadowPropsShort() + const { t } = useTranslation() + const dispatch = useDispatch() + const cexTransferProviders = useCexTransferProviders() + const openReceiveModal = useOpenReceiveModal() + + const { onClose } = useReactNavigationModal() + + const disableForKorea = useFeatureFlag(FeatureFlags.DisableFiatOnRampKorea) + + const backgroundImageWrapperCallback = usePortfolioEmptyStateBackground() + + const onPressBuy = useCallback(() => { + onClose() + disableForKorea + ? navigate(ModalName.KoreaCexTransferInfoModal) + : dispatch( + openModal({ + name: ModalName.FiatOnRampAggregator, + }), + ) + }, [disableForKorea, onClose, dispatch]) + + const onPressReceive = useCallback(() => { + onClose() + openReceiveModal() + }, [onClose, openReceiveModal]) + + const cards = useMemo( + () => + [ + { + title: t('home.tokens.empty.action.buy.title'), + blurb: t('home.tokens.empty.action.buy.description'), + elementName: ElementName.EmptyStateBuy, + // Intentionally sized differently per designs because this icon has more vertical padding than others + icon: ( + + + + ), + onPress: onPressBuy, + backgroundImageWrapperCallback, + }, + { + title: t('home.tokens.empty.action.receive.title'), + blurb: t('home.tokens.empty.action.receive.description'), + elementName: ElementName.EmptyStateReceive, + icon: + cexTransferProviders.length > 0 ? ( + , + ...cexTransferProviders.map((provider) => provider.logos?.lightLogo ?? ''), + ]} + /> + ) : ( + + ), + onPress: onPressReceive, + }, + ] satisfies ActionCardItem[], + [backgroundImageWrapperCallback, cexTransferProviders, onPressBuy, onPressReceive, t], + ) + return ( + + + {cards.map((card) => ( + + ))} + + + ) +} + +const ICON_SHIFT = 10 + +function OverlappingLogos({ logos }: { logos: (string | JSX.Element)[] }): JSX.Element { + return ( + + (typeof item === 'string' ? : item)} + /> + + ) +} + +/* + * Set the zIndex to -index to reverse the order of the elements. + */ +const LogoRendererComponent = ({ + children, + index, +}: PropsWithChildren<{ + index: number +}>): JSX.Element => { + return ( + + {children} + + ) +} + +function ServiceProviderLogo({ uri }: { uri: string }): JSX.Element { + return ( + + + + ) +} + +function ReceiveCryptoIcon(): JSX.Element { + return ( + + + + ) +} diff --git a/apps/mobile/src/components/home/introCards/OnboardingIntroCardStack.tsx b/apps/mobile/src/components/home/introCards/OnboardingIntroCardStack.tsx new file mode 100644 index 00000000..fadf8f7b --- /dev/null +++ b/apps/mobile/src/components/home/introCards/OnboardingIntroCardStack.tsx @@ -0,0 +1,169 @@ +import { SharedEventName } from '@uniswap/analytics-events' +import { FeatureFlags, useFeatureFlag } from '@universe/gating' +import React, { useCallback, useEffect, useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { useDispatch, useSelector } from 'react-redux' +import { navigate } from 'src/app/navigation/rootNavigation' +import { + NotificationPermission, + useNotificationOSPermissionsEnabled, +} from 'src/features/notifications/hooks/useNotificationOSPermissionsEnabled' +import { Flex } from 'ui/src' +import { PUSH_NOTIFICATIONS_CARD_BANNER } from 'ui/src/assets' +import { Buy } from 'ui/src/components/icons' +import { AccountType } from 'uniswap/src/features/accounts/types' +import { ElementName, ModalName, WalletEventName } from 'uniswap/src/features/telemetry/constants' +import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { OnboardingCardLoggingName } from 'uniswap/src/features/telemetry/types' +import { ImportType, OnboardingEntryPoint } from 'uniswap/src/types/onboarding' +import { MobileScreens, OnboardingScreens, UnitagScreens } from 'uniswap/src/types/screens/mobile' +import { + CardType, + IntroCardGraphicType, + IntroCardProps, + isOnboardingCardLoggingName, +} from 'wallet/src/components/introCards/IntroCard' +import { INTRO_CARD_MIN_HEIGHT, IntroCardStack } from 'wallet/src/components/introCards/IntroCardStack' +import { useSharedIntroCards } from 'wallet/src/components/introCards/useSharedIntroCards' +import { selectHasViewedNotificationsCard } from 'wallet/src/features/behaviorHistory/selectors' +import { setHasViewedNotificationsCard } from 'wallet/src/features/behaviorHistory/slice' +import { useActiveAccountWithThrow } from 'wallet/src/features/wallet/hooks' + +type OnboardingIntroCardStackProps = { + isLoading?: boolean + showEmptyWalletState: boolean + onCardsChange?: (hasCards: boolean) => void +} +export function OnboardingIntroCardStack({ + showEmptyWalletState, + isLoading = false, + onCardsChange, +}: OnboardingIntroCardStackProps): JSX.Element | null { + const { t } = useTranslation() + const dispatch = useDispatch() + const activeAccount = useActiveAccountWithThrow() + const address = activeAccount.address + const isSignerAccount = activeAccount.type === AccountType.SignerMnemonic + + const { notificationPermissionsEnabled } = useNotificationOSPermissionsEnabled() + const notificationOnboardingCardEnabled = useFeatureFlag(FeatureFlags.NotificationOnboardingCard) + const hasViewedNotificationsCard = useSelector(selectHasViewedNotificationsCard) + const showEnableNotificationsCard = + notificationOnboardingCardEnabled && + notificationPermissionsEnabled === NotificationPermission.Disabled && + !hasViewedNotificationsCard + + const navigateToUnitagClaim = useCallback(() => { + navigate(MobileScreens.UnitagStack, { + screen: UnitagScreens.ClaimUnitag, + params: { + entryPoint: MobileScreens.Home, + address, + }, + }) + }, [address]) + + const navigateToUnitagIntro = useCallback(() => { + navigate(ModalName.UnitagsIntro, { + address, + entryPoint: MobileScreens.Home, + }) + }, [address]) + + const navigateToBackupFlow = useCallback((): void => { + navigate(MobileScreens.OnboardingStack, { + screen: OnboardingScreens.Backup, + params: { + importType: ImportType.BackupOnly, + entryPoint: OnboardingEntryPoint.BackupCard, + }, + }) + }, []) + + const { cards: sharedCards } = useSharedIntroCards({ + navigateToUnitagClaim, + navigateToUnitagIntro, + navigateToBackupFlow, + }) + + const cards = useMemo((): IntroCardProps[] => { + const output: IntroCardProps[] = [] + + // Don't show cards for view only wallets + if (!isSignerAccount) { + return output + } + + if (showEmptyWalletState) { + output.push({ + loggingName: OnboardingCardLoggingName.FundWallet, + graphic: { + type: IntroCardGraphicType.Icon, + Icon: Buy, + }, + title: t('onboarding.home.intro.fund.title'), + description: t('onboarding.home.intro.fund.description'), + cardType: CardType.Required, + onPress: (): void => { + navigate(ModalName.FundWallet) + sendAnalyticsEvent(SharedEventName.ELEMENT_CLICKED, { + element: ElementName.OnboardingIntroCardFundWallet, + }) + }, + }) + } + + output.push(...sharedCards) + + if (showEnableNotificationsCard) { + output.push({ + loggingName: OnboardingCardLoggingName.EnablePushNotifications, + graphic: { + type: IntroCardGraphicType.Image, + image: PUSH_NOTIFICATIONS_CARD_BANNER, + }, + title: t('onboarding.home.intro.pushNotifications.title'), + description: t('onboarding.home.intro.pushNotifications.description'), + cardType: CardType.Dismissible, + onPress: (): void => { + navigate(ModalName.NotificationsOSSettings) + dispatch(setHasViewedNotificationsCard(true)) + sendAnalyticsEvent(SharedEventName.ELEMENT_CLICKED, { + element: ElementName.OnboardingIntroCardEnablePushNotifications, + }) + }, + onClose: (): void => { + dispatch(setHasViewedNotificationsCard(true)) + }, + }) + } + + return output + }, [showEmptyWalletState, isSignerAccount, sharedCards, t, dispatch, showEnableNotificationsCard]) + + useEffect(() => { + onCardsChange?.(cards.length > 0) + }, [cards.length, onCardsChange]) + + const handleSwiped = useCallback( + (_card: IntroCardProps, index: number) => { + const loggingName = cards[index]?.loggingName + if (loggingName && isOnboardingCardLoggingName(loggingName)) { + sendAnalyticsEvent(WalletEventName.OnboardingIntroCardSwiped, { + card_name: loggingName, + }) + } + }, + [cards], + ) + + if (!cards.length) { + return null + } + + return ( + + {isLoading ? : } + + ) +} diff --git a/apps/mobile/src/components/icons/Favorite.tsx b/apps/mobile/src/components/icons/Favorite.tsx new file mode 100644 index 00000000..1a08e625 --- /dev/null +++ b/apps/mobile/src/components/icons/Favorite.tsx @@ -0,0 +1,46 @@ +import React, { useCallback, useEffect, useState } from 'react' +import { useAnimatedStyle, useDerivedValue, withSequence, withTiming } from 'react-native-reanimated' +import { Flex, useSporeColors } from 'ui/src' +import { HeartWithFill } from 'ui/src/components/icons' + +interface FavoriteButtonProps { + isFavorited: boolean + size: number +} + +const DELAY = 100 +const ANIMATION_CONFIG = { duration: DELAY } + +export const Favorite = ({ isFavorited, size }: FavoriteButtonProps): JSX.Element => { + const colors = useSporeColors() + const unfilledColor = colors.neutral2.val + + const getColor = useCallback( + () => (isFavorited ? colors.accent1.val : unfilledColor), + [isFavorited, colors.accent1, unfilledColor], + ) + + const [color, setColor] = useState(getColor()) + + // oxlint-disable-next-line react/exhaustive-deps -- we want to recalculate this when isFavorited changes + useEffect(() => { + const timer = setTimeout(() => { + setColor(getColor()) + }, DELAY) + return () => clearTimeout(timer) + }, [getColor, isFavorited]) + + /* oxlint-disable react/exhaustive-deps -- isFavorited triggers animation re-derivation even though it's not read in the worklet */ + const scale = useDerivedValue(() => { + return withSequence(withTiming(0, ANIMATION_CONFIG), withTiming(1, ANIMATION_CONFIG)) + }, [isFavorited]) + /* oxlint-enable react/exhaustive-deps */ + + const animatedStyle = useAnimatedStyle(() => ({ transform: [{ scale: scale.value }] }), [scale]) + + return ( + + + + ) +} diff --git a/apps/mobile/src/components/icons/useBiometricsIcon.tsx b/apps/mobile/src/components/icons/useBiometricsIcon.tsx new file mode 100644 index 00000000..b5d06a32 --- /dev/null +++ b/apps/mobile/src/components/icons/useBiometricsIcon.tsx @@ -0,0 +1,24 @@ +import { useDeviceSupportsBiometricAuth } from 'src/features/biometrics/useDeviceSupportsBiometricAuth' +import { Faceid, Fingerprint } from 'ui/src/components/icons' + +export type BiometricsIconProps = { + color?: string +} + +export function useBiometricsIcon(): (({ color }: BiometricsIconProps) => JSX.Element) | null { + const { touchId: isTouchIdSupported, faceId: isFaceIdSupported } = useDeviceSupportsBiometricAuth() + + if (isTouchIdSupported) { + return function renderFingerprint({ color }: BiometricsIconProps): JSX.Element { + return + } + } + + if (isFaceIdSupported) { + return function renderFaceId({ color }: BiometricsIconProps): JSX.Element { + return + } + } + + return null +} diff --git a/apps/mobile/src/components/input/PasswordInput.tsx b/apps/mobile/src/components/input/PasswordInput.tsx new file mode 100644 index 00000000..deddf721 --- /dev/null +++ b/apps/mobile/src/components/input/PasswordInput.tsx @@ -0,0 +1,60 @@ +import React, { forwardRef, useState } from 'react' +import { TextInput as NativeTextInput } from 'react-native' +import { Flex, TouchableArea } from 'ui/src' +import { Eye, EyeOff } from 'ui/src/components/icons' +import { AnimatedFlex } from 'ui/src/components/layout/AnimatedFlex' +import { TextInput, TextInputProps } from 'uniswap/src/components/input/TextInput' + +export const PasswordInput = forwardRef(function PasswordInputInner(props, ref) { + const [showPassword, setShowPassword] = useState(false) + + const { value, placeholder, onChangeText, returnKeyType, onSubmitEditing, ...rest } = props + + const onPressEyeIcon = (): void => { + setShowPassword(!showPassword) + } + + return ( + + + + + + {showPassword ? : } + + + + + ) +}) diff --git a/apps/mobile/src/components/input/SelectionCircle.test.tsx b/apps/mobile/src/components/input/SelectionCircle.test.tsx new file mode 100644 index 00000000..f84b3800 --- /dev/null +++ b/apps/mobile/src/components/input/SelectionCircle.test.tsx @@ -0,0 +1,8 @@ +import React from 'react' +import { SelectionCircle } from 'src/components/input/SelectionCircle' +import { render } from 'src/test/test-utils' + +it('renders selection circle', () => { + const tree = render() + expect(tree).toMatchSnapshot() +}) diff --git a/apps/mobile/src/components/input/SelectionCircle.tsx b/apps/mobile/src/components/input/SelectionCircle.tsx new file mode 100644 index 00000000..853b7a75 --- /dev/null +++ b/apps/mobile/src/components/input/SelectionCircle.tsx @@ -0,0 +1,36 @@ +import React from 'react' +import { ColorTokens, Flex } from 'ui/src' +import { iconSizes } from 'ui/src/theme' + +interface SelectionCircleProps { + selected: boolean + size: keyof typeof iconSizes + unselectedColor?: ColorTokens + selectedColor?: ColorTokens +} + +export function SelectionCircle({ + selected, + size, + unselectedColor = '$neutral2', + selectedColor = '$accent1', +}: SelectionCircleProps): JSX.Element { + return ( + + + + ) +} diff --git a/apps/mobile/src/components/input/__snapshots__/SelectionCircle.test.tsx.snap b/apps/mobile/src/components/input/__snapshots__/SelectionCircle.test.tsx.snap new file mode 100644 index 00000000..b29ce168 --- /dev/null +++ b/apps/mobile/src/components/input/__snapshots__/SelectionCircle.test.tsx.snap @@ -0,0 +1,44 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders selection circle 1`] = ` + + + +`; diff --git a/apps/mobile/src/components/layout/AnimatedFlatList.tsx b/apps/mobile/src/components/layout/AnimatedFlatList.tsx new file mode 100644 index 00000000..87b16f39 --- /dev/null +++ b/apps/mobile/src/components/layout/AnimatedFlatList.tsx @@ -0,0 +1,69 @@ +/* oxlint-disable typescript/no-explicit-any -- Generic FlatList types are complex and varied */ +// Adds ForwardRef to Animated.FlaList +// https://github.com/software-mansion/react-native-reanimated/issues/2976 + +import { BottomSheetFlatList } from '@gorhom/bottom-sheet' +import React, { forwardRef, PropsWithChildren } from 'react' +import { FlatList, FlatListProps, LayoutChangeEvent, View } from 'react-native' +import Animated, { ILayoutAnimationBuilder } from 'react-native-reanimated' + +// difficult to properly type +// oxlint-disable-next-line typescript/no-explicit-any -- biome-parity: oxlint is stricter here +const ReanimatedFlatList = Animated.createAnimatedComponent(FlatList as any) as any +// oxlint-disable-next-line typescript/no-explicit-any -- biome-parity: oxlint is stricter here +const ReanimatedBottomSheetFlatList = Animated.createAnimatedComponent(BottomSheetFlatList as any) as any +const AnimatedView = Animated.createAnimatedComponent(View) + +const createCellRenderer = ( + itemLayoutAnimation?: ILayoutAnimationBuilder, +): React.FC< + PropsWithChildren<{ + onLayout: (event: LayoutChangeEvent) => void + }> +> => { + const cellRenderer: React.FC< + PropsWithChildren<{ + onLayout: (event: LayoutChangeEvent) => void + }> + > = (props) => { + return ( + + {props.children} + + ) + } + + return cellRenderer +} + +interface ReanimatedFlatlistProps extends FlatListProps { + itemLayoutAnimation?: ILayoutAnimationBuilder + FlatListComponent?: FlatList +} + +/** + * re-create Reanimated FlatList but correctly pass on forwardRef in order to use scrollTo to scroll to the next page in our horizontal FlatList + * + * Source: https://github.com/software-mansion/react-native-reanimated/blob/main/src/reanimated2/component/FlatList.tsx + * + * TODO: [MOB-207] remove this and use Animated.FlatList directly when can use refs with it. Also type the generic T properly for FlatList and dont use `any` + */ +// oxlint-disable-next-line typescript/no-explicit-any -- biome-parity: oxlint is stricter here +export const AnimatedFlatList = forwardRef, ReanimatedFlatlistProps>( + function AnimatedFlatListInner({ itemLayoutAnimation, FlatListComponent = ReanimatedFlatList, ...restProps }, ref) { + // oxlint-disable-next-line react/exhaustive-deps -- itemLayoutAnimation intentionally excluded to avoid recreation + const cellRenderer = React.useMemo(() => createCellRenderer(itemLayoutAnimation), []) + return + }, +) + +/** + * In bottom sheet contexts, this will support pull to dismiss. + * See AnimatedFlatList for other props. + */ +// oxlint-disable-next-line typescript/no-explicit-any -- biome-parity: oxlint is stricter here +export const AnimatedBottomSheetFlatList = forwardRef, ReanimatedFlatlistProps>( + function AnimatedBottomSheetFlatListInner(props, ref) { + return + }, +) diff --git a/apps/mobile/src/components/layout/BackButtonView.tsx b/apps/mobile/src/components/layout/BackButtonView.tsx new file mode 100644 index 00000000..0f859d21 --- /dev/null +++ b/apps/mobile/src/components/layout/BackButtonView.tsx @@ -0,0 +1,26 @@ +import React from 'react' +import { useTranslation } from 'react-i18next' +import { ColorTokens, Flex, Text } from 'ui/src' +import { RotatableChevron } from 'ui/src/components/icons' +import { IconSizeTokens } from 'ui/src/theme/tokens' + +type Props = { + size?: IconSizeTokens + color?: ColorTokens + showButtonLabel?: boolean +} + +export function BackButtonView({ size, color, showButtonLabel }: Props): JSX.Element { + const { t } = useTranslation() + + return ( + + + {showButtonLabel && ( + + {t('common.button.back')} + + )} + + ) +} diff --git a/apps/mobile/src/components/layout/BackHeader.tsx b/apps/mobile/src/components/layout/BackHeader.tsx new file mode 100644 index 00000000..69bcde6f --- /dev/null +++ b/apps/mobile/src/components/layout/BackHeader.tsx @@ -0,0 +1,33 @@ +import React, { PropsWithChildren } from 'react' +import { BackButton } from 'src/components/buttons/BackButton' +import { Flex, FlexProps } from 'ui/src' + +const BACK_BUTTON_SIZE = 24 +const BACK_BUTTON_SIZE_TOKEN = '$icon.24' + +type BackButtonRowProps = { + alignment?: 'left' | 'center' + endAdornment?: JSX.Element + onPressBack?: () => void +} & FlexProps + +export function BackHeader({ + alignment = 'center', + children, + endAdornment = , + onPressBack, + ...spacingProps +}: PropsWithChildren): JSX.Element { + return ( + + + {children} + {endAdornment} + + ) +} diff --git a/apps/mobile/src/components/layout/SafeKeyboardScreen.tsx b/apps/mobile/src/components/layout/SafeKeyboardScreen.tsx new file mode 100644 index 00000000..a8550d0a --- /dev/null +++ b/apps/mobile/src/components/layout/SafeKeyboardScreen.tsx @@ -0,0 +1,75 @@ +import React, { PropsWithChildren, useState } from 'react' +import { ScrollView, ScrollViewProps, StyleSheet } from 'react-native' +import { KeyboardAvoidingView } from 'react-native-keyboard-controller' +import { Screen, ScreenProps } from 'src/components/layout/Screen' +import { Flex, flexStyles } from 'ui/src' +import { spacing } from 'ui/src/theme' +import { useKeyboardLayout } from 'uniswap/src/utils/useKeyboardLayout' +import { isIOS } from 'utilities/src/platform' + +type OnboardingScreenProps = ScreenProps & { + header?: JSX.Element + footer?: JSX.Element + minHeightWhenKeyboardExpanded?: boolean + keyboardDismissMode?: ScrollViewProps['keyboardDismissMode'] +} + +export function SafeKeyboardScreen({ + children, + header, + footer, + minHeightWhenKeyboardExpanded = false, + keyboardDismissMode, + ...screenProps +}: PropsWithChildren): JSX.Element { + const [footerHeight, setFooterHeight] = useState(0) + const keyboard = useKeyboardLayout() + + const compact = keyboard.isVisible && keyboard.containerHeight !== 0 + + // This makes sure this component behaves just like `behavior="padding"` when + // there's enough space on the screen to show all components. + const minHeight = minHeightWhenKeyboardExpanded && compact ? keyboard.containerHeight - footerHeight : 0 + + return ( + + + {header} + + + {children} + + + { + setFooterHeight(height) + }} + > + {footer} + + + + ) +} + +const styles = StyleSheet.create({ + base: { + flex: 1, + justifyContent: 'flex-end', + }, + container: { + paddingBottom: spacing.spacing12, + }, + expand: { + flexGrow: 1, + }, +}) diff --git a/apps/mobile/src/components/layout/Screen.tsx b/apps/mobile/src/components/layout/Screen.tsx new file mode 100644 index 00000000..650c5c86 --- /dev/null +++ b/apps/mobile/src/components/layout/Screen.tsx @@ -0,0 +1,75 @@ +import React, { useMemo } from 'react' +import { Edge, NativeSafeAreaViewProps } from 'react-native-safe-area-context' +import { Flex, FlexProps } from 'ui/src' +import { useAppInsets } from 'uniswap/src/hooks/useAppInsets' + +// Used to determine amount of top padding for short screens +export const SHORT_SCREEN_HEADER_HEIGHT_RATIO = 0.88 + +export type ScreenProps = FlexProps & + // The SafeAreaView from react-native-safe-area-context also supports a `mode` prop which + // lets you choose if `edges` are added as margin or padding, but we don’t use that so + // our Screen component doesn't need to support it + Omit & { edges?: readonly Edge[] } & { + noInsets?: boolean + } + +function SafeAreaWithInsets({ children, edges, noInsets, ...rest }: ScreenProps): JSX.Element { + // Safe area insets are wrong (0 when they shouldn't be) when using the + // component from react-native-safe-area-context, because when the initial screen is + // outside the viewport (as is the case with a screen slide-in animation on navigation) + // the safe area insets are calculated based on the initial screen, not the final screen. + // This is a known issue with react-native-safe-area-context, and the solution is to use + // the useSafeAreaInsets hook instead. See: + // https://github.com/th3rdwave/react-native-safe-area-context/issues/114 + const insets = useAppInsets() // useAppInsets uses useSafeAreaInsets internally + + const safeAreaStyles = useMemo(() => { + const style: { + paddingTop?: number + paddingBottom?: number + paddingLeft?: number + paddingRight?: number + } = {} + + if (noInsets) { + return style + } + + if (!edges) { + return { + paddingTop: insets.top, + paddingBottom: insets.bottom, + paddingLeft: insets.left, + paddingRight: insets.right, + } + } + if (edges.includes('top')) { + style.paddingTop = insets.top + } + if (edges.includes('bottom')) { + style.paddingBottom = insets.bottom + } + if (edges.includes('left')) { + style.paddingLeft = insets.left + } + if (edges.includes('right')) { + style.paddingRight = insets.right + } + return style + }, [edges, insets, noInsets]) + + return ( + + {children} + + ) +} + +export function Screen({ backgroundColor = '$surface1', children, ...rest }: ScreenProps): JSX.Element { + return ( + + {children} + + ) +} diff --git a/apps/mobile/src/components/layout/TabHelpers.tsx b/apps/mobile/src/components/layout/TabHelpers.tsx new file mode 100644 index 00000000..29e9be4a --- /dev/null +++ b/apps/mobile/src/components/layout/TabHelpers.tsx @@ -0,0 +1,182 @@ +/* oxlint-disable react-native/no-unused-styles */ +import { FlashList, FlashListProps } from '@shopify/flash-list' +import { FeatureFlags, useFeatureFlag } from '@universe/gating' +import React, { RefObject, useCallback, useMemo } from 'react' +import { + FlatList, + FlatListProps, + NativeScrollEvent, + NativeSyntheticEvent, + StyleProp, + StyleSheet, + ViewStyle, +} from 'react-native' +import Animated, { SharedValue } from 'react-native-reanimated' +import { Route } from 'react-native-tab-view' +import { Flex, Text } from 'ui/src' +import { colorsLight, spacing } from 'ui/src/theme' +import { TestIDType } from 'uniswap/src/test/fixtures/testIDs' +import { PendingNotificationBadge } from 'wallet/src/features/notifications/components/PendingNotificationBadge' + +export const TAB_VIEW_SCROLL_THROTTLE = 16 +export const TAB_BAR_HEIGHT = 48 + +export const TAB_STYLES = StyleSheet.create({ + activeTabIndicator: { + backgroundColor: colorsLight.accent1, + bottom: 0, + height: 0, + position: 'absolute', + }, + container: { + flex: 1, + overflow: 'hidden', + }, + header: { + marginBottom: 0, + paddingBottom: 0, + position: 'absolute', + width: '100%', + zIndex: 1, + }, + headerContainer: { + left: 0, + position: 'absolute', + right: 0, + top: 0, + width: '100%', + zIndex: 1, + }, + tabBar: { + // add inactive border to bottom of tab bar + borderBottomWidth: 0, + margin: 0, + marginHorizontal: 0, + padding: 0, + // remove default shadow border under tab bar + shadowColor: colorsLight.none, + shadowOpacity: 0, + shadowRadius: 0, + top: 0, + }, + // For padding on the list components themselves within tabs. + tabListInner: { + paddingBottom: spacing.spacing12, + paddingTop: spacing.spacing4, + }, +}) + +export type HeaderConfig = { + heightExpanded: number + heightCollapsed: number +} + +export type ScrollPair = { + list: RefObject | RefObject | null> + position: Animated.SharedValue + index: number +} + +export type TabProps = { + owner: string + containerProps?: TabContentProps + scrollHandler?: (event: NativeSyntheticEvent) => void + isExternalProfile?: boolean + renderedInModal?: boolean + refreshing?: boolean + onRefresh?: () => void + isActiveTab?: boolean + headerHeight?: number + testID?: TestIDType +} + +export type TabContentProps = Partial> & { + contentContainerStyle: StyleProp + emptyComponentStyle?: StyleProp + estimatedItemSize?: number + onMomentumScrollEnd?: (event: NativeSyntheticEvent) => void + onScrollEndDrag?: (event: NativeSyntheticEvent) => void + scrollEventThrottle?: number +} + +export type TabLabelProps = { + route: Route + focused: boolean + isExternalProfile?: boolean + textStyleType?: 'primary' | 'secondary' + enableNotificationBadge?: boolean +} +export const TabLabel = ({ + route, + focused, + isExternalProfile, + textStyleType = 'primary', + enableNotificationBadge, +}: TabLabelProps): JSX.Element => { + const isBottomTabsEnabled = useFeatureFlag(FeatureFlags.BottomTabs) + const showNotificationBadge = !isBottomTabsEnabled && enableNotificationBadge && !isExternalProfile && !focused + + return ( + + + {route.title} + + {/* Streamline UI by hiding the Activity tab spinner when focused + and showing it only on the specific pending transactions. */} + {showNotificationBadge ? : null} + + ) +} + +/** + * Keeps tab content in sync, by scrolling content in case collapsing header height has changed between tabs + */ +export const useScrollSync = ({ + currentTabIndex, + scrollPairs, + headerConfig, +}: { + currentTabIndex: SharedValue + scrollPairs: ScrollPair[] + headerConfig: HeaderConfig +}): { sync: (event: NativeSyntheticEvent) => void } => { + const sync: FlatListProps['onMomentumScrollEnd'] | FlashListProps['onMomentumScrollEnd'] = + useCallback( + (event: { nativeEvent: NativeScrollEvent }) => { + const { y } = event.nativeEvent.contentOffset + + const { heightCollapsed, heightExpanded } = headerConfig + + const headerDiff = heightExpanded - heightCollapsed + + for (const { list, position, index } of scrollPairs) { + const scrollPosition = position.value + + if (scrollPosition > headerDiff && y > headerDiff) { + continue + } + + if (index !== currentTabIndex.value) { + list.current?.scrollToOffset({ + offset: Math.min(y, headerDiff), + animated: false, + }) + } + } + }, + [currentTabIndex, scrollPairs, headerConfig], + ) + + return useMemo(() => ({ sync }), [sync]) +} diff --git a/apps/mobile/src/components/layout/VirtualizedList.tsx b/apps/mobile/src/components/layout/VirtualizedList.tsx new file mode 100644 index 00000000..3a9b598c --- /dev/null +++ b/apps/mobile/src/components/layout/VirtualizedList.tsx @@ -0,0 +1,34 @@ +import React, { ComponentProps, PropsWithChildren } from 'react' +import type { FlatListProps } from 'react-native' +import { AnimatedBottomSheetFlatList, AnimatedFlatList } from 'src/components/layout/AnimatedFlatList' + +type VirtualizedListProps = PropsWithChildren>> & { + renderedInModal?: boolean +} + +const DATA: FlatListProps['data'] = [] +const keyExtractor = (): string => 'key' + +/** Dummy component wrapping `FlatList` to behave like a ScrollView */ +// oxlint-disable-next-line typescript/no-explicit-any -- Generic list ref type varies between FlatList and BottomSheetFlatList +export const VirtualizedList = React.forwardRef(function VirtualizedListInner( + { children, renderedInModal, ...props }: VirtualizedListProps, + ref, +) { + const List = renderedInModal ? AnimatedBottomSheetFlatList : AnimatedFlatList + + return ( + {children}} + data={DATA} + keyExtractor={keyExtractor} + keyboardShouldPersistTaps="always" + renderItem={null} + scrollEventThrottle={16} + showsHorizontalScrollIndicator={false} + showsVerticalScrollIndicator={false} + /> + ) +}) diff --git a/apps/mobile/src/components/layout/screens/EdgeGestureTarget.tsx b/apps/mobile/src/components/layout/screens/EdgeGestureTarget.tsx new file mode 100644 index 00000000..ec888b75 --- /dev/null +++ b/apps/mobile/src/components/layout/screens/EdgeGestureTarget.tsx @@ -0,0 +1,34 @@ +import React from 'react' +import { Flex } from 'ui/src' +import { useDeviceDimensions } from 'ui/src/hooks/useDeviceDimensions' + +/** + * Adds a transparent box to the specific edge as a gesture target. + * Useful when rendering `BottomSheetFlatList`s inside a navigator. + */ +export function HorizontalEdgeGestureTarget({ + edge = 'left', + height, + top = 0, + width = 20, +}: { + edge?: 'left' | 'right' + height?: number + top?: number + width?: number +}): JSX.Element { + const dimensions = useDeviceDimensions() + + return ( + + ) +} diff --git a/apps/mobile/src/components/layout/screens/HeaderScrollScreen.tsx b/apps/mobile/src/components/layout/screens/HeaderScrollScreen.tsx new file mode 100644 index 00000000..590bcbdd --- /dev/null +++ b/apps/mobile/src/components/layout/screens/HeaderScrollScreen.tsx @@ -0,0 +1,89 @@ +import { useScrollToTop } from '@react-navigation/native' +import React, { PropsWithChildren, useRef } from 'react' +import { FlatList } from 'react-native-gesture-handler' +import { useAnimatedScrollHandler, useSharedValue, withTiming } from 'react-native-reanimated' +import type { Edge } from 'react-native-safe-area-context' +import { Screen } from 'src/components/layout/Screen' +import { HorizontalEdgeGestureTarget } from 'src/components/layout/screens/EdgeGestureTarget' +import { ScrollHeader } from 'src/components/layout/screens/ScrollHeader' +import { VirtualizedList } from 'src/components/layout/VirtualizedList' +import { ColorTokens, Flex, flexStyles, useSporeColors } from 'ui/src' +import { iconSizes } from 'ui/src/theme' +import { HandleBar } from 'uniswap/src/components/modals/HandleBar' + +// Distance to scroll to show scrolled state header elements +const SHOW_HEADER_SCROLL_Y_DISTANCE = 50 + +const EDGES: Edge[] = ['top', 'left', 'right'] + +type HeaderScrollScreenProps = { + centerElement?: JSX.Element + rightElement?: JSX.Element + renderedInModal?: boolean // Apply styling to display within bottom sheet modal + showHandleBar?: boolean // add handlebar element to top of view + backgroundColor?: ColorTokens + backButtonColor?: ColorTokens +} + +export function HeaderScrollScreen({ + centerElement, + rightElement = , + renderedInModal = false, + showHandleBar = false, + backgroundColor = '$surface1', + backButtonColor, + children, +}: PropsWithChildren): JSX.Element { + const colors = useSporeColors() + + // difficult to properly type + // oxlint-disable-next-line typescript/no-explicit-any -- FlatList generic type is complex and varies by data + const listRef = useRef>(null) + + // scrolls to top when tapping on the active tab + useScrollToTop(listRef) + + const scrollY = useSharedValue(0) + + // On scroll, centerElement and the bottom border fade in + const scrollHandler = useAnimatedScrollHandler( + { + onScroll: (event) => { + scrollY.value = event.contentOffset.y + }, + onEndDrag: (event) => { + scrollY.value = withTiming(event.contentOffset.y > 0 ? SHOW_HEADER_SCROLL_Y_DISTANCE : 0) + }, + }, + [scrollY], + ) + + return ( + + {showHandleBar ? : null} + + + {children} + + + + + ) +} diff --git a/apps/mobile/src/components/layout/screens/ScreenWithHeader.tsx b/apps/mobile/src/components/layout/screens/ScreenWithHeader.tsx new file mode 100644 index 00000000..7cf64111 --- /dev/null +++ b/apps/mobile/src/components/layout/screens/ScreenWithHeader.tsx @@ -0,0 +1,109 @@ +import React, { PropsWithChildren, useMemo } from 'react' +import { Edge } from 'react-native-safe-area-context' +import { BackButton } from 'src/components/buttons/BackButton' +import { Screen } from 'src/components/layout/Screen' +import { HorizontalEdgeGestureTarget } from 'src/components/layout/screens/EdgeGestureTarget' +import { ColorTokens, Flex, flexStyles } from 'ui/src' +import { iconSizes } from 'ui/src/theme' +import { useAppInsets } from 'uniswap/src/hooks/useAppInsets' + +type ScreenWithHeaderProps = { + centerElement?: JSX.Element + rightElement?: JSX.Element + fullScreen?: boolean // Expand to device edges + backgroundColor?: ColorTokens + backButtonColor?: ColorTokens + edges?: Edge[] +} + +export function ScreenWithHeader({ + centerElement, + rightElement, + fullScreen = false, + backgroundColor = '$surface1', + backButtonColor, + edges = ['top', 'left', 'right'], + children, +}: PropsWithChildren): JSX.Element { + return ( + + + {children} + + + ) +} + +type ScreenHeaderProps = { + centerElement?: JSX.Element + rightElement?: JSX.Element + fullScreen?: boolean // Expand to device edges + backgroundColor?: ColorTokens + backButtonColor?: ColorTokens +} + +/** + * Fixed header component that can be used in any screen context. + * Supports customization of center and right elements, and can expand to device edges. + */ +function ScreenHeader({ + centerElement, + rightElement = , + fullScreen = false, + backgroundColor, + backButtonColor, +}: ScreenHeaderProps): JSX.Element { + const insets = useAppInsets() + const headerRowStyles = useMemo(() => { + return fullScreen + ? { + paddingTop: insets.top, + } + : { paddingTop: 0 } + }, [fullScreen, insets.top]) + + return ( + + + + + {centerElement} + + {rightElement} + + + + ) +} + +// If full screen, extend content to edge of device screen +function HeaderWrapper({ + fullScreen, + children, + backgroundColor = '$surface1', +}: PropsWithChildren<{ + fullScreen: boolean + backgroundColor?: ColorTokens +}>): JSX.Element { + if (!fullScreen) { + return {children} + } + return ( + + {children} + + ) +} diff --git a/apps/mobile/src/components/layout/screens/ScrollHeader.tsx b/apps/mobile/src/components/layout/screens/ScrollHeader.tsx new file mode 100644 index 00000000..c45d9d8f --- /dev/null +++ b/apps/mobile/src/components/layout/screens/ScrollHeader.tsx @@ -0,0 +1,118 @@ +import { useScrollToTop } from '@react-navigation/native' +import React, { ReactElement, useMemo } from 'react' +import { StyleProp, ViewStyle } from 'react-native' +import Animated, { Extrapolate, interpolate, SharedValue, useAnimatedStyle } from 'react-native-reanimated' +import { BackButton } from 'src/components/buttons/BackButton' +import { WithScrollToTop } from 'src/components/layout/screens/WithScrollToTop' +import { ColorTokens, Flex } from 'ui/src' +import { AnimatedFlex } from 'ui/src/components/layout/AnimatedFlex' +import { iconSizes, zIndexes } from 'ui/src/theme' +import { useAppInsets } from 'uniswap/src/hooks/useAppInsets' + +type ScrollHeaderProps = { + scrollY: SharedValue + showHeaderScrollYDistance: number + fullScreen?: boolean + // hard to type + // oxlint-disable-next-line typescript/no-explicit-any -- Ref type varies based on list component used + listRef: React.MutableRefObject + centerElement?: JSX.Element + rightElement?: JSX.Element + backgroundColor?: ColorTokens + backButtonColor?: ColorTokens +} + +/** + * Fixed header that will fade in on scroll. Define values in parent, to be used by some + * relevant list component. + * + * Used to achieve functionality of HeaderScrollScreen, but can be used in any context. + */ +export function ScrollHeader({ + listRef, + scrollY, + showHeaderScrollYDistance, + centerElement, + rightElement = , + fullScreen = false, + backgroundColor, + backButtonColor, +}: ScrollHeaderProps): JSX.Element { + // scrolls to top when tapping on the active tab + useScrollToTop(listRef) + + const visibleOnScrollStyle = useAnimatedStyle(() => { + return { + opacity: interpolate(scrollY.value, [0, showHeaderScrollYDistance], [0, 1], Extrapolate.CLAMP), + } + }) + + const insets = useAppInsets() + const headerRowStyles = useMemo(() => { + return fullScreen + ? { + paddingTop: insets.top, + } + : { paddingTop: 0 } + }, [fullScreen, insets.top]) + + const headerWrapperStyles = fullScreen ? [visibleOnScrollStyle, { zIndex: zIndexes.popover }] : [] + + return ( + + + + + + {centerElement} + + {rightElement} + + + + + ) +} + +// If full screen, extend content to edge of device screen with an absolute position. +function HeaderWrapper({ + fullScreen, + children, + style, + backgroundColor = '$surface1', +}: { + fullScreen: boolean + children: ReactElement + style?: StyleProp>> + backgroundColor?: ColorTokens +}): JSX.Element { + if (!fullScreen) { + return {children} + } + return ( + + {children} + + ) +} diff --git a/apps/mobile/src/components/layout/screens/WithScrollToTop.tsx b/apps/mobile/src/components/layout/screens/WithScrollToTop.tsx new file mode 100644 index 00000000..6c316b94 --- /dev/null +++ b/apps/mobile/src/components/layout/screens/WithScrollToTop.tsx @@ -0,0 +1,24 @@ +import React, { PropsWithChildren } from 'react' +import { Pressable } from 'react-native' +import { config } from 'uniswap/src/config' + +type WithScrollToTopProps = PropsWithChildren + +// accept any ref +// oxlint-disable-next-line typescript/no-explicit-any, react/display-name -- Component needs to accept refs from various list types +export const WithScrollToTop = React.forwardRef(({ children }, ref): JSX.Element => { + const onPress = (): void => { + if (typeof ref === 'function' || !ref?.current?.scrollToOffset) { + return + } + + ref.current.scrollToOffset({ animated: true, offset: 0 }) + } + + // In E2E test mode, don't wrap in Pressable to allow Maestro to access child testIDs + if (config.isE2ETest) { + return <>{children} + } + + return {children} +}) diff --git a/apps/mobile/src/components/loading/loaders.tsx b/apps/mobile/src/components/loading/loaders.tsx new file mode 100644 index 00000000..0905f332 --- /dev/null +++ b/apps/mobile/src/components/loading/loaders.tsx @@ -0,0 +1,70 @@ +import React, { memo } from 'react' +import { TransactionLoader } from 'src/components/loading/parts/TransactionLoader' +import { WaveLoader } from 'src/components/loading/parts/WaveLoader' +import { Flex, FlexLoader, FlexLoaderProps, getToken, Skeleton } from 'ui/src' + +function Graph(): JSX.Element { + return ( + + + + ) +} + +const Transaction = memo(function TransactionInner({ repeat = 1 }: { repeat?: number }): JSX.Element { + return ( + + + {/* oxlint-disable-next-line max-params */} + {new Array(repeat).fill(null).map((_, i, { length }) => ( + + + + ))} + + + ) +}) + +function Box(props: FlexLoaderProps): JSX.Element { + return ( + + + + ) +} + +function Image(): JSX.Element { + return ( + + + + ) +} + +function Favorite({ + height, + contrast, + ...props +}: { height?: number; contrast?: boolean } & FlexLoaderProps): JSX.Element { + return ( + + {/* surface3 because these only show up on explore modal which has a blurred bg that makes neutral3 look weird */} + + + ) +} + +export const Loader = { + Box, + Transaction, + Graph, + Image, + Favorite, +} diff --git a/apps/mobile/src/components/loading/parts/TransactionLoader.tsx b/apps/mobile/src/components/loading/parts/TransactionLoader.tsx new file mode 100644 index 00000000..d29390ba --- /dev/null +++ b/apps/mobile/src/components/loading/parts/TransactionLoader.tsx @@ -0,0 +1,36 @@ +import React from 'react' +import { Flex, Text } from 'ui/src' +import { TXN_HISTORY_ICON_SIZE } from 'uniswap/src/components/activity/utils' + +interface TransactionLoaderProps { + opacity: number +} + +export function TransactionLoader({ opacity }: TransactionLoaderProps): JSX.Element { + return ( + + + + + + + + + + + + + + ) +} diff --git a/apps/mobile/src/components/loading/parts/WaveLoader.tsx b/apps/mobile/src/components/loading/parts/WaveLoader.tsx new file mode 100644 index 00000000..12e6cd4e --- /dev/null +++ b/apps/mobile/src/components/loading/parts/WaveLoader.tsx @@ -0,0 +1,46 @@ +import React, { useEffect } from 'react' +import { StyleSheet } from 'react-native' +import Animated, { + interpolate, + useAnimatedStyle, + useSharedValue, + withRepeat, + withTiming, +} from 'react-native-reanimated' +import { useChartDimensions } from 'src/components/PriceExplorer/useChartDimensions' +import { Flex, useSporeColors } from 'ui/src' +import Wave from 'ui/src/assets/backgrounds/wave.svg' + +const WAVE_WIDTH = 416 +const WAVE_DURATION = 2000 + +export function WaveLoader(): JSX.Element { + const colors = useSporeColors() + const yPosition = useSharedValue(0) + const { chartHeight } = useChartDimensions() + + useEffect(() => { + yPosition.value = withRepeat(withTiming(1, { duration: WAVE_DURATION }), Infinity, false) + // oxlint-disable-next-line react/exhaustive-deps -- biome-parity: oxlint is stricter here + }, []) + + const animatedStyle = useAnimatedStyle(() => ({ + transform: [ + { + translateX: interpolate(yPosition.value, [0, 1], [0, -WAVE_WIDTH]), + }, + ], + })) + + return ( + + + + + + + + + + ) +} diff --git a/apps/mobile/src/components/mnemonic/HiddenMnemonicWordView.tsx b/apps/mobile/src/components/mnemonic/HiddenMnemonicWordView.tsx new file mode 100644 index 00000000..e40754f3 --- /dev/null +++ b/apps/mobile/src/components/mnemonic/HiddenMnemonicWordView.tsx @@ -0,0 +1,49 @@ +import React from 'react' +import { useTranslation } from 'react-i18next' +import { Flex, Text, TouchableArea, useShadowPropsShort } from 'ui/src' +import { EyeSlash } from 'ui/src/components/icons' +import { HiddenWordView } from 'ui/src/components/placeholders/HiddenWordView' + +const ROWS = 6 +const COLUMNS = 2 + +type HiddenMnemonicWordViewProps = { + enableRevealButton?: boolean + onRevealPress?: () => void +} +export function HiddenMnemonicWordView({ + enableRevealButton = false, + onRevealPress, +}: HiddenMnemonicWordViewProps): JSX.Element { + const { t } = useTranslation() + const shadowProps = useShadowPropsShort() + + return ( + + + {enableRevealButton && ( + + onRevealPress?.()}> + + + + {t('common.button.reveal')} + + + + + )} + + ) +} diff --git a/apps/mobile/src/components/mnemonic/MnemonicConfirmation.tsx b/apps/mobile/src/components/mnemonic/MnemonicConfirmation.tsx new file mode 100644 index 00000000..1a5817e6 --- /dev/null +++ b/apps/mobile/src/components/mnemonic/MnemonicConfirmation.tsx @@ -0,0 +1,48 @@ +import { useTranslation } from 'react-i18next' +import { requireNativeComponent, StyleProp, ViewProps } from 'react-native' +import { useNativeComponentKey } from 'src/app/hooks' +import { FlexProps, flexStyles, HiddenFromScreenReaders } from 'ui/src' +import { useDeviceDimensions } from 'ui/src/hooks/useDeviceDimensions' +import { spacing } from 'ui/src/theme' +import { isAndroid } from 'utilities/src/platform' + +interface NativeMnemonicConfirmationProps { + mnemonicId: Address + shouldShowSmallText: boolean + selectedWordPlaceholder: string + onConfirmComplete: () => void +} + +const NativeMnemonicConfirmation = requireNativeComponent('MnemonicConfirmation') + +type MnemonicConfirmationProps = ViewProps & { + mnemonicId: Address + onConfirmComplete: () => void +} + +const mnemonicConfirmationStyle: StyleProp = { + flex: 1, + flexGrow: 1, +} + +export function MnemonicConfirmation(props: MnemonicConfirmationProps): JSX.Element { + const { t } = useTranslation() + const { fullHeight } = useDeviceDimensions() + const shouldShowSmallText = fullHeight < 700 + // Android only (ensures that Jetpack Compose mounts the view again + // after navigating back in the stack navigator) + // (see https://github.com/react-native-community/discussions-and-proposals/issues/446#issuecomment-2041254054) + const { key } = useNativeComponentKey(isAndroid) + + return ( + + + + ) +} diff --git a/apps/mobile/src/components/mnemonic/MnemonicDisplay.tsx b/apps/mobile/src/components/mnemonic/MnemonicDisplay.tsx new file mode 100644 index 00000000..ae399b21 --- /dev/null +++ b/apps/mobile/src/components/mnemonic/MnemonicDisplay.tsx @@ -0,0 +1,137 @@ +import React, { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { NativeSyntheticEvent, requireNativeComponent, StyleSheet, ViewProps } from 'react-native' +import { useNativeComponentKey } from 'src/app/hooks' +import { HiddenMnemonicWordView } from 'src/components/mnemonic/HiddenMnemonicWordView' +import { Flex, flexStyles, HiddenFromScreenReaders, Text } from 'ui/src' +import { GraduationCap } from 'ui/src/components/icons' +import { spacing } from 'ui/src/theme' +import { logger } from 'utilities/src/logger/logger' +import { isAndroid } from 'utilities/src/platform' +import { useSignerAccounts } from 'wallet/src/features/wallet/hooks' +import { Keyring } from 'wallet/src/features/wallet/Keyring/Keyring' + +const EMPTY_MNEMONIC_EVENT = 'Empty mnemonic' + +type HeightMeasuredEvent = { + height: number +} + +type EmptyMnemonicEvent = { + mnemonicId: string +} + +interface NativeMnemonicDisplayProps { + copyText: string + copiedText: string + mnemonicId: string + + onHeightMeasured: (event: NativeSyntheticEvent) => void + onEmptyMnemonic: (event: NativeSyntheticEvent) => void +} + +const NativeMnemonicDisplay = requireNativeComponent('MnemonicDisplay') + +type MnemonicDisplayProps = { + showMnemonic?: boolean + enableRevealButton?: boolean + onMnemonicShown?: () => void +} & ViewProps & + Pick + +export function MnemonicDisplay({ + showMnemonic = true, + enableRevealButton = false, + onMnemonicShown, + ...nativeComponentProps +}: MnemonicDisplayProps): JSX.Element { + const { t } = useTranslation() + const [height, setHeight] = useState(0) + // Android only (ensures that Jetpack Compose mounts the view again + // after navigating back in the stack navigator) + // (see https://github.com/react-native-community/discussions-and-proposals/issues/446#issuecomment-2041254054) + const { key } = useNativeComponentKey(isAndroid) + + const [revealPressed, setRevealPressed] = useState(false) + const showMnemonicWithReveal = enableRevealButton ? revealPressed : showMnemonic + + const signerMnemonicAccounts = useSignerAccounts() + const [keyringPrivateKeyAddresses, setKeyringPrivateKeyAddresses] = useState([]) + const [keyringMnemonicIds, setKeyringMnemonicIds] = useState([]) + + useEffect(() => { + Keyring.getMnemonicIds() + .then(setKeyringMnemonicIds) + .catch(() => { + // no-op + }) + Keyring.getAddressesForStoredPrivateKeys() + .then(setKeyringPrivateKeyAddresses) + .catch(() => { + // no-op + }) + }, []) + + return ( + + {showMnemonicWithReveal ? ( + { + // Round to limit state updates (was called with nearly the same value multiple times) + setHeight(Math.round(e.nativeEvent.height)) + }} + onEmptyMnemonic={(e) => { + logger.warn('MnemonicDisplay.tsx', 'onEmptyMnemonic', EMPTY_MNEMONIC_EVENT, { + mnemonicId: e.nativeEvent.mnemonicId, + keyringMnemonicIds, + keyringPrivateKeyAddresses, + signerMnemonicAccountAddresses: signerMnemonicAccounts.map((account) => account.address), + signerMnemonicAccountMnemonicIds: signerMnemonicAccounts.map((account) => account.mnemonicId), + }) + }} + {...nativeComponentProps} + /> + ) : ( + setHeight(Math.round(e.nativeEvent.layout.height))}> + { + onMnemonicShown?.() + setRevealPressed(true) + }} + /> + + )} + + + + + {t('onboarding.backup.manual.banner')} + + + + + ) +} + +const styles = StyleSheet.create({ + mnemonicDisplay: { + // Set flex: 1 to prevent component from collapsing before it is measured + flex: 1, + marginBottom: spacing.spacing12, + }, +}) diff --git a/apps/mobile/src/components/mnemonic/SeedPhraseDisplay.tsx b/apps/mobile/src/components/mnemonic/SeedPhraseDisplay.tsx new file mode 100644 index 00000000..495aa2a8 --- /dev/null +++ b/apps/mobile/src/components/mnemonic/SeedPhraseDisplay.tsx @@ -0,0 +1,103 @@ +import { useFocusEffect, useNavigation } from '@react-navigation/core' +import { addScreenshotListener } from 'expo-screen-capture' +import React, { useCallback, useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { navigate } from 'src/app/navigation/rootNavigation' +import { MnemonicDisplay } from 'src/components/mnemonic/MnemonicDisplay' +import { WalletRestoreType } from 'src/components/RestoreWalletModal/RestoreWalletModalState' +import { useBiometricAppSpeedBump } from 'src/features/biometrics/useBiometricAppSpeedBump' +import { useLockScreenOnBlur } from 'src/features/lockScreen/hooks/useLockScreenOnBlur' +import { useWalletRestore } from 'src/features/wallet/useWalletRestore' +import { Button, Flex } from 'ui/src' +import { WarningSeverity } from 'uniswap/src/components/modals/WarningModal/types' +import { WarningModal } from 'uniswap/src/components/modals/WarningModal/WarningModal' +import { ModalName } from 'uniswap/src/features/telemetry/constants' +import { TestID } from 'uniswap/src/test/fixtures/testIDs' + +type Props = { + mnemonicId: string + onDismiss?: () => void + walletNeedsRestore?: boolean +} + +export function SeedPhraseDisplay({ mnemonicId, onDismiss, walletNeedsRestore }: Props): JSX.Element { + const { t } = useTranslation() + const { walletRestoreType } = useWalletRestore({ + openModalImmediately: true, + }) + const [showSeedPhrase, setShowSeedPhrase] = useState(false) + const navigation = useNavigation() + const [showSeedPhraseViewWarningModal, setShowSeedPhraseViewWarningModal] = useState(!walletNeedsRestore) + + useFocusEffect( + useCallback(() => { + if (walletRestoreType !== WalletRestoreType.None) { + navigation.goBack() + + // This is a very unlikely edge case if the user somehow get to this screen on a new device. + // In this case, we want to back an additional time to dismiss the NewDevice modal which is + // will try to reopen anytime this screen is focused. + if (walletRestoreType === WalletRestoreType.NewDevice) { + navigation.goBack() + } + } + }, [walletRestoreType, navigation]), + ) + + useLockScreenOnBlur() + + const onShowSeedPhraseConfirmed = (): void => { + setShowSeedPhrase(true) + setShowSeedPhraseViewWarningModal(false) + } + const { onBiometricContinue } = useBiometricAppSpeedBump(onShowSeedPhraseConfirmed) + + // oxlint-disable-next-line react/exhaustive-deps -- we want to recalculate this when showSeedPhrase changes + useEffect(() => { + const listener = addScreenshotListener(() => + navigate(ModalName.ScreenshotWarning, { acknowledgeText: t('common.button.close') }), + ) + return () => listener.remove() + }, [showSeedPhrase, t]) + + return ( + <> + + + + + + + + + + {showSeedPhraseViewWarningModal && ( + { + setShowSeedPhraseViewWarningModal(false) + if (!showSeedPhrase) { + onDismiss?.() + } + }} + onAcknowledge={onBiometricContinue} + /> + )} + + ) +} diff --git a/apps/mobile/src/components/mnemonic/cloudImportUtils.tsx b/apps/mobile/src/components/mnemonic/cloudImportUtils.tsx new file mode 100644 index 00000000..69015ab9 --- /dev/null +++ b/apps/mobile/src/components/mnemonic/cloudImportUtils.tsx @@ -0,0 +1,34 @@ +import { Alert } from 'react-native' +import { isCloudStorageAvailable } from 'src/features/CloudBackup/RNCloudStorageBackupsManager' +import { openSettings } from 'src/utils/linking' +import { AppTFunction } from 'ui/src/i18n/types' +import { isAndroid } from 'utilities/src/platform' + +/** + * Checks whether cloud backup (iCloud/GDrive) is available. Otherwise we + * show them alert prompting them to enable it. + * + * @param t - translation function + * @returns true if cloud backup is available, false otherwise + */ +export const checkCloudBackupOrShowAlert = async (t: AppTFunction): Promise => { + const cloudStorageAvailable = await isCloudStorageAvailable() + + if (cloudStorageAvailable) { + return true + } + + Alert.alert( + isAndroid ? t('account.cloud.error.unavailable.title.android') : t('account.cloud.error.unavailable.title.ios'), + isAndroid ? t('account.cloud.error.unavailable.message.android') : t('account.cloud.error.unavailable.message.ios'), + [ + { + text: t('account.cloud.error.unavailable.button.settings'), + onPress: openSettings, + style: 'default', + }, + { text: t('account.cloud.error.unavailable.button.cancel'), style: 'cancel' }, + ], + ) + return false +} diff --git a/apps/mobile/src/components/modals/FullScreenNavModal.tsx b/apps/mobile/src/components/modals/FullScreenNavModal.tsx new file mode 100644 index 00000000..e8e2ab56 --- /dev/null +++ b/apps/mobile/src/components/modals/FullScreenNavModal.tsx @@ -0,0 +1,44 @@ +import React, { PropsWithChildren } from 'react' +import { useDispatch } from 'react-redux' +import { closeModal } from 'src/features/modals/modalSlice' +import { ModalsState } from 'src/features/modals/ModalsState' +import { useSporeColors } from 'ui/src' +import { Modal } from 'uniswap/src/components/modals/Modal' +import { ModalProps } from 'uniswap/src/components/modals/ModalProps' + +/** + * This is a wrapper around the Modal component intended but not limited to + * more complex modals that require full screen navigation. + */ +export function FullScreenNavModal({ + name, + children, + ...modalProps +}: PropsWithChildren< + { + name: keyof ModalsState + } & ModalProps +>): JSX.Element { + const colors = useSporeColors() + const dispatch = useDispatch() + + const onClose = (): void => { + dispatch(closeModal({ name })) + } + + return ( + + {children} + + ) +} diff --git a/apps/mobile/src/components/modals/ReactNavigationModals/AdvancedSettingsModal.tsx b/apps/mobile/src/components/modals/ReactNavigationModals/AdvancedSettingsModal.tsx new file mode 100644 index 00000000..a98294e9 --- /dev/null +++ b/apps/mobile/src/components/modals/ReactNavigationModals/AdvancedSettingsModal.tsx @@ -0,0 +1,10 @@ +import { AppStackScreenProp } from 'src/app/navigation/types' +import { ReactNavigationModal } from 'src/components/modals/ReactNavigationModals/ReactNavigationModal' +import { ModalName } from 'uniswap/src/features/telemetry/constants' +import { SmartWalletAdvancedSettingsModal } from 'wallet/src/components/smartWallet/modals/SmartWalletAdvancedSettingsModal' + +export const AdvancedSettingsModal = ( + props: AppStackScreenProp, +): JSX.Element => { + return +} diff --git a/apps/mobile/src/components/modals/ReactNavigationModals/BridgedAssetModal.tsx b/apps/mobile/src/components/modals/ReactNavigationModals/BridgedAssetModal.tsx new file mode 100644 index 00000000..52ea8f98 --- /dev/null +++ b/apps/mobile/src/components/modals/ReactNavigationModals/BridgedAssetModal.tsx @@ -0,0 +1,8 @@ +import { AppStackScreenProp } from 'src/app/navigation/types' +import { ReactNavigationModal } from 'src/components/modals/ReactNavigationModals/ReactNavigationModal' +import { BridgedAssetModal } from 'uniswap/src/components/BridgedAsset/BridgedAssetModal' +import { ModalName } from 'uniswap/src/features/telemetry/constants' + +export const BridgedAssetModalScreen = (props: AppStackScreenProp): JSX.Element => { + return +} diff --git a/apps/mobile/src/components/modals/ReactNavigationModals/HiddenTokenInfoModalScreen.tsx b/apps/mobile/src/components/modals/ReactNavigationModals/HiddenTokenInfoModalScreen.tsx new file mode 100644 index 00000000..10d3d8f4 --- /dev/null +++ b/apps/mobile/src/components/modals/ReactNavigationModals/HiddenTokenInfoModalScreen.tsx @@ -0,0 +1,10 @@ +import { AppStackScreenProp } from 'src/app/navigation/types' +import { ReactNavigationModal } from 'src/components/modals/ReactNavigationModals/ReactNavigationModal' +import { ModalName } from 'uniswap/src/features/telemetry/constants' +import { HiddenTokenInfoModal } from 'uniswap/src/features/transactions/modals/HiddenTokenInfoModal' + +export const HiddenTokenInfoModalScreen = ( + props: AppStackScreenProp, +): JSX.Element => { + return +} diff --git a/apps/mobile/src/components/modals/ReactNavigationModals/LanguageSettingsScreen.tsx b/apps/mobile/src/components/modals/ReactNavigationModals/LanguageSettingsScreen.tsx new file mode 100644 index 00000000..80a412cf --- /dev/null +++ b/apps/mobile/src/components/modals/ReactNavigationModals/LanguageSettingsScreen.tsx @@ -0,0 +1,8 @@ +import { AppStackScreenProp } from 'src/app/navigation/types' +import { ReactNavigationModal } from 'src/components/modals/ReactNavigationModals/ReactNavigationModal' +import { ModalName } from '@l.x/lx/src/features/telemetry/constants' +import { SettingsLanguageModal } from '@luxfi/wallet/src/components/settings/language/SettingsLanguageModal' + +export const LanguageSettingsScreen = (props: AppStackScreenProp): JSX.Element => { + return +} diff --git a/apps/mobile/src/components/modals/ReactNavigationModals/PasskeyHelpModalScreen.tsx b/apps/mobile/src/components/modals/ReactNavigationModals/PasskeyHelpModalScreen.tsx new file mode 100644 index 00000000..3506160a --- /dev/null +++ b/apps/mobile/src/components/modals/ReactNavigationModals/PasskeyHelpModalScreen.tsx @@ -0,0 +1,8 @@ +import { AppStackScreenProp } from 'src/app/navigation/types' +import { ReactNavigationModal } from 'src/components/modals/ReactNavigationModals/ReactNavigationModal' +import { PasskeysHelpModal } from 'uniswap/src/features/passkey/PasskeysHelpModal' +import { ModalName } from 'uniswap/src/features/telemetry/constants' + +export const PasskeyHelpModalScreen = (props: AppStackScreenProp): JSX.Element => { + return +} diff --git a/apps/mobile/src/components/modals/ReactNavigationModals/PasskeyManagementModalScreen.tsx b/apps/mobile/src/components/modals/ReactNavigationModals/PasskeyManagementModalScreen.tsx new file mode 100644 index 00000000..707b690c --- /dev/null +++ b/apps/mobile/src/components/modals/ReactNavigationModals/PasskeyManagementModalScreen.tsx @@ -0,0 +1,10 @@ +import { AppStackScreenProp } from 'src/app/navigation/types' +import { ReactNavigationModal } from 'src/components/modals/ReactNavigationModals/ReactNavigationModal' +import { PasskeyManagementModal } from 'uniswap/src/features/passkey/PasskeyManagementModal' +import { ModalName } from 'uniswap/src/features/telemetry/constants' + +export const PasskeyManagementModalScreen = ( + props: AppStackScreenProp, +): JSX.Element => { + return +} diff --git a/apps/mobile/src/components/modals/ReactNavigationModals/PermissionsSettingsScreen.tsx b/apps/mobile/src/components/modals/ReactNavigationModals/PermissionsSettingsScreen.tsx new file mode 100644 index 00000000..d1dc01d8 --- /dev/null +++ b/apps/mobile/src/components/modals/ReactNavigationModals/PermissionsSettingsScreen.tsx @@ -0,0 +1,10 @@ +import { AppStackScreenProp } from 'src/app/navigation/types' +import { ReactNavigationModal } from 'src/components/modals/ReactNavigationModals/ReactNavigationModal' +import { ModalName } from 'uniswap/src/features/telemetry/constants' +import { PermissionsModal } from 'wallet/src/components/settings/permissions/PermissionsModal' + +export const PermissionsSettingsScreen = ( + props: AppStackScreenProp, +): JSX.Element => { + return +} diff --git a/apps/mobile/src/components/modals/ReactNavigationModals/PortfolioBalanceSettingsScreen.tsx b/apps/mobile/src/components/modals/ReactNavigationModals/PortfolioBalanceSettingsScreen.tsx new file mode 100644 index 00000000..75ae19d1 --- /dev/null +++ b/apps/mobile/src/components/modals/ReactNavigationModals/PortfolioBalanceSettingsScreen.tsx @@ -0,0 +1,10 @@ +import { AppStackScreenProp } from 'src/app/navigation/types' +import { ReactNavigationModal } from 'src/components/modals/ReactNavigationModals/ReactNavigationModal' +import { ModalName } from 'uniswap/src/features/telemetry/constants' +import { PortfolioBalanceModal } from 'wallet/src/components/settings/portfolioBalance/PortfolioBalanceModal' + +export const PortfolioBalanceSettingsScreen = ( + props: AppStackScreenProp, +): JSX.Element => { + return +} diff --git a/apps/mobile/src/components/modals/ReactNavigationModals/ReactNavigationModal.tsx b/apps/mobile/src/components/modals/ReactNavigationModals/ReactNavigationModal.tsx new file mode 100644 index 00000000..87b60266 --- /dev/null +++ b/apps/mobile/src/components/modals/ReactNavigationModals/ReactNavigationModal.tsx @@ -0,0 +1,75 @@ +import { type ComponentType, memo } from 'react' +import type { AppStackParamList, AppStackScreenProp } from 'src/app/navigation/types' +import { useReactNavigationModal } from 'src/components/modals/useReactNavigationModal' +import type { GetProps } from 'ui/src' +import { BridgedAssetModal } from 'uniswap/src/components/BridgedAsset/BridgedAssetModal' +import { WormholeModal } from 'uniswap/src/components/BridgedAsset/WormholeModal' +import { ReportPortfolioDataModal } from 'uniswap/src/components/reporting/ReportPortfolioDataModal' +import { ReportTokenDataModal } from 'uniswap/src/components/reporting/ReportTokenDataModal' +import { ReportTokenIssueModal } from 'uniswap/src/components/reporting/ReportTokenIssueModal' +import { PasskeyManagementModal } from 'uniswap/src/features/passkey/PasskeyManagementModal' +import { PasskeysHelpModal } from 'uniswap/src/features/passkey/PasskeysHelpModal' +import { ModalName } from 'uniswap/src/features/telemetry/constants' +import { TestnetModeModal } from 'uniswap/src/features/testnets/TestnetModeModal' +import { HiddenTokenInfoModal } from 'uniswap/src/features/transactions/modals/HiddenTokenInfoModal' +import { PermissionsModal } from 'wallet/src/components/settings/permissions/PermissionsModal' +import { PortfolioBalanceModal } from 'wallet/src/components/settings/portfolioBalance/PortfolioBalanceModal' +import { SmartWalletAdvancedSettingsModal } from 'wallet/src/components/smartWallet/modals/SmartWalletAdvancedSettingsModal' +import { SmartWalletEnabledModal } from 'wallet/src/components/smartWallet/modals/SmartWalletEnabledModal' +import { SmartWalletNudge } from 'wallet/src/components/smartWallet/modals/SmartWalletNudge' + +// Define names of shared modals we're explicitly supporting on mobile +type ValidModalNames = keyof Pick< + AppStackParamList, + | typeof ModalName.TestnetMode + | typeof ModalName.HiddenTokenInfoModal + | typeof ModalName.PasskeyManagement + | typeof ModalName.PasskeysHelp + | typeof ModalName.SmartWalletAdvancedSettingsModal + | typeof ModalName.SmartWalletEnabledModal + | typeof ModalName.SmartWalletNudge + | typeof ModalName.PermissionsModal + | typeof ModalName.PortfolioBalanceModal + | typeof ModalName.BridgedAsset + | typeof ModalName.Wormhole + | typeof ModalName.ReportPortfolioData + | typeof ModalName.ReportTokenIssue + | typeof ModalName.ReportTokenData +> + +type ModalNameWithComponentProps = { + [ModalName.TestnetMode]: GetProps + [ModalName.HiddenTokenInfoModal]: GetProps + [ModalName.PasskeyManagement]: GetProps + [ModalName.PasskeysHelp]: GetProps + [ModalName.SmartWalletNudge]: GetProps + [ModalName.SmartWalletAdvancedSettingsModal]: GetProps + [ModalName.SmartWalletEnabledModal]: GetProps + [ModalName.PermissionsModal]: GetProps + [ModalName.PortfolioBalanceModal]: GetProps + [ModalName.BridgedAsset]: GetProps + [ModalName.Wormhole]: GetProps + [ModalName.ReportPortfolioData]: GetProps + [ModalName.ReportTokenIssue]: GetProps + [ModalName.ReportTokenData]: GetProps +} + +type NavigationModalProps = { + modalComponent: ComponentType + route: AppStackScreenProp['route'] +} + +/** + * A generic wrapper component that adapts a shared modal to work with React Navigation. + */ +function ReactNavigationModalInner({ + modalComponent: ModalComponent, + route, +}: NavigationModalProps): JSX.Element { + const { onClose } = useReactNavigationModal() + const params = (route.params ?? {}) as NonNullable + + return +} + +export const ReactNavigationModal = memo(ReactNavigationModalInner) diff --git a/apps/mobile/src/components/modals/ReactNavigationModals/ReportPortfolioDataModalScreen.tsx b/apps/mobile/src/components/modals/ReactNavigationModals/ReportPortfolioDataModalScreen.tsx new file mode 100644 index 00000000..e40de0e8 --- /dev/null +++ b/apps/mobile/src/components/modals/ReactNavigationModals/ReportPortfolioDataModalScreen.tsx @@ -0,0 +1,10 @@ +import { AppStackScreenProp } from 'src/app/navigation/types' +import { ReactNavigationModal } from 'src/components/modals/ReactNavigationModals/ReactNavigationModal' +import { ReportPortfolioDataModal } from 'uniswap/src/components/reporting/ReportPortfolioDataModal' +import { ModalName } from 'uniswap/src/features/telemetry/constants' + +export const ReportPortfolioDataModalScreen = ( + props: AppStackScreenProp, +): JSX.Element => { + return +} diff --git a/apps/mobile/src/components/modals/ReactNavigationModals/ReportTokenDataModalScreen.tsx b/apps/mobile/src/components/modals/ReactNavigationModals/ReportTokenDataModalScreen.tsx new file mode 100644 index 00000000..0cf6770c --- /dev/null +++ b/apps/mobile/src/components/modals/ReactNavigationModals/ReportTokenDataModalScreen.tsx @@ -0,0 +1,10 @@ +import { AppStackScreenProp } from 'src/app/navigation/types' +import { ReactNavigationModal } from 'src/components/modals/ReactNavigationModals/ReactNavigationModal' +import { ReportTokenDataModal } from 'uniswap/src/components/reporting/ReportTokenDataModal' +import { ModalName } from 'uniswap/src/features/telemetry/constants' + +export const ReportTokenDataModalScreen = ( + props: AppStackScreenProp, +): JSX.Element => { + return +} diff --git a/apps/mobile/src/components/modals/ReactNavigationModals/ReportTokenIssueModalScreen.tsx b/apps/mobile/src/components/modals/ReactNavigationModals/ReportTokenIssueModalScreen.tsx new file mode 100644 index 00000000..aba9bc3f --- /dev/null +++ b/apps/mobile/src/components/modals/ReactNavigationModals/ReportTokenIssueModalScreen.tsx @@ -0,0 +1,10 @@ +import { AppStackScreenProp } from 'src/app/navigation/types' +import { ReactNavigationModal } from 'src/components/modals/ReactNavigationModals/ReactNavigationModal' +import { ReportTokenIssueModal } from 'uniswap/src/components/reporting/ReportTokenIssueModal' +import { ModalName } from 'uniswap/src/features/telemetry/constants' + +export const ReportTokenIssueModalScreen = ( + props: AppStackScreenProp, +): JSX.Element => { + return +} diff --git a/apps/mobile/src/components/modals/ReactNavigationModals/SmartWalletEnabledModalScreen.tsx b/apps/mobile/src/components/modals/ReactNavigationModals/SmartWalletEnabledModalScreen.tsx new file mode 100644 index 00000000..74274d03 --- /dev/null +++ b/apps/mobile/src/components/modals/ReactNavigationModals/SmartWalletEnabledModalScreen.tsx @@ -0,0 +1,10 @@ +import { AppStackScreenProp } from 'src/app/navigation/types' +import { ReactNavigationModal } from 'src/components/modals/ReactNavigationModals/ReactNavigationModal' +import { ModalName } from 'uniswap/src/features/telemetry/constants' +import { SmartWalletEnabledModal } from 'wallet/src/components/smartWallet/modals/SmartWalletEnabledModal' + +export const SmartWalletEnabledModalScreen = ( + props: AppStackScreenProp, +): JSX.Element => { + return +} diff --git a/apps/mobile/src/components/modals/ReactNavigationModals/SmartWalletNudgeScreen.tsx b/apps/mobile/src/components/modals/ReactNavigationModals/SmartWalletNudgeScreen.tsx new file mode 100644 index 00000000..1e8c2e32 --- /dev/null +++ b/apps/mobile/src/components/modals/ReactNavigationModals/SmartWalletNudgeScreen.tsx @@ -0,0 +1,23 @@ +import { useMemo } from 'react' +import { AppStackScreenProp } from 'src/app/navigation/types' +import { ReactNavigationModal } from 'src/components/modals/ReactNavigationModals/ReactNavigationModal' +import { useOnEnableSmartWallet } from 'src/features/smartWallet/hooks/useOnEnableSmartWallet' +import { ModalName } from 'uniswap/src/features/telemetry/constants' +import { SmartWalletNudge, SmartWalletNudgeProps } from 'wallet/src/components/smartWallet/modals/SmartWalletNudge' + +export const SmartWalletNudgeScreen = (props: AppStackScreenProp): JSX.Element => { + const onEnableSmartWallet = useOnEnableSmartWallet() + + const modalComponent = useMemo(() => { + // Create a wrapper component that pre-fills the onEnableSmartWallet prop if it's not defined + // oxlint-disable-next-line universe-custom/no-nested-component-definitions -- memoized wrapper component + return function SmartWalletNudgeWrapper(modalProps: SmartWalletNudgeProps) { + if (modalProps.onEnableSmartWallet) { + return + } + return + } + }, [onEnableSmartWallet]) + + return +} diff --git a/apps/mobile/src/components/modals/ReactNavigationModals/TestnetModeModalScreen.tsx b/apps/mobile/src/components/modals/ReactNavigationModals/TestnetModeModalScreen.tsx new file mode 100644 index 00000000..517293b7 --- /dev/null +++ b/apps/mobile/src/components/modals/ReactNavigationModals/TestnetModeModalScreen.tsx @@ -0,0 +1,8 @@ +import { AppStackScreenProp } from 'src/app/navigation/types' +import { ReactNavigationModal } from 'src/components/modals/ReactNavigationModals/ReactNavigationModal' +import { ModalName } from 'uniswap/src/features/telemetry/constants' +import { TestnetModeModal } from 'uniswap/src/features/testnets/TestnetModeModal' + +export const TestnetModeModalScreen = (props: AppStackScreenProp): JSX.Element => { + return +} diff --git a/apps/mobile/src/components/modals/ReactNavigationModals/WormholeModal.tsx b/apps/mobile/src/components/modals/ReactNavigationModals/WormholeModal.tsx new file mode 100644 index 00000000..92a60c22 --- /dev/null +++ b/apps/mobile/src/components/modals/ReactNavigationModals/WormholeModal.tsx @@ -0,0 +1,8 @@ +import { AppStackScreenProp } from 'src/app/navigation/types' +import { ReactNavigationModal } from 'src/components/modals/ReactNavigationModals/ReactNavigationModal' +import { WormholeModal } from 'uniswap/src/components/BridgedAsset/WormholeModal' +import { ModalName } from 'uniswap/src/features/telemetry/constants' + +export const WormholeModalScreen = (props: AppStackScreenProp): JSX.Element => { + return +} diff --git a/apps/mobile/src/components/modals/useIsInModal.ts b/apps/mobile/src/components/modals/useIsInModal.ts new file mode 100644 index 00000000..9d1aa450 --- /dev/null +++ b/apps/mobile/src/components/modals/useIsInModal.ts @@ -0,0 +1,19 @@ +import { useNavigationState } from '@react-navigation/core' + +/** + * Hook to check if the current screen is in a specific modal + * @param modalName The modal name to check against (defaults to Explore) + * @param checkParent If true, checks parent screen instead of current screen + * @returns boolean indicating if the current screen is inside the specified modal + */ +export function useIsInModal(modalName: string, checkParent = false): boolean { + return useNavigationState((state) => { + const routeIndex = checkParent ? state.index - 1 : state.index + + if (routeIndex < 0) { + return false + } + + return state.routes[routeIndex]?.name === modalName + }) +} diff --git a/apps/mobile/src/components/modals/useReactNavigationModal.test.tsx b/apps/mobile/src/components/modals/useReactNavigationModal.test.tsx new file mode 100644 index 00000000..c92be5c7 --- /dev/null +++ b/apps/mobile/src/components/modals/useReactNavigationModal.test.tsx @@ -0,0 +1,54 @@ +import { act, renderHook } from '@testing-library/react' +import { useReactNavigationModal } from 'src/components/modals/useReactNavigationModal' + +const mockGoBack = jest.fn() +const mockIsFocused = jest.fn(() => true) +const mockCanGoBack = jest.fn(() => true) + +jest.mock('src/app/navigation/types', () => ({ + useAppStackNavigation: jest.fn(() => ({ + goBack: mockGoBack, + isFocused: mockIsFocused, + canGoBack: mockCanGoBack, + })), +})) + +describe('useReactNavigationModal', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('should call navigation.goBack when onClose is called', () => { + const { result } = renderHook(() => useReactNavigationModal()) + expect(result.current.preventCloseRef.current).toBe(false) + act(() => { + result.current.onClose() + result.current.onClose() + result.current.onClose() + result.current.onClose() + }) + expect(mockGoBack).toHaveBeenCalledTimes(1) + expect(result.current.preventCloseRef.current).toBe(true) + }) + + it('should not call navigation.goBack when preventCloseRef is true', () => { + const { result } = renderHook(() => useReactNavigationModal()) + act(() => { + result.current.preventCloseRef.current = true + }) + act(() => { + result.current.onClose() + }) + expect(mockGoBack).not.toHaveBeenCalled() + }) + + it('should not call navigation.goBack when navigation is not focused', () => { + mockIsFocused.mockReturnValue(false) + const { result } = renderHook(() => useReactNavigationModal()) + act(() => { + result.current.onClose() + }) + expect(mockGoBack).not.toHaveBeenCalled() + expect(result.current.preventCloseRef.current).toBe(false) + }) +}) diff --git a/apps/mobile/src/components/modals/useReactNavigationModal.tsx b/apps/mobile/src/components/modals/useReactNavigationModal.tsx new file mode 100644 index 00000000..e6686db7 --- /dev/null +++ b/apps/mobile/src/components/modals/useReactNavigationModal.tsx @@ -0,0 +1,35 @@ +import { MutableRefObject, useCallback, useRef } from 'react' +import { useAppStackNavigation } from 'src/app/navigation/types' + +/** + * Helper hook to close a modal using react navigation. The purpose of this + * hook is to allow us to use react-navigation while still using the common + * modal component. + */ +export function useReactNavigationModal(): { + onClose: () => void + /** + * Needed to prevent the modal from being closed twice, which can + * happen if the modal is dismissed by pressing a close button in the + * modal and also when it gets called when the modal closes. + */ + preventCloseRef: MutableRefObject +} { + const navigation = useAppStackNavigation() + + const preventCloseRef = useRef(false) + const onClose = useCallback(() => { + if (preventCloseRef.current || !navigation.isFocused()) { + return + } + preventCloseRef.current = true + if (navigation.canGoBack()) { + navigation.goBack() + } + }, [navigation]) + + return { + onClose, + preventCloseRef, + } +} diff --git a/apps/mobile/src/components/notifications/Badge.tsx b/apps/mobile/src/components/notifications/Badge.tsx new file mode 100644 index 00000000..12d4a7a8 --- /dev/null +++ b/apps/mobile/src/components/notifications/Badge.tsx @@ -0,0 +1,34 @@ +import React, { memo, PropsWithChildren } from 'react' +import { Flex } from 'ui/src' +import { useSelectAddressHasNotifications } from 'uniswap/src/features/notifications/slice/hooks' + +type Props = PropsWithChildren<{ + address: Address +}> + +const NOTIFICATION_DOT_SIZE = 12 + +function NotificationBadgeInner({ children, address }: Props): JSX.Element { + const hasNotifications = useSelectAddressHasNotifications(address) + return ( + + {hasNotifications ? ( + + ) : null} + {children} + + ) +} + +export const NotificationBadge = memo(NotificationBadgeInner) diff --git a/apps/mobile/src/components/notifications/NotificationsBGImage.tsx b/apps/mobile/src/components/notifications/NotificationsBGImage.tsx new file mode 100644 index 00000000..8522e1af --- /dev/null +++ b/apps/mobile/src/components/notifications/NotificationsBGImage.tsx @@ -0,0 +1,60 @@ +import { useLayoutEffect, useState } from 'react' +import { Dimensions, Image, Platform } from 'react-native' +import { Flex, useIsDarkMode } from 'ui/src' +import { ONBOARDING_NOTIFICATIONS_DARK, ONBOARDING_NOTIFICATIONS_LIGHT } from 'ui/src/assets' + +/** + * Helper component to render the notifications background image based on the current theme + * and platform. + * + * One of the reasons why this is more complicated than it needs to be is because the android + * and ios images are different sizes and not the same aspect ratio. + */ +export const NotificationsBackgroundImage = (): JSX.Element => { + const isDarkMode = useIsDarkMode() + const [imageHeight, setImageHeight] = useState(0) + const [imageWidth, setImageWidth] = useState(0) + const imageSource = isDarkMode + ? Platform.select(ONBOARDING_NOTIFICATIONS_DARK) + : Platform.select(ONBOARDING_NOTIFICATIONS_LIGHT) + + const imageUri = Image.resolveAssetSource(imageSource).uri + + useLayoutEffect(() => { + Image.getSize(imageUri, (width, height) => { + setImageWidth(width) + setImageHeight(height) + }) + }, [imageUri]) + + const screenWidth = Dimensions.get('window').width + + // Since this image is dynamically loaded in a BSM, the initial BSM height + // does not account for the image. This variable is so that we can put + // a placeholder view immediately to smooth out the BSM animation. + const containerHeight = imageWidth > 0 && imageHeight > 0 ? (imageHeight * (0.9 * screenWidth)) / imageWidth : 0 + + return ( + + {imageWidth > 0 && imageHeight > 0 && ( + + )} + + ) +} diff --git a/apps/mobile/src/components/text/AnimatedText.test.tsx b/apps/mobile/src/components/text/AnimatedText.test.tsx new file mode 100644 index 00000000..04cdb558 --- /dev/null +++ b/apps/mobile/src/components/text/AnimatedText.test.tsx @@ -0,0 +1,71 @@ +import { fireEvent } from '@testing-library/react-native' +import React from 'react' +import { TextInput } from 'react-native' +import { makeMutable } from 'react-native-reanimated' +import { act } from 'react-test-renderer' +import { AnimatedText } from 'src/components/text/AnimatedText' +import { renderWithProviders } from 'src/test/render' + +describe(AnimatedText, () => { + it('renders without error', () => { + const tree = renderWithProviders() + + expect(tree).toMatchSnapshot() + }) + + describe('when text is in the loading state', () => { + it('displays text placeholder with loading shimmer when the loading property is true', async () => { + const tree = renderWithProviders() + + const shimmerPlaceholder = tree.getByTestId('shimmer-placeholder') + + fireEvent(shimmerPlaceholder, 'layout', { + nativeEvent: { + layout: { + width: 100, + height: 100, + }, + }, + }) + + const textPlaceholder = tree.queryByTestId('text-placeholder') + const shimmer = await tree.findByTestId('shimmer') + + expect(textPlaceholder).toBeTruthy() + expect(shimmer).toBeTruthy() + }) + + it('displays the loading placeholder without shimmer when the loading property has "no-shimmer" value', () => { + const tree = renderWithProviders() + + const shimmerPlaceholder = tree.queryByTestId('shimmer-placeholder') + expect(shimmerPlaceholder).toBeFalsy() + + const textPlaceholder = tree.queryByTestId('text-placeholder') + const shimmer = tree.queryByTestId('shimmer') + + expect(textPlaceholder).toBeTruthy() + expect(shimmer).toBeFalsy() + }) + }) + + describe('when text is not in the loading state', () => { + it('updates text when text value is modified', async () => { + const textValue = makeMutable('Initial') + const tree = renderWithProviders() + + expect(tree.UNSAFE_queryByType(TextInput)).toHaveAnimatedProps({ text: 'Initial' }) + + textValue.value = 'Updated' + + await act(() => { + // We must re-render the component to see the updated text + // (updating the animated value does not trigger a re-render and + // doesn't modify props returned in jest's tree) + tree.rerender() + }) + + expect(tree.UNSAFE_queryByType(TextInput)).toHaveAnimatedProps({ text: 'Updated' }) + }) + }) +}) diff --git a/apps/mobile/src/components/text/AnimatedText.tsx b/apps/mobile/src/components/text/AnimatedText.tsx new file mode 100644 index 00000000..7aca3817 --- /dev/null +++ b/apps/mobile/src/components/text/AnimatedText.tsx @@ -0,0 +1,140 @@ +import React from 'react' +import { TextProps as RNTextProps, StyleSheet, TextInput, TextInputProps, useWindowDimensions } from 'react-native' +import Animated, { createAnimatedPropAdapter, useAnimatedProps } from 'react-native-reanimated' +import { Flex, TextProps as TamaTextProps, TextFrame, TextLoaderWrapper, usePropsAndStyle } from 'ui/src' +import { fonts } from 'ui/src/theme' + +// base animated text component using a TextInput +// forked from https://github.com/wcandillon/react-native-redash/blob/master/src/ReText.tsx +// and modified to support the loading state +Animated.addWhitelistedNativeProps({ text: true }) + +type TextPropsBase = TamaTextProps & Omit + +type TextProps = TextPropsBase & { + text?: Animated.SharedValue + style?: Animated.AnimateProps['style'] + loading?: boolean | 'no-shimmer' + loadingPlaceholderText?: string +} + +const AnimatedTextInput = Animated.createAnimatedComponent(TextInput) + +// adapted from https://github.com/software-mansion/react-native-reanimated/blob/Reanimated2/src/reanimated2/PropAdapters.ts#L57, +// as Reanimated 3 does not contain the TextInputAdapter +const TextInputAdapter = createAnimatedPropAdapter( + (props) => { + 'worklet' + const keys = Object.keys(props) + // convert text to value like RN does here: https://github.com/facebook/react-native/blob/f2c6279ca497b34d5a2bfbb6f2d33dc7a7bea02a/Libraries/Components/TextInput/TextInput.js#L878 + if (keys.includes('value')) { + props['text'] = props['value'] + delete props['value'] + } + }, + ['text'], +) + +const BaseAnimatedText = ({ + style, + text, + loading, + loadingPlaceholderText = '000.00', + ...rest +}: TextProps): JSX.Element => { + const animatedProps = useAnimatedProps( + () => { + // oxlint-disable-next-line typescript/no-unsafe-return + return { + text: text?.value, + defaultValue: text?.value, + // Here we use any because the text prop is not available in the type + // oxlint-disable-next-line typescript/no-explicit-any -- Text prop not available in animated type definition + } as any + }, + [text], + TextInputAdapter, + ) + + if (loading) { + return ( + + + {/* Use empty input for loading shimmer height calculation (it is different + than the text component height) */} + + {/* Use the text component to properly calculate the width of the loading shimmer. + An input component with a width dependent on the length of the content was sometimes + rendered with a very small width regardless of the text passed as a value */} + {loadingPlaceholderText} + + + ) + } + + return ( + + ) +} +// end of forked from https://github.com/wcandillon/react-native-redash/blob/master/src/ReText.tsx + +// gives you tamagui props with reanimated support +/** + * @deprecated Prefer + * + * See: https://tamagui.dev/docs/core/animations + * + * TODO(MOB-1948): Remove this + * */ +export const AnimatedText = ({ style, ...propsIn }: TextProps): JSX.Element => { + const variant = propsIn.variant ?? 'body2' + const [props, textStyles] = usePropsAndStyle( + { + variant, + ...propsIn, + }, + { + forComponent: TextFrame, + }, + ) + + const { fontScale } = useWindowDimensions() + const enableFontScaling = fontScale > 1 + const multiplier = fonts[variant].maxFontSizeMultiplier + + return ( + + ) +} + +const styles = StyleSheet.create({ + input: { + padding: 0, // inputs have default padding on Android + }, + loadingInput: { + marginHorizontal: 0, + opacity: 0, + paddingHorizontal: 0, + width: 0, + }, + loadingPlaceholder: { + opacity: 0, + }, +}) diff --git a/apps/mobile/src/components/text/DecimalNumber.test.tsx b/apps/mobile/src/components/text/DecimalNumber.test.tsx new file mode 100644 index 00000000..85c77a6f --- /dev/null +++ b/apps/mobile/src/components/text/DecimalNumber.test.tsx @@ -0,0 +1,21 @@ +import React from 'react' +import { DecimalNumber } from 'src/components/text/DecimalNumber' +import { render } from 'src/test/test-utils' + +it('renders a DecimalNumber', () => { + const tree = render() + + expect(tree).toMatchSnapshot() +}) + +it('renders a DecimalNumber without a comma separator', () => { + const tree = render() + + expect(tree).toMatchSnapshot() +}) + +it('renders a DecimalNumber without a decimal part', () => { + const tree = render() + + expect(tree).toMatchSnapshot() +}) diff --git a/apps/mobile/src/components/text/DecimalNumber.tsx b/apps/mobile/src/components/text/DecimalNumber.tsx new file mode 100644 index 00000000..8fc2e69a --- /dev/null +++ b/apps/mobile/src/components/text/DecimalNumber.tsx @@ -0,0 +1,40 @@ +import React from 'react' +import { Text, TextProps } from 'ui/src' +import { TextVariantTokens } from 'ui/src/theme' + +type DecimalNumberProps = TextProps & { + number?: number + formattedNumber: string + separator?: string + variant: TextVariantTokens + loading?: boolean + decimalThreshold?: number // below this value (not including) decimal part would have wholePartColor too +} + +// Utility component to display decimal numbers where the decimal part +// is dimmed +export function DecimalNumber({ + loading = false, + number, + formattedNumber, + separator = '.', + variant, + decimalThreshold = 1, + ...rest +}: DecimalNumberProps): JSX.Element { + const [pre, post] = formattedNumber.split(separator) + + const decimalPartColor = number === undefined || number >= decimalThreshold ? '$neutral3' : '$neutral1' + + return ( + + {pre} + {post && ( + + {separator} + {post} + + )} + + ) +} diff --git a/apps/mobile/src/components/text/LongMarkdownText.test.tsx b/apps/mobile/src/components/text/LongMarkdownText.test.tsx new file mode 100644 index 00000000..4afabf38 --- /dev/null +++ b/apps/mobile/src/components/text/LongMarkdownText.test.tsx @@ -0,0 +1,155 @@ +import React from 'react' +import { MarkdownProps } from 'react-native-markdown-display' +import { ReactTestInstance } from 'react-test-renderer' +import { LongMarkdownText } from 'src/components/text/LongMarkdownText' +import { fireEvent, render, within } from 'src/test/test-utils' +import { fonts } from 'ui/src/theme' +import { TestID } from 'uniswap/src/test/fixtures/testIDs' + +const TEXT_VARIANT = 'body2' +const LINE_HEIGHT = fonts[TEXT_VARIANT].lineHeight + +const SHORT_TEXT = 'Short text' +const LONG_TEXT = 'Some very long text' + +jest.mock('react-native-markdown-display', () => { + const Markdown = jest.requireActual('react-native-markdown-display').default + + return { + __esModule: true, // this property makes Markdown renderering work in the es module + default: jest.fn().mockImplementation((props: MarkdownProps) => ), + } +}) + +const fireLayoutEvent = (instance: ReactTestInstance, lines: number): void => { + const height = lines * LINE_HEIGHT + + fireEvent(instance, 'layout', { + nativeEvent: { + layout: { + height, + width: 100, + }, + }, + }) +} + +const renderMarkdown = (text: string): ReturnType => + render() + +const measureMarkdown = (tree: ReturnType, numberOfLines: number): void => { + const markdownWrapperInstance = tree.getByTestId('markdown-wrapper') + fireLayoutEvent(markdownWrapperInstance, numberOfLines) +} + +const getMarkdownPropsWithHeight = (height: number | 'auto'): any => + expect.objectContaining({ + style: expect.objectContaining({ + body: expect.objectContaining({ + height, // height auto means the text doesn't exceed the limit + }), + }), + }) + +describe(LongMarkdownText, () => { + const MockedMarkdown = require('react-native-markdown-display').default as jest.Mock + + it('renders without error', () => { + const tree = renderMarkdown(LONG_TEXT) + + expect(tree).toMatchSnapshot() + }) + + describe('short text not exceeding the limit', () => { + it('shows the entire text', () => { + const tree = renderMarkdown(SHORT_TEXT) + measureMarkdown(tree, 1) // Assume Short text is one line + + // props are at index 0, ref is at index 1 + expect(MockedMarkdown.mock.lastCall[0]).toEqual( + getMarkdownPropsWithHeight('auto'), // height auto means the text doesn't exceed the limit + ) + }) + + it('does not display the "read more" button', () => { + const tree = renderMarkdown(SHORT_TEXT) + measureMarkdown(tree, 1) // Assume Short text is one line + + const readMoreButton = tree.queryByTestId(TestID.ReadMoreButton) + + expect(readMoreButton).toBeNull() + }) + }) + + describe('long text exceeding the limit', () => { + describe('when the text is not expanded', () => { + it('limits the number of visible lines', () => { + const tree = renderMarkdown(LONG_TEXT) + + measureMarkdown(tree, 5) // Assume Some very long text is five lines + + expect(MockedMarkdown.mock.lastCall[0]).toEqual( + getMarkdownPropsWithHeight(LINE_HEIGHT * 3), // Height is limited to 3 lines + ) + }) + + it('displays the "read more" button', () => { + const tree = renderMarkdown(LONG_TEXT) + + measureMarkdown(tree, 5) // Assume Some very long text is five lines + + const readMoreButton = tree.queryByTestId(TestID.ReadMoreButton) + + expect(readMoreButton).toBeTruthy() + expect(within(readMoreButton!).getByText('Read more')).toBeTruthy() + }) + }) + + describe('when the text is expanded', () => { + it('shows the entire text', () => { + const tree = renderMarkdown(LONG_TEXT) + + measureMarkdown(tree, 5) // Assume Some very long text is five lines + + const readMoreButton = tree.getByTestId(TestID.ReadMoreButton) + fireEvent.press(readMoreButton) + + expect(MockedMarkdown.mock.lastCall[0]).toEqual( + getMarkdownPropsWithHeight('auto'), // height auto means the text doesn't exceed the limit + ) + }) + + it('displays the "read less" button', () => { + const tree = renderMarkdown(LONG_TEXT) + + measureMarkdown(tree, 5) // Assume Some very long text is five lines + + const readMoreButton = tree.getByTestId(TestID.ReadMoreButton) + fireEvent.press(readMoreButton) + + expect(readMoreButton).toBeTruthy() + + expect(within(readMoreButton!).getByText('Read less')).toBeTruthy() + }) + }) + + it('toggles the text when the "read more/less" button is pressed', () => { + const tree = renderMarkdown(LONG_TEXT) + + measureMarkdown(tree, 5) // Assume Some very long text is five lines + + const readMoreButton = tree.getByTestId(TestID.ReadMoreButton) + fireEvent.press(readMoreButton) // expand + + expect(MockedMarkdown.mock.lastCall[0]).toEqual( + getMarkdownPropsWithHeight('auto'), // height auto means the text doesn't exceed the limit + ) + + fireEvent.press(readMoreButton) // collapse + + expect(MockedMarkdown.mock.lastCall[0]).toEqual( + getMarkdownPropsWithHeight(LINE_HEIGHT * 3), // Height is limited to 3 lines + ) + }) + }) +}) diff --git a/apps/mobile/src/components/text/LongMarkdownText.tsx b/apps/mobile/src/components/text/LongMarkdownText.tsx new file mode 100644 index 00000000..aa8fdb42 --- /dev/null +++ b/apps/mobile/src/components/text/LongMarkdownText.tsx @@ -0,0 +1,129 @@ +import React, { useCallback, useReducer, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { LayoutChangeEvent } from 'react-native' +import Markdown, { MarkdownProps } from 'react-native-markdown-display' +import { Flex, SpaceTokens, Text, useSporeColors } from 'ui/src' +import { fonts } from 'ui/src/theme' +import { TestID } from 'uniswap/src/test/fixtures/testIDs' +import { openUri } from 'uniswap/src/utils/linking' + +type LongMarkdownTextProps = { + initialDisplayedLines?: number + text: string + gap?: SpaceTokens + color?: string + linkColor?: string + codeBackgroundColor?: string + readMoreOrLessColor?: string + variant?: keyof typeof fonts +} + +export function LongMarkdownText(props: LongMarkdownTextProps): JSX.Element { + const colors = useSporeColors() + const { t } = useTranslation() + const { + initialDisplayedLines = 3, + text, + gap = '$spacing8', + color = colors.neutral1.val, + linkColor = colors.neutral2.val, + readMoreOrLessColor = colors.neutral2.val, + codeBackgroundColor = colors.surface3.val, + variant = 'body2', + } = props + + const [expanded, toggleExpanded] = useReducer((isExpanded) => !isExpanded, true) + const [textLengthExceedsLimit, setTextLengthExceedsLimit] = useState(false) + const [textLineHeight, setTextLineHeight] = useState(fonts[variant].lineHeight) + const initialContentHeightRef = useRef(undefined) + const maxVisibleHeight = textLineHeight * initialDisplayedLines + + const onMarkdownLayout = useCallback( + (event: LayoutChangeEvent) => { + if (initialContentHeightRef.current !== undefined) { + return + } + const textContentHeight = event.nativeEvent.layout.height + const currentLines = Math.floor(textContentHeight / textLineHeight) + setTextLengthExceedsLimit(currentLines > initialDisplayedLines) + toggleExpanded() + initialContentHeightRef.current = textContentHeight + }, + [initialDisplayedLines, textLineHeight], + ) + + const codeStyle = { backgroundColor: codeBackgroundColor, borderColor: 'transparent' } + + const markdownStyle: MarkdownProps['style'] = { + body: { + color, + fontFamily: fonts[variant].family, + overflow: 'hidden', + height: !textLengthExceedsLimit || expanded ? 'auto' : maxVisibleHeight, + }, + code_inline: codeStyle, + fence: codeStyle, + code_block: codeStyle, + link: { color: linkColor }, + paragraph: { + marginBottom: 0, + marginTop: 0, + fontSize: fonts.body2.fontSize, + lineHeight: textLineHeight, + }, + } + + return ( + + + {/* Render fake one-line markdown to properly measure the height of a single text line */} + { + setTextLineHeight(height) + }} + > + + + { + // add our own custom link handler since it has security checks that only open http/https links + openUri({ uri }).catch(() => undefined) + return false + }} + // HACK: children prop no in TS definition + {...{ children: text }} + /> + + + {/* Text is removed vs hidden using opacity to ensure spacing after the element is consistent in all cases. + This will cause mild thrash as data loads into a page but will ensure consistent spacing */} + {textLengthExceedsLimit ? ( + + {expanded ? t('common.longText.button.less') : t('common.longText.button.more')} + + ) : null} + + ) +} diff --git a/apps/mobile/src/components/text/LongText.test.tsx b/apps/mobile/src/components/text/LongText.test.tsx new file mode 100644 index 00000000..670659fb --- /dev/null +++ b/apps/mobile/src/components/text/LongText.test.tsx @@ -0,0 +1,118 @@ +import React from 'react' +import { ReactTestInstance } from 'react-test-renderer' +import { LongText } from 'src/components/text/LongText' +import { fireEvent, render, within } from 'src/test/test-utils' +import { TestID } from 'uniswap/src/test/fixtures/testIDs' + +const SHORT_TEXT = 'Short text' +const LONG_TEXT = 'Some very long text' + +const fireTextLayoutEvent = (instance: ReactTestInstance, lines: number): void => { + fireEvent(instance, 'textLayout', { + nativeEvent: { + lines: Array.from({ length: lines }).map(() => ({ + width: 100, + height: 20, + ascender: 20, + })), + }, + }) +} + +describe(LongText, () => { + it('renders without error', () => { + const tree = render() + + expect(tree).toMatchSnapshot() + }) + + describe('short text not exceeding the limit', () => { + it('shows the entire text', () => { + const tree = render() + + const textInstance = tree.getByText(SHORT_TEXT) + expect(textInstance.props['numberOfLines']).toBeUndefined() + + fireTextLayoutEvent(textInstance, 1) // Assume Short text is one line + + // the number of lines will be the same as the initialDisplayedLines + expect(textInstance.props['numberOfLines']).toBe(3) + }) + + it('does not display the "read more" button', () => { + const tree = render() + + fireTextLayoutEvent(tree.getByText(SHORT_TEXT), 1) // Assume Short text is one line + const readMoreButton = tree.queryByTestId(TestID.ReadMoreButton) + + expect(readMoreButton).toBeNull() + }) + }) + + describe('long text exceeding the limit', () => { + describe('when the text is not expanded', () => { + it('limits the number of visible lines', () => { + const tree = render() + + const textInstance = tree.getByText(LONG_TEXT) + fireTextLayoutEvent(textInstance, 5) // Assume Some very long text is five lines + + expect(textInstance.props['numberOfLines']).toBe(3) + }) + + it('displays the "read more" button', () => { + const tree = render() + + fireTextLayoutEvent(tree.getByText(LONG_TEXT), 5) // Assume Some very long text is five lines + const readMoreButton = tree.queryByTestId(TestID.ReadMoreButton) + + expect(readMoreButton).toBeTruthy() + expect(within(readMoreButton!).getByText('Read more')).toBeTruthy() + }) + }) + + describe('when the text is expanded', () => { + it('shows the entire text', () => { + const tree = render() + + const textInstance = tree.getByText(LONG_TEXT) + fireTextLayoutEvent(textInstance, 5) // Assume Some very long text is five lines + + expect(textInstance.props['numberOfLines']).toBe(3) + + const readMoreButton = tree.getByTestId(TestID.ReadMoreButton) + fireEvent.press(readMoreButton) + + expect(textInstance.props['numberOfLines']).toBeUndefined() + }) + + it('displays the "read less" button', () => { + const tree = render() + + fireTextLayoutEvent(tree.getByText(LONG_TEXT), 5) // Assume Some very long text is five lines + const readMoreButton = tree.getByTestId(TestID.ReadMoreButton) + fireEvent.press(readMoreButton) + + expect(within(readMoreButton).getByText('Read less')).toBeTruthy() + }) + }) + + it('toggles the text when the "read more/less" button is pressed', () => { + const tree = render() + + fireTextLayoutEvent(tree.getByText(LONG_TEXT), 5) // Assume Some very long text is five lines + const readMoreButton = tree.getByTestId(TestID.ReadMoreButton) + fireEvent.press(readMoreButton) // expand + + expect(tree.getByText(LONG_TEXT).props['numberOfLines']).toBeUndefined() + + fireEvent.press(readMoreButton) // collapse + + expect(tree.getByText(LONG_TEXT).props['numberOfLines']).toBe(3) + + fireEvent.press(readMoreButton) // expand + + expect(tree.getByText(LONG_TEXT).props['numberOfLines']).toBeUndefined() + }) + }) +}) diff --git a/apps/mobile/src/components/text/LongText.tsx b/apps/mobile/src/components/text/LongText.tsx new file mode 100644 index 00000000..171e17af --- /dev/null +++ b/apps/mobile/src/components/text/LongText.tsx @@ -0,0 +1,77 @@ +import React, { ComponentProps, useCallback, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { NativeSyntheticEvent, TextLayoutEventData } from 'react-native' +import { Flex, SpaceTokens, Text, useSporeColors } from 'ui/src' +import { fonts } from 'ui/src/theme' +import { TestID } from 'uniswap/src/test/fixtures/testIDs' + +type LongTextProps = { + initialDisplayedLines?: number + text: string + gap?: SpaceTokens + color?: string + linkColor?: string + codeBackgroundColor?: string + readMoreOrLessColor?: string + variant?: keyof typeof fonts +} & Omit, 'children' | 'numberOfLines' | 'onTextLayout' | 'color' | 'variant'> + +export function LongText(props: LongTextProps): JSX.Element { + const colors = useSporeColors() + const { t } = useTranslation() + const { + initialDisplayedLines = 3, + text, + gap = '$spacing8', + color = colors.neutral1.val, + readMoreOrLessColor = colors.neutral2.val, + variant = 'body2', + ...rest + } = props + + const [expanded, setExpanded] = useState(true) + const [textLengthExceedsLimit, setTextLengthExceedsLimit] = useState(false) + const isInitializedRef = useRef(false) + + const onTextLayout = useCallback( + (e: NativeSyntheticEvent) => { + // Only needs to measure full number of lines once + if (isInitializedRef.current) { + return + } + setTextLengthExceedsLimit(e.nativeEvent.lines.length > initialDisplayedLines) + setExpanded(false) + isInitializedRef.current = true + }, + [initialDisplayedLines], + ) + + return ( + + + {text} + + + {/* Text is removed vs hidden using opacity to ensure spacing after the element is consistent in all cases. + This will cause mild thrash as data loads into a page but will ensure consistent spacing */} + {textLengthExceedsLimit ? ( + setExpanded(!expanded)} + > + {expanded ? t('common.longText.button.less') : t('common.longText.button.more')} + + ) : null} + + ) +} diff --git a/apps/mobile/src/components/text/TextWithFuseMatches.test.tsx b/apps/mobile/src/components/text/TextWithFuseMatches.test.tsx new file mode 100644 index 00000000..11a4d625 --- /dev/null +++ b/apps/mobile/src/components/text/TextWithFuseMatches.test.tsx @@ -0,0 +1,21 @@ +import React from 'react' +import { TextWithFuseMatches } from 'src/components/text/TextWithFuseMatches' +import { render } from 'src/test/test-utils' + +it('renders text without matches', () => { + const tree = render() + expect(tree).toMatchSnapshot() +}) + +it('renders text with few matches', () => { + const tree = render( + , + ) + expect(tree).toMatchSnapshot() +}) diff --git a/apps/mobile/src/components/text/TextWithFuseMatches.tsx b/apps/mobile/src/components/text/TextWithFuseMatches.tsx new file mode 100644 index 00000000..d561b11d --- /dev/null +++ b/apps/mobile/src/components/text/TextWithFuseMatches.tsx @@ -0,0 +1,71 @@ +import Fuse from 'fuse.js' +import React from 'react' +import { Flex, Text, TextProps } from 'ui/src' +import { TextVariantTokens } from 'ui/src/theme' + +interface TextWithFuseMatchesProps { + text: string + matches?: readonly Fuse.FuseResultMatch[] + variant?: TextVariantTokens + numberOfLines?: Pick +} + +export function TextWithFuseMatches({ + matches, + text, + variant = 'body1', + numberOfLines = 1, +}: TextWithFuseMatchesProps & TextProps): JSX.Element { + if (!matches || matches.length === 0) { + return ( + + {text} + + ) + } + + const charIsMatch = new Set() + for (const match of matches) { + for (const index of match.indices) { + for (let i = index[0]; i < index[1] + 1; i++) { + charIsMatch.add(i) + } + } + } + + // PERF: batch pieces? + const pieces = [] + for (let i = 0; i < text.length; i++) { + if (charIsMatch.has(i)) { + pieces.push([text[i], true]) + } else { + pieces.push([text[i], false]) + } + } + + const elements = ( + <> + {pieces.map((p, i) => { + if (p[1]) { + return ( + + {p[0]} + + ) + } else { + return ( + + {p[0]} + + ) + } + })} + + ) + + return ( + + {elements} + + ) +} diff --git a/apps/mobile/src/components/text/__snapshots__/AnimatedText.test.tsx.snap b/apps/mobile/src/components/text/__snapshots__/AnimatedText.test.tsx.snap new file mode 100644 index 00000000..1ed25043 --- /dev/null +++ b/apps/mobile/src/components/text/__snapshots__/AnimatedText.test.tsx.snap @@ -0,0 +1,54 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`AnimatedText renders without error 1`] = ` + +`; diff --git a/apps/mobile/src/components/text/__snapshots__/DecimalNumber.test.tsx.snap b/apps/mobile/src/components/text/__snapshots__/DecimalNumber.test.tsx.snap new file mode 100644 index 00000000..7def4eb8 --- /dev/null +++ b/apps/mobile/src/components/text/__snapshots__/DecimalNumber.test.tsx.snap @@ -0,0 +1,92 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders a DecimalNumber 1`] = ` + + 14,123 + + . + 78 + + +`; + +exports[`renders a DecimalNumber without a comma separator 1`] = ` + + 14 + + , + 23 + + +`; + +exports[`renders a DecimalNumber without a decimal part 1`] = ` + + 14,123 + +`; diff --git a/apps/mobile/src/components/text/__snapshots__/LongMarkdownText.test.tsx.snap b/apps/mobile/src/components/text/__snapshots__/LongMarkdownText.test.tsx.snap new file mode 100644 index 00000000..3f8c4536 --- /dev/null +++ b/apps/mobile/src/components/text/__snapshots__/LongMarkdownText.test.tsx.snap @@ -0,0 +1,119 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`LongMarkdownText renders without error 1`] = ` + + + + + + + + . + + + + + + + + + + Some very long text + + + + + + +`; diff --git a/apps/mobile/src/components/text/__snapshots__/LongText.test.tsx.snap b/apps/mobile/src/components/text/__snapshots__/LongText.test.tsx.snap new file mode 100644 index 00000000..fde29aea --- /dev/null +++ b/apps/mobile/src/components/text/__snapshots__/LongText.test.tsx.snap @@ -0,0 +1,30 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`LongText renders without error 1`] = ` + + + Some very long text + + +`; diff --git a/apps/mobile/src/components/text/__snapshots__/TextWithFuseMatches.test.tsx.snap b/apps/mobile/src/components/text/__snapshots__/TextWithFuseMatches.test.tsx.snap new file mode 100644 index 00000000..f5750a3f --- /dev/null +++ b/apps/mobile/src/components/text/__snapshots__/TextWithFuseMatches.test.tsx.snap @@ -0,0 +1,385 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders text with few matches 1`] = ` + + + A + + + + + + t + + + e + + + x + + + t + + + + + + w + + + i + + + t + + + h + + + o + + + u + + + t + + + + + + m + + + a + + + t + + + c + + + h + + + e + + + s + + +`; + +exports[`renders text without matches 1`] = ` + + A text without matches + +`; diff --git a/apps/mobile/src/components/text/useRegionalizedLineHeight.tsx b/apps/mobile/src/components/text/useRegionalizedLineHeight.tsx new file mode 100644 index 00000000..565506e6 --- /dev/null +++ b/apps/mobile/src/components/text/useRegionalizedLineHeight.tsx @@ -0,0 +1,9 @@ +import { Language } from 'uniswap/src/features/language/constants' +import { useCurrentLanguage } from 'uniswap/src/features/language/hooks' + +// For special Vietnamese characters that extend beyond the text frame, +// we do not apply line height to avoid truncating the text. +export function useRegionalizedLineHeight(): 'unset' | undefined { + const currentLanguage = useCurrentLanguage() + return currentLanguage === Language.Vietnamese ? 'unset' : undefined +} diff --git a/apps/mobile/src/components/tokens/TokenMetadata.tsx b/apps/mobile/src/components/tokens/TokenMetadata.tsx new file mode 100644 index 00000000..ec5fcaed --- /dev/null +++ b/apps/mobile/src/components/tokens/TokenMetadata.tsx @@ -0,0 +1,18 @@ +import React, { PropsWithChildren } from 'react' +import { FlexAlignType } from 'react-native' +import { Flex } from 'ui/src' + +type TokenMetadataProps = PropsWithChildren<{ + align?: FlexAlignType +}> + +/** Helper component to format rhs metadata for a given token. */ +export const TokenMetadata = ({ children, align = 'flex-end' }: TokenMetadataProps): JSX.Element => { + return ( + + + {children} + + + ) +} diff --git a/apps/mobile/src/components/unitags/UnitagBanner.tsx b/apps/mobile/src/components/unitags/UnitagBanner.tsx new file mode 100644 index 00000000..1b882a37 --- /dev/null +++ b/apps/mobile/src/components/unitags/UnitagBanner.tsx @@ -0,0 +1,157 @@ +import React, { useCallback } from 'react' +import { Trans, useTranslation } from 'react-i18next' +import { navigate } from 'src/app/navigation/rootNavigation' +import { Flex, Image, Text, TouchableArea, TouchableAreaProps, useIsDarkMode, useIsShortMobileDevice } from 'ui/src' +import { UNITAGS_BANNER_VERTICAL_DARK, UNITAGS_BANNER_VERTICAL_LIGHT } from 'ui/src/assets' +import { useDeviceDimensions } from 'ui/src/hooks/useDeviceDimensions' +import { iconSizes } from 'ui/src/theme' +import { ModalName } from 'uniswap/src/features/telemetry/constants' +import { UNITAG_SUFFIX_NO_LEADING_DOT } from 'uniswap/src/features/unitags/constants' +import { TestID } from 'uniswap/src/test/fixtures/testIDs' +import { MobileScreens, UnitagScreens } from 'uniswap/src/types/screens/mobile' +import { dismissNativeKeyboard } from 'utilities/src/device/keyboard/dismissNativeKeyboard' +import { useUnitagClaimHandler } from 'wallet/src/features/unitags/useUnitagClaimHandler' + +const IMAGE_ASPECT_RATIO = 0.42 +const IMAGE_SCREEN_WIDTH_PROPORTION = 0.18 +const COMPACT_IMAGE_SCREEN_WIDTH_PROPORTION = 0.15 + +export function UnitagBanner({ + address, + compact, + entryPoint, + onPressClaim, +}: { + address: Address + compact?: boolean + entryPoint: MobileScreens.Home | MobileScreens.Settings + onPressClaim?: () => void +}): JSX.Element { + const { t } = useTranslation() + const { fullWidth } = useDeviceDimensions() + const isDarkMode = useIsDarkMode() + const isShortDevice = useIsShortMobileDevice() + + const imageWidth = compact + ? COMPACT_IMAGE_SCREEN_WIDTH_PROPORTION * fullWidth + : IMAGE_SCREEN_WIDTH_PROPORTION * fullWidth + const imageHeight = imageWidth / IMAGE_ASPECT_RATIO + const analyticsEntryPoint = entryPoint === MobileScreens.Home ? 'home' : 'settings' + + const navigateToClaim = useCallback(() => { + navigate(MobileScreens.UnitagStack, { + screen: UnitagScreens.ClaimUnitag, + params: { + entryPoint: MobileScreens.Home, + address, + }, + }) + }, [address]) + + const navigateToIntro = useCallback(() => { + navigate(ModalName.UnitagsIntro, { + address, + entryPoint: MobileScreens.Home, + }) + }, [address]) + + const { handleClaim, handleDismiss } = useUnitagClaimHandler({ + analyticsEntryPoint, + navigateToClaim, + navigateToIntro, + }) + + const onPressClaimNow = (): void => { + if (onPressClaim) { + onPressClaim() + } + dismissNativeKeyboard() + handleClaim() + } + + const baseButtonStyle: TouchableAreaProps = { + backgroundColor: '$accent1', + borderRadius: '$rounded20', + justifyContent: 'center', + height: iconSizes.icon36, + py: '$spacing8', + px: '$spacing12', + } + + return ( + + {compact ? ( + + + }} + i18nKey="unitags.banner.title.compact" + values={{ unitagDomain: UNITAG_SUFFIX_NO_LEADING_DOT }} + /> + + + ) : ( + + + + {t('unitags.banner.title.full', { + unitagDomain: UNITAG_SUFFIX_NO_LEADING_DOT, + })} + + {!isShortDevice && ( + + {t('unitags.banner.subtitle')} + + )} + + + {/* TODO: replace with Button when it's extensible enough to accommodate designs */} + + + {t('unitags.banner.button.claim')} + + + handleDismiss()} + > + + {t('common.button.later')} + + + + + )} + + + + + ) +} diff --git a/apps/mobile/src/components/unitags/UnitagsIntroModal.tsx b/apps/mobile/src/components/unitags/UnitagsIntroModal.tsx new file mode 100644 index 00000000..edbfdc64 --- /dev/null +++ b/apps/mobile/src/components/unitags/UnitagsIntroModal.tsx @@ -0,0 +1,87 @@ +import 'react-native-reanimated' +import { SharedEventName } from '@uniswap/analytics-events' +import React from 'react' +import { useTranslation } from 'react-i18next' +import { useDispatch } from 'react-redux' +import { navigate } from 'src/app/navigation/rootNavigation' +import { AppStackScreenProp } from 'src/app/navigation/types' +import { useReactNavigationModal } from 'src/components/modals/useReactNavigationModal' +import { TermsOfService } from 'src/screens/Onboarding/TermsOfService' +import { Button, Flex, GeneratedIcon, Image, Text, useIsDarkMode } from 'ui/src' +import { UNITAGS_INTRO_BANNER_DARK, UNITAGS_INTRO_BANNER_LIGHT } from 'ui/src/assets' +import { Lightning, Person, Ticket } from 'ui/src/components/icons' +import { Modal } from 'uniswap/src/components/modals/Modal' +import { ModalName } from 'uniswap/src/features/telemetry/constants' +import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { MobileScreens, UnitagScreens } from 'uniswap/src/types/screens/mobile' +import { setHasCompletedUnitagsIntroModal } from 'wallet/src/features/behaviorHistory/slice' + +export function UnitagsIntroModal({ route }: AppStackScreenProp): JSX.Element { + const { t } = useTranslation() + const isDarkMode = useIsDarkMode() + const appDispatch = useDispatch() + const { onClose } = useReactNavigationModal() + + const params = route.params + const address = params.address + const entryPoint = params.entryPoint + + const onPressClaimOneNow = (): void => { + appDispatch(setHasCompletedUnitagsIntroModal(true)) + onClose() + navigate(MobileScreens.UnitagStack, { + screen: UnitagScreens.ClaimUnitag, + params: { + entryPoint, + address, + }, + }) + if (address) { + sendAnalyticsEvent(SharedEventName.TERMS_OF_SERVICE_ACCEPTED, { address }) + } + } + + return ( + + + + {t('unitags.intro.title')} + + {t('unitags.intro.subtitle')} + + + + + + + + + + + + + + + + + + + ) +} + +function BodyItem({ Icon, title }: { Icon: GeneratedIcon; title: string }): JSX.Element { + return ( + + + + {title} + + + ) +} diff --git a/apps/mobile/src/components/unitags/UnitagsIntroModalState.ts b/apps/mobile/src/components/unitags/UnitagsIntroModalState.ts new file mode 100644 index 00000000..5d397c3b --- /dev/null +++ b/apps/mobile/src/components/unitags/UnitagsIntroModalState.ts @@ -0,0 +1,6 @@ +import { MobileScreens } from 'uniswap/src/types/screens/mobile' + +export interface UnitagsIntroModalState { + address: Address + entryPoint: MobileScreens.Home | MobileScreens.Settings +} diff --git a/apps/mobile/src/env.d.ts b/apps/mobile/src/env.d.ts new file mode 100644 index 00000000..a7c89fb9 --- /dev/null +++ b/apps/mobile/src/env.d.ts @@ -0,0 +1,12 @@ +import { config, TamaguiGroupNames } from 'ui/src/tamagui.config' + +type Conf = typeof config + +declare module 'tamagui' { + // oxlint-disable-next-line typescript/no-empty-interface + interface TamaguiCustomConfig extends Conf {} + + interface TypeOverride { + groupNames(): TamaguiGroupNames + } +} diff --git a/apps/mobile/src/features/CloudBackup/CloudBackupForm/CloudBackupPassword.ts b/apps/mobile/src/features/CloudBackup/CloudBackupForm/CloudBackupPassword.ts new file mode 100644 index 00000000..479ff313 --- /dev/null +++ b/apps/mobile/src/features/CloudBackup/CloudBackupForm/CloudBackupPassword.ts @@ -0,0 +1,9 @@ +import { CloudBackupPasswordFormContextProvider } from 'src/features/CloudBackup/CloudBackupForm/CloudBackupPasswordFormContext' +import { ContinueButton } from 'src/features/CloudBackup/CloudBackupForm/ContinueButton' +import { CloudPasswordInput } from 'src/features/CloudBackup/CloudBackupForm/PasswordInput' + +export const CloudBackupPassword = { + PasswordInput: CloudPasswordInput, + ContinueButton, + FormProvider: CloudBackupPasswordFormContextProvider, +} diff --git a/apps/mobile/src/features/CloudBackup/CloudBackupForm/CloudBackupPasswordFormContext.tsx b/apps/mobile/src/features/CloudBackup/CloudBackupForm/CloudBackupPasswordFormContext.tsx new file mode 100644 index 00000000..b949e75f --- /dev/null +++ b/apps/mobile/src/features/CloudBackup/CloudBackupForm/CloudBackupPasswordFormContext.tsx @@ -0,0 +1,127 @@ +import { createContext, PropsWithChildren, useCallback, useContext, useMemo, useState } from 'react' +import { dismissNativeKeyboard } from 'utilities/src/device/keyboard/dismissNativeKeyboard' +import { + getPasswordStrength, + isPasswordStrongEnough, + PasswordErrors, + PasswordStrength, +} from 'wallet/src/utils/password' + +type CloudBackupPasswordFormContextType = { + password: string + passwordStrength: PasswordStrength + error: PasswordErrors | undefined + isConfirmation: boolean + isInputValid: boolean + onPressNext: () => void + onPasswordSubmitEditing: () => void + onPasswordChangeText: (newPassword: string) => void +} + +const CloudBackupPasswordFormContext = createContext(null) + +export function useCloudBackupPasswordFormContext(): CloudBackupPasswordFormContextType { + const context = useContext(CloudBackupPasswordFormContext) + + if (!context) { + throw new Error('useCloudBackupPasswordFormContext must be used within a CloudBackupPasswordFormContextProvider') + } + + return context +} + +type CloudBackupPasswordFormContextProviderProps = PropsWithChildren<{ + isConfirmation?: boolean + passwordToConfirm?: string + checkPasswordBeforeSubmit?: boolean + navigateToNextScreen: ({ password }: { password: string }) => void +}> + +export function CloudBackupPasswordFormContextProvider({ + children, + isConfirmation = false, + passwordToConfirm, + checkPasswordBeforeSubmit = false, + navigateToNextScreen, +}: CloudBackupPasswordFormContextProviderProps): JSX.Element { + const [password, setPassword] = useState('') + const [error, setError] = useState(undefined) + const [passwordStrength, setPasswordStrength] = useState(PasswordStrength.NONE) + + const isStrongPassword = isPasswordStrongEnough({ + minStrength: PasswordStrength.MEDIUM, + currentStrength: passwordStrength, + }) + + const matchesPassword = password === passwordToConfirm + const isInputValid = + !error && + password.length > 0 && + (isConfirmation || isStrongPassword) && + (!checkPasswordBeforeSubmit || matchesPassword) + + const onPasswordChangeText = useCallback( + (newPassword: string): void => { + if (isConfirmation) { + setError(undefined) + } + // always reset error if not confirmation + if (!isConfirmation) { + setPasswordStrength(getPasswordStrength(newPassword)) + setError(undefined) + } + setPassword(newPassword) + }, + [isConfirmation], + ) + + const onPasswordSubmitEditing = useCallback((): void => { + if (!isConfirmation && !isStrongPassword) { + return + } + if (isConfirmation && !matchesPassword) { + setError(PasswordErrors.PasswordsDoNotMatch) + return + } + setError(undefined) + dismissNativeKeyboard() + }, [isConfirmation, isStrongPassword, matchesPassword]) + + const onPressNext = useCallback((): void => { + if (isConfirmation && !matchesPassword) { + setError(PasswordErrors.PasswordsDoNotMatch) + return + } + + if (!error) { + navigateToNextScreen({ password }) + } + }, [error, isConfirmation, matchesPassword, navigateToNextScreen, password]) + + const contextValue = useMemo( + () => ({ + password, + passwordStrength, + error, + isConfirmation, + isInputValid, + onPressNext, + onPasswordChangeText, + onPasswordSubmitEditing, + }), + [ + error, + passwordStrength, + isConfirmation, + isInputValid, + onPressNext, + onPasswordChangeText, + onPasswordSubmitEditing, + password, + ], + ) + + return ( + {children} + ) +} diff --git a/apps/mobile/src/features/CloudBackup/CloudBackupForm/ContinueButton.tsx b/apps/mobile/src/features/CloudBackup/CloudBackupForm/ContinueButton.tsx new file mode 100644 index 00000000..158797ea --- /dev/null +++ b/apps/mobile/src/features/CloudBackup/CloudBackupForm/ContinueButton.tsx @@ -0,0 +1,18 @@ +import { useTranslation } from 'react-i18next' +import { useCloudBackupPasswordFormContext } from 'src/features/CloudBackup/CloudBackupForm/CloudBackupPasswordFormContext' +import { Button, Flex } from 'ui/src' +import { TestID } from 'uniswap/src/test/fixtures/testIDs' + +export function ContinueButton({ onPressContinue }: { onPressContinue?: () => void }): JSX.Element { + const { isInputValid, onPressNext } = useCloudBackupPasswordFormContext() + + const { t } = useTranslation() + + return ( + + + + ) +} diff --git a/apps/mobile/src/features/CloudBackup/CloudBackupForm/PasswordInput.tsx b/apps/mobile/src/features/CloudBackup/CloudBackupForm/PasswordInput.tsx new file mode 100644 index 00000000..18b41922 --- /dev/null +++ b/apps/mobile/src/features/CloudBackup/CloudBackupForm/PasswordInput.tsx @@ -0,0 +1,83 @@ +import { useRef } from 'react' +import { useTranslation } from 'react-i18next' +import { TextInput } from 'react-native' +import { PasswordInput } from 'src/components/input/PasswordInput' +import { useCloudBackupPasswordFormContext } from 'src/features/CloudBackup/CloudBackupForm/CloudBackupPasswordFormContext' +import { PasswordError } from 'src/features/onboarding/PasswordError' +import { Flex, Text } from 'ui/src' +import { useDebounce } from 'utilities/src/time/timing' +import { + getPasswordStrengthTextAndColor, + PASSWORD_VALIDATION_DEBOUNCE_MS, + PasswordErrors, + PasswordStrength, +} from 'wallet/src/utils/password' + +export function CloudPasswordInput(): JSX.Element { + const { password, error, passwordStrength, isConfirmation, onPasswordChangeText, onPasswordSubmitEditing } = + useCloudBackupPasswordFormContext() + const debouncedPasswordStrength = useDebounce(passwordStrength, PASSWORD_VALIDATION_DEBOUNCE_MS) + + const { t } = useTranslation() + const passwordInputRef = useRef(null) + + let errorText = '' + if (error === PasswordErrors.PasswordsDoNotMatch) { + errorText = t('settings.setting.backup.password.error.mismatch') + } else if (error) { + // use the upstream zxcvbn error message + errorText = error + } + + return ( + + + { + onPasswordChangeText(newText) + }} + onSubmitEditing={onPasswordSubmitEditing} + /> + {!isConfirmation && } + {error ? : null} + + + ) +} + +function PasswordStrengthText({ strength }: { strength: PasswordStrength }): JSX.Element { + const { t } = useTranslation() + const { color } = getPasswordStrengthTextAndColor(t, strength) + + const hasPassword = strength !== PasswordStrength.NONE + let strengthText: string = '' + switch (strength) { + case PasswordStrength.STRONG: + strengthText = t('settings.setting.backup.password.strong') + break + case PasswordStrength.MEDIUM: + strengthText = t('settings.setting.backup.password.medium') + break + case PasswordStrength.WEAK: + strengthText = t('settings.setting.backup.password.weak') + break + default: + break + } + + return ( + + + {strengthText} + + + ) +} diff --git a/apps/mobile/src/features/CloudBackup/CloudBackupProcessingAnimation.tsx b/apps/mobile/src/features/CloudBackup/CloudBackupProcessingAnimation.tsx new file mode 100644 index 00000000..70110c9e --- /dev/null +++ b/apps/mobile/src/features/CloudBackup/CloudBackupProcessingAnimation.tsx @@ -0,0 +1,143 @@ +import { useFocusEffect } from '@react-navigation/core' +import { NativeStackNavigationProp } from '@react-navigation/native-stack' +import React, { useCallback, useEffect, useReducer } from 'react' +import { useTranslation } from 'react-i18next' +import { ActivityIndicator, Alert } from 'react-native' +import { useDispatch } from 'react-redux' +import { OnboardingStackParamList, SettingsStackParamList } from 'src/app/navigation/types' +import { backupMnemonicToCloudStorage } from 'src/features/CloudBackup/RNCloudStorageBackupsManager' +import { Flex, Text } from 'ui/src' +import { CheckmarkCircle } from 'ui/src/components/icons' +import { iconSizes } from 'ui/src/theme' +import { MobileScreens, OnboardingScreens } from 'uniswap/src/types/screens/mobile' +import { getCloudProviderName } from 'uniswap/src/utils/cloud-backup/getCloudProviderName' +import { logger } from 'utilities/src/logger/logger' +import { ONE_SECOND_MS } from 'utilities/src/time/time' +import { promiseMinDelay } from 'utilities/src/time/timing' +import { useOnboardingContext } from 'wallet/src/features/onboarding/OnboardingContext' +import { EditAccountAction, editAccountActions } from 'wallet/src/features/wallet/accounts/editAccountSaga' +import { BackupType } from 'wallet/src/features/wallet/accounts/types' +import { hasBackup } from 'wallet/src/features/wallet/accounts/utils' +import { useSignerAccount } from 'wallet/src/features/wallet/hooks' + +type Props = { + accountAddress: Address + password: string + onBackupComplete: () => void + onErrorPress: () => void + navigation: + | NativeStackNavigationProp + | NativeStackNavigationProp +} + +/** Screen to perform secure recovery phrase backup to Cloud */ +export function CloudBackupProcessingAnimation({ + accountAddress, + onBackupComplete, + onErrorPress, + password, + navigation, +}: Props): JSX.Element { + const { t } = useTranslation() + const dispatch = useDispatch() + const { addBackupMethod, getOnboardingOrImportedAccount } = useOnboardingContext() + const onboardingContextAccount = getOnboardingOrImportedAccount() + const activeAccount = useSignerAccount(accountAddress) + + const account = activeAccount || onboardingContextAccount + + // Compute backup status at component level to avoid stale closure - ensures fresh evaluation on each render + // when onboardingContextAccount updates with new backup state + const accountHasCloudBackup = hasBackup(BackupType.Cloud, account) + + if (!account) { + throw Error('No account available for backup') + } + + const mnemonicId = account.mnemonicId + + const [processing, doneProcessing] = useReducer(() => false, true) + + // Handle finished backing up to Cloud + useEffect(() => { + if (accountHasCloudBackup) { + doneProcessing() + // Show success state for 1s before navigating + const timer = setTimeout(onBackupComplete, ONE_SECOND_MS) + return () => clearTimeout(timer) + } + return undefined + }, [accountHasCloudBackup, onBackupComplete]) + + // Handle backup to Cloud when screen appears + const backup = useCallback(async () => { + try { + // Ensure processing state is shown for at least 1s + await promiseMinDelay(backupMnemonicToCloudStorage(mnemonicId, password), ONE_SECOND_MS) + if (activeAccount) { + dispatch( + editAccountActions.trigger({ + type: EditAccountAction.AddBackupMethod, + address: accountAddress, + backupMethod: BackupType.Cloud, + }), + ) + } else { + addBackupMethod(BackupType.Cloud) + } + } catch (error) { + logger.error(error, { + tags: { file: 'CloudBackupProcessingScreen', function: 'onPressNext' }, + }) + + Alert.alert( + t('settings.setting.backup.error.title', { cloudProviderName: getCloudProviderName() }), + t('settings.setting.backup.error.message.full', { + cloudProviderName: getCloudProviderName(), + }), + [ + { + text: t('common.button.ok'), + style: 'default', + onPress: onErrorPress, + }, + ], + ) + } + }, [accountAddress, activeAccount, addBackupMethod, dispatch, mnemonicId, onErrorPress, password, t]) + + /** + * Delays cloud backup to avoid android oauth consent screen blocking navigation transition + */ + useFocusEffect( + useCallback(() => { + return navigation.addListener('transitionEnd', async () => { + await backup() + }) + }, [backup, navigation]), + ) + + const iconSize = iconSizes.icon40 + + return processing ? ( + + + + + + {t('settings.setting.backup.status.inProgress', { + cloudProviderName: getCloudProviderName(), + })} + + + ) : ( + + + + {t('settings.setting.backup.status.complete', { + cloudProviderName: getCloudProviderName(), + })} + + + ) +} diff --git a/apps/mobile/src/features/CloudBackup/RNCloudStorageBackupsManager.ts b/apps/mobile/src/features/CloudBackup/RNCloudStorageBackupsManager.ts new file mode 100644 index 00000000..50fb4e86 --- /dev/null +++ b/apps/mobile/src/features/CloudBackup/RNCloudStorageBackupsManager.ts @@ -0,0 +1,38 @@ +import { NativeModules } from 'react-native' +import { CloudStorageMnemonicBackup } from 'src/features/CloudBackup/types' + +interface RNCloudStorageBackupsManager { + isCloudStorageAvailable: () => Promise + deleteCloudStorageMnemonicBackup: (mnemonicId: string) => Promise + getCloudBackupList: () => Promise + backupMnemonicToCloudStorage: (mnemonicId: string, password: string) => Promise + restoreMnemonicFromCloudStorage: (mnemonicId: string, password: string) => Promise +} + +declare module 'react-native' { + interface NativeModulesStatic { + RNCloudStorageBackupsManager: RNCloudStorageBackupsManager + } +} + +const { RNCloudStorageBackupsManager } = NativeModules + +export function isCloudStorageAvailable(): Promise { + return RNCloudStorageBackupsManager.isCloudStorageAvailable() +} + +export function deleteCloudStorageMnemonicBackup(mnemonicId: string): Promise { + return RNCloudStorageBackupsManager.deleteCloudStorageMnemonicBackup(mnemonicId) +} + +export function getCloudBackupList(): Promise { + return RNCloudStorageBackupsManager.getCloudBackupList() +} + +export function backupMnemonicToCloudStorage(mnemonicId: string, password: string): Promise { + return RNCloudStorageBackupsManager.backupMnemonicToCloudStorage(mnemonicId, password) +} + +export function restoreMnemonicFromCloudStorage(mnemonicId: string, password: string): Promise { + return RNCloudStorageBackupsManager.restoreMnemonicFromCloudStorage(mnemonicId, password) +} diff --git a/apps/mobile/src/features/CloudBackup/passwordLockoutSlice.ts b/apps/mobile/src/features/CloudBackup/passwordLockoutSlice.ts new file mode 100644 index 00000000..48f85f83 --- /dev/null +++ b/apps/mobile/src/features/CloudBackup/passwordLockoutSlice.ts @@ -0,0 +1,34 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit' + +// oxlint-disable-next-line import/no-unused-modules +export interface PasswordLockoutState { + passwordAttempts: number + endTime?: number +} + +export const initialPasswordLockoutState: Readonly = { + passwordAttempts: 0, +} + +const slice = createSlice({ + name: 'passwordLockout', + initialState: initialPasswordLockoutState, + reducers: { + incrementPasswordAttempts: (state) => { + state.passwordAttempts++ + }, + resetPasswordAttempts: (state) => { + state.passwordAttempts = 0 + }, + setLockoutEndTime: (state, action: PayloadAction<{ lockoutEndTime: number }>) => { + state.endTime = action.payload.lockoutEndTime + }, + resetLockoutEndTime: (state) => { + state.endTime = undefined + }, + }, +}) + +export const { incrementPasswordAttempts, resetPasswordAttempts, setLockoutEndTime, resetLockoutEndTime } = + slice.actions +export const { reducer: passwordLockoutReducer } = slice diff --git a/apps/mobile/src/features/CloudBackup/selectors.ts b/apps/mobile/src/features/CloudBackup/selectors.ts new file mode 100644 index 00000000..327caeca --- /dev/null +++ b/apps/mobile/src/features/CloudBackup/selectors.ts @@ -0,0 +1,9 @@ +import { MobileState } from 'src/app/mobileReducer' + +export const selectPasswordAttempts = (state: MobileState): number => { + return state.passwordLockout.passwordAttempts +} + +export const selectLockoutEndTime = (state: MobileState): number | undefined => { + return state.passwordLockout.endTime +} diff --git a/apps/mobile/src/features/CloudBackup/types.ts b/apps/mobile/src/features/CloudBackup/types.ts new file mode 100644 index 00000000..112cb3f7 --- /dev/null +++ b/apps/mobile/src/features/CloudBackup/types.ts @@ -0,0 +1,5 @@ +export interface CloudStorageMnemonicBackup { + mnemonicId: string + createdAt: number + googleDriveEmail?: string +} diff --git a/apps/mobile/src/features/CloudBackup/useCloudBackups.ts b/apps/mobile/src/features/CloudBackup/useCloudBackups.ts new file mode 100644 index 00000000..5a673220 --- /dev/null +++ b/apps/mobile/src/features/CloudBackup/useCloudBackups.ts @@ -0,0 +1,54 @@ +import { useCallback, useEffect, useState } from 'react' +import { getCloudBackupList } from 'src/features/CloudBackup/RNCloudStorageBackupsManager' +import { CloudStorageMnemonicBackup } from 'src/features/CloudBackup/types' +import { config } from 'uniswap/src/config' + +type UseCloudBackup = { + backups: CloudStorageMnemonicBackup[] + isLoading: boolean | null + isError: boolean + triggerCloudStorageBackupsFetch: () => void +} + +type UseCloudBackupOptions = { + autoFetch?: boolean +} + +export const useCloudBackups = (options?: UseCloudBackupOptions): UseCloudBackup => { + const [backups, setBackups] = useState([]) + const [isLoading, setIsLoading] = useState(null) + const [isError, setIsError] = useState(false) + + const triggerCloudStorageBackupsFetch = useCallback(() => { + setIsError(false) + setIsLoading(true) + // delays native oauth consent screen to avoid UI freezes + setTimeout(async () => { + try { + if (config.isE2ETest) { + setIsLoading(false) + setIsError(false) + return + } + setBackups(await getCloudBackupList()) + setIsLoading(false) + } catch { + setIsError(true) + setIsLoading(false) + } + }, 0) + }, []) + + useEffect(() => { + if (options?.autoFetch) { + triggerCloudStorageBackupsFetch() + } + }, [options?.autoFetch, triggerCloudStorageBackupsFetch]) + + return { + backups, + isLoading, + isError, + triggerCloudStorageBackupsFetch, + } +} diff --git a/apps/mobile/src/features/analytics/appsflyer.tsx b/apps/mobile/src/features/analytics/appsflyer.tsx new file mode 100644 index 00000000..6671a996 --- /dev/null +++ b/apps/mobile/src/features/analytics/appsflyer.tsx @@ -0,0 +1,25 @@ +import appsFlyer from 'react-native-appsflyer' +import { config } from 'uniswap/src/config' +import { isBetaEnv, isDevEnv } from 'utilities/src/environment/env' +import { logger } from 'utilities/src/logger/logger' + +export function initAppsFlyer(): void { + appsFlyer.initSdk( + { + devKey: config.appsflyerApiKey, + isDebug: isDevEnv() || isBetaEnv(), + appId: config.appsflyerAppId, + onInstallConversionDataListener: false, + onDeepLinkListener: false, + timeToWaitForATTUserAuthorization: 10, + // Ensures we have to manually start the SDK to respect any opting out + manualStart: true, + }, + (result) => { + logger.debug('appsflyer', 'initAppsFlyer', 'Result:', result) + }, + (error) => { + logger.error(error, { tags: { file: 'appsflyer', function: 'initAppsFlyer' } }) + }, + ) +} diff --git a/apps/mobile/src/features/analytics/onboardingTimestamp.ts b/apps/mobile/src/features/analytics/onboardingTimestamp.ts new file mode 100644 index 00000000..503d38a2 --- /dev/null +++ b/apps/mobile/src/features/analytics/onboardingTimestamp.ts @@ -0,0 +1,37 @@ +import { MMKV } from 'react-native-mmkv' +import { logger } from 'utilities/src/logger/logger' + +const storage = new MMKV({ id: 'onboarding-timestamp' }) +const ONBOARDING_TIMESTAMP_KEY = 'onboardingCompletedTimestamp' + +export function setOnboardingTimestamp(): void { + try { + storage.set(ONBOARDING_TIMESTAMP_KEY, Date.now()) + } catch (error) { + logger.error(error, { + tags: { file: 'onboardingTimestamp.ts', function: 'setOnboardingTimestamp' }, + }) + } +} + +export function getOnboardingTimestamp(): number | undefined { + try { + const value = storage.getNumber(ONBOARDING_TIMESTAMP_KEY) + return value && value !== 0 ? value : undefined + } catch (error) { + logger.error(error, { + tags: { file: 'onboardingTimestamp.ts', function: 'getOnboardingTimestamp' }, + }) + return undefined + } +} + +export function clearOnboardingTimestamp(): void { + try { + storage.delete(ONBOARDING_TIMESTAMP_KEY) + } catch (error) { + logger.error(error, { + tags: { file: 'onboardingTimestamp.ts', function: 'clearOnboardingTimestamp' }, + }) + } +} diff --git a/apps/mobile/src/features/analytics/useLogMissingMnemonic.ts b/apps/mobile/src/features/analytics/useLogMissingMnemonic.ts new file mode 100644 index 00000000..725ba4b7 --- /dev/null +++ b/apps/mobile/src/features/analytics/useLogMissingMnemonic.ts @@ -0,0 +1,56 @@ +import { useEffect } from 'react' +import { WalletEventName } from 'uniswap/src/features/telemetry/constants' +import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { MobileUserPropertyName, setUserProperty } from 'uniswap/src/features/telemetry/user' +import { logger } from 'utilities/src/logger/logger' +import { useSignerAccounts } from 'wallet/src/features/wallet/hooks' +import { Keyring } from 'wallet/src/features/wallet/Keyring/Keyring' + +// WALL-6234 +export function useLogMissingMnemonic(): void { + const signerMnemonicAccounts = useSignerAccounts() + const mnemonicId = signerMnemonicAccounts[0]?.mnemonicId + + // oxlint-disable-next-line react/exhaustive-deps -- only re-run when account count changes, not full array content + useEffect(() => { + const logMissingMnemonic = async (): Promise => { + if (!mnemonicId) { + setUserProperty(MobileUserPropertyName.HasMatchingMnemonicAndPrivateKey, 'none') + return + } + + const keyringMnemonicIds = await Keyring.getMnemonicIds() + + if (keyringMnemonicIds.find((id) => id === mnemonicId)) { + // Ignore if mnemonic is in the keyring. + setUserProperty(MobileUserPropertyName.HasMatchingMnemonicAndPrivateKey, 'true') + return + } + + const keyringPrivateKeyAddresses = await Keyring.getAddressesForStoredPrivateKeys() + + const accountsSortedByTime = signerMnemonicAccounts.sort((a, b) => a.timeImportedMs - b.timeImportedMs) + + setUserProperty(MobileUserPropertyName.HasMatchingMnemonicAndPrivateKey, 'false') + sendAnalyticsEvent(WalletEventName.KeyringMissingMnemonic, { + mnemonicId, + timeImportedMsFirst: accountsSortedByTime[0]?.timeImportedMs, + timeImportedMsLast: accountsSortedByTime[accountsSortedByTime.length - 1]?.timeImportedMs, + keyringMnemonicIds, + keyringPrivateKeyAddresses, + signerMnemonicAccounts: accountsSortedByTime.map((account) => ({ + mnemonicId: account.mnemonicId, + address: account.address, + timeImportedMs: account.timeImportedMs, + })), + }) + } + + logMissingMnemonic().catch((error) => { + logger.error(error, { + tags: { file: 'useLogMissingMnemonic.ts', function: 'logMissingMnemonic' }, + }) + }) + // oxlint-disable-next-line react/exhaustive-deps -- biome-parity: oxlint is stricter here + }, [mnemonicId, signerMnemonicAccounts.length]) +} diff --git a/apps/mobile/src/features/analytics/useLogUnexpectedOnboardingReset.test.ts b/apps/mobile/src/features/analytics/useLogUnexpectedOnboardingReset.test.ts new file mode 100644 index 00000000..a08a31d2 --- /dev/null +++ b/apps/mobile/src/features/analytics/useLogUnexpectedOnboardingReset.test.ts @@ -0,0 +1,70 @@ +import * as onboardingTimestamp from 'src/features/analytics/onboardingTimestamp' +import { useLogUnexpectedOnboardingReset } from 'src/features/analytics/useLogUnexpectedOnboardingReset' +import { renderHook } from 'src/test/test-utils' +import { logger } from 'utilities/src/logger/logger' +import { initialWalletState } from 'wallet/src/features/wallet/slice' + +jest.mock('src/features/analytics/onboardingTimestamp') +jest.mock('utilities/src/logger/logger', () => ({ + logger: { + debug: jest.fn(), + error: jest.fn(), + }, +})) + +const mockGetOnboardingTimestamp = jest.mocked(onboardingTimestamp.getOnboardingTimestamp) +const mockSetOnboardingTimestamp = jest.mocked(onboardingTimestamp.setOnboardingTimestamp) +const mockClearOnboardingTimestamp = jest.mocked(onboardingTimestamp.clearOnboardingTimestamp) + +describe('useLogUnexpectedOnboardingReset', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('does nothing when user has not yet onboarded', () => { + mockGetOnboardingTimestamp.mockReturnValue(undefined) + + renderHook(() => useLogUnexpectedOnboardingReset(), { + preloadedState: { wallet: { ...initialWalletState, finishedOnboarding: false } }, + }) + + expect(logger.error).not.toHaveBeenCalled() + expect(mockSetOnboardingTimestamp).not.toHaveBeenCalled() + expect(mockClearOnboardingTimestamp).not.toHaveBeenCalled() + }) + + it('does nothing when user is properly onboarded', () => { + mockGetOnboardingTimestamp.mockReturnValue(Date.now()) + + renderHook(() => useLogUnexpectedOnboardingReset(), { + preloadedState: { wallet: { ...initialWalletState, finishedOnboarding: true } }, + }) + + expect(logger.error).not.toHaveBeenCalled() + expect(mockSetOnboardingTimestamp).not.toHaveBeenCalled() + expect(mockClearOnboardingTimestamp).not.toHaveBeenCalled() + }) + + it('sets timestamp for existing users who onboarded before this feature', () => { + mockGetOnboardingTimestamp.mockReturnValue(undefined) + + renderHook(() => useLogUnexpectedOnboardingReset(), { + preloadedState: { wallet: { ...initialWalletState, finishedOnboarding: true } }, + }) + + expect(mockSetOnboardingTimestamp).toHaveBeenCalled() + expect(logger.error).not.toHaveBeenCalled() + expect(mockClearOnboardingTimestamp).not.toHaveBeenCalled() + }) + + it('logs error when unexpected reset detected (timestamp exists but redux shows not onboarded)', () => { + mockGetOnboardingTimestamp.mockReturnValue(Date.now()) + + renderHook(() => useLogUnexpectedOnboardingReset(), { + preloadedState: { wallet: { ...initialWalletState, finishedOnboarding: false } }, + }) + + expect(logger.error).toHaveBeenCalled() + expect(mockClearOnboardingTimestamp).toHaveBeenCalled() + }) +}) diff --git a/apps/mobile/src/features/analytics/useLogUnexpectedOnboardingReset.ts b/apps/mobile/src/features/analytics/useLogUnexpectedOnboardingReset.ts new file mode 100644 index 00000000..4a558176 --- /dev/null +++ b/apps/mobile/src/features/analytics/useLogUnexpectedOnboardingReset.ts @@ -0,0 +1,71 @@ +import { useEffect } from 'react' +import { useSelector } from 'react-redux' +import { + clearOnboardingTimestamp, + getOnboardingTimestamp, + setOnboardingTimestamp, +} from 'src/features/analytics/onboardingTimestamp' +import { logger } from 'utilities/src/logger/logger' +import { selectFinishedOnboarding } from 'wallet/src/features/wallet/selectors' + +/** + * Detects and logs when Redux onboarding state appears to have been unexpectedly reset. + * Uses a timestamp stored outside Redux to detect mismatches. + */ +export function useLogUnexpectedOnboardingReset(): void { + const finishedOnboarding = useSelector(selectFinishedOnboarding) + + useEffect(() => { + const onboardedTimestamp = getOnboardingTimestamp() + + // User has not yet onboarded - nothing to check + if (!onboardedTimestamp && !finishedOnboarding) { + logger.debug( + 'useLogUnexpectedOnboardingReset.ts', + 'useLogUnexpectedOnboardingReset', + 'User has not yet onboarded', + ) + return + } + + // User is properly onboarded with matching timestamp + if (onboardedTimestamp && finishedOnboarding) { + logger.debug( + 'useLogUnexpectedOnboardingReset.ts', + 'useLogUnexpectedOnboardingReset', + 'User is properly onboarded', + ) + return + } + + // Existing user who onboarded before this feature was added - set the timestamp + if (!onboardedTimestamp && finishedOnboarding) { + logger.debug( + 'useLogUnexpectedOnboardingReset.ts', + 'useLogUnexpectedOnboardingReset', + 'No onboarding timestamp found for onboarded user. Setting it now.', + ) + setOnboardingTimestamp() + return + } + + // Mismatch detected: timestamp exists but Redux says not onboarded + // This indicates unexpected data loss in Redux + if (onboardedTimestamp && !finishedOnboarding) { + const timeSinceOnboarding = Date.now() - onboardedTimestamp + logger.error(new Error('Unexpected onboarding state reset detected in redux store data'), { + tags: { + file: 'useLogUnexpectedOnboardingReset.ts', + function: 'useLogUnexpectedOnboardingReset', + }, + extra: { + onboardedTimestamp, + timeSinceOnboardingMs: timeSinceOnboarding, + reduxFinishedOnboarding: finishedOnboarding, + }, + }) + // Clear timestamp to prevent repeated logging + clearOnboardingTimestamp() + } + }, [finishedOnboarding]) +} diff --git a/apps/mobile/src/features/appLoading/SplashScreen.tsx b/apps/mobile/src/features/appLoading/SplashScreen.tsx new file mode 100644 index 00000000..a887cd14 --- /dev/null +++ b/apps/mobile/src/features/appLoading/SplashScreen.tsx @@ -0,0 +1,37 @@ +import React from 'react' +import { Image, StyleSheet } from 'react-native' +import { Flex, useIsDarkMode } from 'ui/src' +import { UNISWAP_MONO_LOGO_LARGE } from 'ui/src/assets' +import { useDeviceDimensions } from 'ui/src/hooks/useDeviceDimensions' +import { isAndroid } from 'utilities/src/platform' + +export const SPLASH_SCREEN_IMAGE_SIZE = 150 + +export function SplashScreen(): JSX.Element { + const dimensions = useDeviceDimensions() + const isDarkMode = useIsDarkMode() + + return ( + + + + ) +} + +const fixedStyle = StyleSheet.create({ + logoStyle: { + height: SPLASH_SCREEN_IMAGE_SIZE, + width: SPLASH_SCREEN_IMAGE_SIZE, + }, +}) diff --git a/apps/mobile/src/features/appRating/saga.ts b/apps/mobile/src/features/appRating/saga.ts new file mode 100644 index 00000000..e21394d9 --- /dev/null +++ b/apps/mobile/src/features/appRating/saga.ts @@ -0,0 +1,194 @@ +import { Alert, Platform } from 'react-native' +import { call, delay, put, select, takeLatest } from 'typed-redux-saga' +import { uniswapUrls } from 'uniswap/src/constants/urls' +import { WalletEventName } from 'uniswap/src/features/telemetry/constants' +import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { finalizeTransaction } from 'uniswap/src/features/transactions/slice' +import { TransactionStatus, TransactionType } from 'uniswap/src/features/transactions/types/transactionDetails' +import i18n from 'uniswap/src/i18n' +import { openUri } from 'uniswap/src/utils/linking' +import { isTestRun } from 'utilities/src/environment/constants' +import { logger } from 'utilities/src/logger/logger' +import { isAndroid } from 'utilities/src/platform' +import { ONE_SECOND_MS } from 'utilities/src/time/time' +import { appRatingStateSelector } from 'wallet/src/features/appRating/selectors' +import { selectActiveAccountAddress } from 'wallet/src/features/wallet/selectors' +import { setAppRating } from 'wallet/src/features/wallet/slice' + +function isAndroid14(): boolean { + return isAndroid && Platform.Version === 34 +} + +// small delay to help ux +const SWAP_FINALIZED_PROMPT_DELAY_MS = 3 * ONE_SECOND_MS + +try { + if (!isTestRun && !isAndroid14()) { + import('expo-store-review') + } +} catch (error) { + const message = error instanceof Error ? error.message : 'Store Review import error' + logger.warn('appRating/saga.ts', 'init', message) +} + +// Wrap the StoreReview import in a function that catches the specific error +const getStoreReview = async () => { + try { + return await import('expo-store-review') + } catch (error) { + const message = error instanceof Error ? error.message : 'Store Review import error' + logger.warn('appRating/saga.ts', 'getStoreReview', message) + return undefined + } +} + +export function* appRatingWatcherSaga() { + function* processFinalizedTx(action: ReturnType) { + // count successful swaps + if (action.payload.typeInfo.type === TransactionType.Swap && action.payload.status === TransactionStatus.Success) { + yield* delay(SWAP_FINALIZED_PROMPT_DELAY_MS) + yield* call(maybeRequestAppRating) + } + } + + yield* takeLatest(finalizeTransaction.type, processFinalizedTx) +} + +function* maybeRequestAppRating() { + try { + const StoreReview = yield* call(getStoreReview) + if (!StoreReview) { + logger.warn('appRating/saga.ts', 'maybeRequestAppRating', 'StoreReview not available') + return + } + + const canRequestReview = yield* call(StoreReview.hasAction) + if (!canRequestReview) { + return + } + + const activeAddress = yield* select(selectActiveAccountAddress) + if (!activeAddress) { + return + } + + const { shouldPrompt, appRatingProvidedMs, appRatingPromptedMs, consecutiveSwapsCondition } = + yield* select(appRatingStateSelector) + + if (!shouldPrompt) { + logger.debug('appRating', 'maybeRequestAppRating', 'Skipping app rating', { + lastPrompt: appRatingPromptedMs, + lastProvided: appRatingProvidedMs, + consecutiveSwapsCondition, + }) + return + } + + logger.debug('appRating', 'maybeRequestAppRating', 'Requesting app rating', { + lastPrompt: appRatingPromptedMs, + lastProvided: appRatingProvidedMs, + consecutiveSwapsCondition, + }) + + // Alerts + const shouldShowNativeReviewModal = yield* call(openRatingOptionsAlert) + + if (shouldShowNativeReviewModal) { + // expo-review does not return whether a rating was actually provided. + // assume it was and mark rating as provided. + yield* put(setAppRating({ ratingProvided: true })) + + sendAnalyticsEvent(WalletEventName.AppRating, { + type: 'store-review', + appRatingPromptedMs, + appRatingProvidedMs, + }) + } else { + // show feedback form + const feedbackSent = yield* call(openFeedbackRequestAlert) + + if (feedbackSent) { + yield* put(setAppRating({ feedbackProvided: true })) + + sendAnalyticsEvent(WalletEventName.AppRating, { + type: 'feedback-form', + appRatingPromptedMs, + appRatingProvidedMs, + }) + } else { + yield* put(setAppRating({ feedbackProvided: false })) + + sendAnalyticsEvent(WalletEventName.AppRating, { + type: 'remind', + appRatingPromptedMs, + appRatingProvidedMs, + }) + } + } + } catch (e) { + logger.error(e, { tags: { file: 'appRating', function: 'maybeRequestAppRating' } }) + } +} + +/** + * Opens the app rating request alert. Either opens the native review modal + * or the feedback form if user wishes to provide feedback. + */ +async function openRatingOptionsAlert() { + return new Promise((resolve) => { + Alert.alert(i18n.t('appRating.mobile.title'), i18n.t('appRating.description'), [ + { + text: i18n.t('appRating.button.notReally'), + onPress: () => resolve(false), + style: 'cancel', + }, + { + text: i18n.t('common.button.yes'), + onPress: () => { + openNativeReviewModal().catch((e) => + logger.error(e, { + tags: { file: 'appRating/saga', function: 'openRatingOptionsAlert' }, + }), + ) + resolve(true) + }, + isPreferred: true, + }, + ]) + }) +} + +/** Opens feedback request modal which will redirect to our feedback form. */ +async function openFeedbackRequestAlert() { + return new Promise((resolve) => { + Alert.alert(i18n.t('appRating.feedback.title'), i18n.t('appRating.feedback.description'), [ + { + text: i18n.t('appRating.feedback.button.send'), + onPress: () => { + openUri({ uri: uniswapUrls.walletFeedbackForm }).catch((e) => + logger.error(e, { tags: { file: 'appRating/saga', function: 'openFeedbackAlert' } }), + ) + resolve(true) + }, + isPreferred: true, + }, + { + text: i18n.t('common.button.later'), + onPress: () => resolve(false), + style: 'cancel', + }, + ]) + }) +} + +/** Opens the native store review modal that will send the rating to the store. */ +async function openNativeReviewModal() { + try { + const StoreReview = await getStoreReview() + if (StoreReview && (await StoreReview.hasAction())) { + await StoreReview.requestReview() + } + } catch (e) { + logger.error(e, { tags: { file: 'appRating/saga', function: 'openNativeReviewModal' } }) + } +} diff --git a/apps/mobile/src/features/appState/appStateResetter.test.ts b/apps/mobile/src/features/appState/appStateResetter.test.ts new file mode 100644 index 00000000..03590430 --- /dev/null +++ b/apps/mobile/src/features/appState/appStateResetter.test.ts @@ -0,0 +1,120 @@ +import { ApolloClient, InMemoryCache } from '@apollo/client' +import { configureStore } from '@reduxjs/toolkit' +import { QueryClient } from '@tanstack/react-query' +import { Image } from 'expo-image' +import { type MobileState, mobileReducer } from 'src/app/mobileReducer' +import { createMobileAppStateResetter } from 'src/features/appState/appStateResetter' +import { openModal } from 'src/features/modals/modalSlice' +import { NotifSettingType } from 'src/features/notifications/constants' +import { updateNotifSettings } from 'src/features/notifications/slice' +import { ScannerModalState } from 'uniswap/src/components/ReceiveQRCode/constants' +import { pushNotification } from 'uniswap/src/features/notifications/slice/slice' +import { AppNotificationType } from 'uniswap/src/features/notifications/slice/types' +import { ModalName } from 'uniswap/src/features/telemetry/constants' + +jest.mock('expo-image', () => ({ + Image: { + clearDiskCache: jest.fn(() => Promise.resolve()), + clearMemoryCache: jest.fn(() => Promise.resolve()), + }, +})) + +const createMockApolloClient = (): ApolloClient => { + const client = new ApolloClient({ + cache: new InMemoryCache(), + }) + jest.spyOn(client, 'resetStore').mockResolvedValue([]) + return client +} + +const createMockQueryClient = (): QueryClient => { + const client = new QueryClient() + jest.spyOn(client, 'resetQueries').mockResolvedValue() + return client +} + +describe('createMobileAppStateResetter', () => { + let store: ReturnType> + let apolloClient: ApolloClient + let queryClient: QueryClient + let resetter: ReturnType + + beforeEach(() => { + store = configureStore({ + reducer: mobileReducer, + }) + apolloClient = createMockApolloClient() + queryClient = createMockQueryClient() + resetter = createMobileAppStateResetter({ + dispatch: store.dispatch, + apolloClient, + queryClient, + }) + jest.clearAllMocks() + }) + + describe('resetAccountHistory', () => { + it('dispatches mobile-specific account history reset actions', async () => { + store.dispatch(openModal({ name: ModalName.WalletConnectScan, initialState: ScannerModalState.ScanQr })) + + // Verify state was modified + expect(store.getState().modals[ModalName.WalletConnectScan].isOpen).toBe(true) + + await resetter.resetAccountHistory() + + // Verify mobile-specific state was reset + const state = store.getState() + expect(state.modals[ModalName.WalletConnectScan].isOpen).toBe(false) // resetModals effect + }) + }) + + describe('resetUserSettings', () => { + it('dispatches mobile-specific user settings reset actions', async () => { + store.dispatch(updateNotifSettings({ [NotifSettingType.GeneralUpdates]: false })) + + // Verify state was modified + expect(store.getState().pushNotifications.generalUpdatesEnabled).toBe(false) + + await resetter.resetUserSettings() + + // Verify mobile-specific state was reset + const state = store.getState() + expect(state.pushNotifications.generalUpdatesEnabled).toBe(true) + }) + }) + + describe('resetQueryCaches', () => { + it('clears Apollo, React Query, and Expo Image caches', async () => { + await resetter.resetQueryCaches() + + // Verify cache clearing methods were called + expect(apolloClient.resetStore).toHaveBeenCalledTimes(1) + expect(queryClient.resetQueries).toHaveBeenCalledTimes(1) + expect(Image.clearDiskCache).toHaveBeenCalledTimes(1) + expect(Image.clearMemoryCache).toHaveBeenCalledTimes(1) + }) + }) + + describe('resetAll', () => { + it('resets all state and clears all caches', async () => { + // Modify state first + store.dispatch( + pushNotification({ + type: AppNotificationType.Success, + title: 'Test notification', + }), + ) + + // Verify state was modified + expect(store.getState().notifications.notificationQueue.length).toBe(1) + + await resetter.resetAll() + + // Verify all resets worked + const state = store.getState() + expect(state.notifications.notificationQueue).toEqual([]) + expect(apolloClient.resetStore).toHaveBeenCalledTimes(1) + expect(queryClient.resetQueries).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/apps/mobile/src/features/appState/appStateResetter.tsx b/apps/mobile/src/features/appState/appStateResetter.tsx new file mode 100644 index 00000000..3cc3ea65 --- /dev/null +++ b/apps/mobile/src/features/appState/appStateResetter.tsx @@ -0,0 +1,64 @@ +import { type ApolloClient, useApolloClient } from '@apollo/client' +import { type Dispatch } from '@reduxjs/toolkit' +import { type QueryClient, useQueryClient } from '@tanstack/react-query' +import { Image } from 'expo-image' +import { useMemo } from 'react' +import { useDispatch } from 'react-redux' +import { resetBiometricSettings } from 'src/features/biometricsSettings/slice' +import { resetModals } from 'src/features/modals/modalSlice' +import { resetPushNotifications } from 'src/features/notifications/slice' +import { resetTweaks } from 'src/features/tweaks/slice' +import { resetWalletConnect } from 'src/features/walletConnect/walletConnectSlice' +import { type AppStateResetter } from 'uniswap/src/state/createAppStateResetter' +import { createLogger } from 'utilities/src/logger/logger' +import { createWalletStateResetter } from 'wallet/src/state/createWalletStateResetter' + +/** + * Creates the mobile app's state resetter instance. + * This wraps the base createAppStateResetter and adds mobile-specific reset actions. + */ +export function createMobileAppStateResetter({ + dispatch, + apolloClient, + queryClient, +}: { + dispatch: Dispatch + apolloClient: ApolloClient + queryClient: QueryClient +}): AppStateResetter { + const logger = createLogger('appStateResetter.tsx', 'createMobileAppStateResetter') + + return createWalletStateResetter({ + dispatch, + + onResetAccountHistory: () => { + dispatch(resetModals()) + dispatch(resetWalletConnect()) + }, + + onResetUserSettings: () => { + dispatch(resetBiometricSettings()) + dispatch(resetPushNotifications()) + dispatch(resetTweaks()) + }, + + onResetQueryCaches: async () => { + await Promise.all([ + apolloClient.resetStore().then(() => logger.info('Apollo cache cleared successfully')), + queryClient.resetQueries().then(() => logger.info('React Query cache cleared successfully')), + Image.clearDiskCache().then(() => logger.info('Image disk cache cleared successfully')), + Image.clearMemoryCache().then(() => logger.info('Image memory cache cleared successfully')), + ]) + }, + }) +} + +export function useAppStateResetter(): AppStateResetter { + const dispatch = useDispatch() + const apolloClient = useApolloClient() + const queryClient = useQueryClient() + return useMemo( + () => createMobileAppStateResetter({ dispatch, apolloClient, queryClient }), + [dispatch, apolloClient, queryClient], + ) +} diff --git a/apps/mobile/src/features/appState/appStateSaga.ts b/apps/mobile/src/features/appState/appStateSaga.ts new file mode 100644 index 00000000..f802cc1a --- /dev/null +++ b/apps/mobile/src/features/appState/appStateSaga.ts @@ -0,0 +1,68 @@ +import { AppState, AppStateStatus } from 'react-native' +import { EventChannel, eventChannel, SagaIterator } from 'redux-saga' +import { transitionAppState } from 'src/features/appState/appStateSlice' +import { cancelled, put, take } from 'typed-redux-saga' +import { isAndroid } from 'utilities/src/platform' + +//------------------------------ +// appStateSaga +//------------------------------ + +export function* appStateSaga(): SagaIterator { + const appStateChannel: EventChannel = eventChannel((emit) => { + return appStateSubscription(emit) + }) + + try { + while (true) { + const nextAppState: AppStateStatus = yield* take(appStateChannel) + yield* put(transitionAppState(nextAppState)) + } + } finally { + if (yield* cancelled()) { + appStateChannel.close() + } + } +} + +//------------------------------ +// AppState subscription +//------------------------------ + +// TODO: disable until we can wrap anything that causes blur, eg: context menu, alert, etc. +const IS_ANDROID_SUBSCRIPTION_ENABLED = false + +/** + * Subscribes to app state changes and returns a function to unsubscribe + * NB: We use blur and focus to replicate 'inactive' state on Android + * @param onChange - Callback function to handle app state changes + * @returns - Function to unsubscribe from the app state change listener + */ +function appStateSubscription(onChange: (value: AppStateStatus) => void): () => void { + const subscription = AppState.addEventListener('change', (value: AppStateStatus) => { + onChange(value) + }) + + // on Android we use blur and focus to replicate 'inactive' state which is only available on iOS + // Inactive is when the user switches to another app, looks at notifications, etc. + let blurSubscription: ReturnType | undefined + let focusSubscription: ReturnType | undefined + + // oxlint-disable-next-line typescript/no-unnecessary-condition + if (isAndroid && IS_ANDROID_SUBSCRIPTION_ENABLED) { + blurSubscription = AppState.addEventListener('blur', () => { + onChange('inactive' as AppStateStatus) + }) + + focusSubscription = AppState.addEventListener('focus', () => { + onChange(AppState.currentState) + }) + } + + // cleanup + return () => { + subscription.remove() + blurSubscription?.remove() + focusSubscription?.remove() + } +} diff --git a/apps/mobile/src/features/appState/appStateSlice.ts b/apps/mobile/src/features/appState/appStateSlice.ts new file mode 100644 index 00000000..d2839b15 --- /dev/null +++ b/apps/mobile/src/features/appState/appStateSlice.ts @@ -0,0 +1,45 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit' +import { AppState, AppStateStatus } from 'react-native' + +//------------------------------------------------------------------------------------------------ +// App State +//------------------------------------------------------------------------------------------------ + +export interface AppStateState { + current: AppStateStatus + previous: AppStateStatus | null +} + +const initialState: AppStateState = { + current: AppState.currentState, + previous: null, +} + +const appStateSlice = createSlice({ + name: 'appState', + initialState, + reducers: { + transitionAppState: (state, action: PayloadAction) => { + state.previous = state.current + state.current = action.payload + }, + }, +}) + +export const { transitionAppState } = appStateSlice.actions +export const appStateReducer = appStateSlice.reducer + +//------------------------------ +// AppState selectors +//------------------------------ + +const selectAppState = (state: { appState: AppStateState }): AppStateState => state.appState + +export const selectCurrentAppState = (state: { appState: AppStateState }): AppStateState['current'] => + selectAppState(state).current + +export const selectPreviousAppState = (state: { appState: AppStateState }): AppStateState['previous'] => + selectAppState(state).previous + +export const selectIsFromBackground = (state: { appState: AppStateState }): boolean => + selectPreviousAppState(state) === 'background' diff --git a/apps/mobile/src/features/biometrics/biometrics-utils.test.ts b/apps/mobile/src/features/biometrics/biometrics-utils.test.ts new file mode 100644 index 00000000..a75f42ca --- /dev/null +++ b/apps/mobile/src/features/biometrics/biometrics-utils.test.ts @@ -0,0 +1,54 @@ +import { authenticateAsync, hasHardwareAsync, isEnrolledAsync } from 'expo-local-authentication' +import { BiometricAuthenticationStatus, tryLocalAuthenticate } from 'src/features/biometrics/biometrics-utils' + +jest.mock('expo-local-authentication') + +const mockedHasHardwareAsync = >hasHardwareAsync +const mockedIsEnrolledAsync = >isEnrolledAsync +const mockedAuthenticateAsync = >authenticateAsync + +describe(tryLocalAuthenticate, () => { + it('checks hardware compatibility', async () => { + mockedHasHardwareAsync.mockResolvedValue(false) + + const status = await tryLocalAuthenticate() + + expect(status).toEqual(BiometricAuthenticationStatus.Unsupported) + }) + + it('checks enrollement', async () => { + mockedHasHardwareAsync.mockResolvedValue(true) + mockedIsEnrolledAsync.mockResolvedValue(false) + mockedAuthenticateAsync.mockResolvedValue({ success: false, error: 'unknown' }) + + const status = await tryLocalAuthenticate() + + expect(status).toEqual(BiometricAuthenticationStatus.MissingEnrollment) + }) + + it('fails to authenticate when user rejects', async () => { + mockedHasHardwareAsync.mockResolvedValue(true) + mockedIsEnrolledAsync.mockResolvedValue(true) + mockedAuthenticateAsync.mockResolvedValue({ success: false, error: 'unknown' }) + + const status = await tryLocalAuthenticate() + + expect(status).toEqual(BiometricAuthenticationStatus.Rejected) + }) + + it('authenticates when user accepts', async () => { + mockedHasHardwareAsync.mockResolvedValue(true) + mockedIsEnrolledAsync.mockResolvedValue(true) + mockedAuthenticateAsync.mockResolvedValue({ success: true }) + + const status = await tryLocalAuthenticate() + + expect(status).toEqual(BiometricAuthenticationStatus.Authenticated) + }) + + it('always return authenticated when disabled', async () => { + const status = await tryLocalAuthenticate() + + expect(status).toEqual(BiometricAuthenticationStatus.Authenticated) + }) +}) diff --git a/apps/mobile/src/features/biometrics/biometrics-utils.ts b/apps/mobile/src/features/biometrics/biometrics-utils.ts new file mode 100644 index 00000000..2ed57f54 --- /dev/null +++ b/apps/mobile/src/features/biometrics/biometrics-utils.ts @@ -0,0 +1,93 @@ +import { + authenticateAsync, + hasHardwareAsync, + isEnrolledAsync, + LocalAuthenticationResult, +} from 'expo-local-authentication' +import DeviceInfo from 'react-native-device-info' +import { openSecuritySettings } from 'src/utils/linking' +import i18n from 'uniswap/src/i18n' +import { logger } from 'utilities/src/logger/logger' +import { isAndroid } from 'utilities/src/platform' + +/** + * Biometric authentication statuses + * Note. Sorted by authentication level + */ +export enum BiometricAuthenticationStatus { + Unsupported = 'UNSUPPORTED', + MissingEnrollment = 'MISSING_ENROLLMENT', + Rejected = 'REJECTED', + Authenticated = 'AUTHENTICATED', + Authenticating = 'AUTHENTICATING', + Lockout = 'LOCKOUT', + UserCancel = 'USER_CANCEL', + SystemCancel = 'SYSTEM_CANCEL', + Invalid = 'INVALID', +} + +export async function enroll(): Promise { + await openSecuritySettings() +} + +// TODO: [MOB-220] Move into a saga +export async function tryLocalAuthenticate(): Promise { + try { + const compatible = await hasHardwareAsync() + + if (!compatible) { + return BiometricAuthenticationStatus.Unsupported + } + + /** + * Important: ExpoLocalAuthentication.isEnrolledAsync() method nested in isEnrolledAsync() returns false when + when users exceeds the amount of retries. Exactly the same when user has no biometric setup on the device + and thats why we have to call authenticateAsync to be able to distinguish between different errors. + */ + const enrolled = await isEnrolledAsync() + const disableDeviceFallback = isAndroid && (await DeviceInfo.getApiLevel()) < 30 + + const result = await authenticateAsync({ + cancelLabel: i18n.t('common.button.cancel'), + promptMessage: i18n.t('settings.setting.biometrics.auth'), + requireConfirmation: false, + biometricsSecurityLevel: 'strong', + disableDeviceFallback, + }) + + if (result.success === true) { + return BiometricAuthenticationStatus.Authenticated + } + + if (isInLockout(result)) { + return BiometricAuthenticationStatus.Lockout + } + + if (isCanceledByUser(result)) { + return BiometricAuthenticationStatus.UserCancel + } + + if (isCanceledBySystem(result)) { + return BiometricAuthenticationStatus.SystemCancel + } + + if (!enrolled) { + return BiometricAuthenticationStatus.MissingEnrollment + } + + return BiometricAuthenticationStatus.Rejected + } catch (error) { + logger.error(error, { tags: { file: 'biometrics/index', function: 'tryLocalAuthenticate' } }) + + return BiometricAuthenticationStatus.Rejected + } +} + +const isInLockout = (result: LocalAuthenticationResult): boolean => + result.success === false && result.error === 'lockout' + +const isCanceledByUser = (result: LocalAuthenticationResult): boolean => + result.success === false && result.error === 'user_cancel' + +const isCanceledBySystem = (result: LocalAuthenticationResult): boolean => + result.success === false && result.error === 'system_cancel' diff --git a/apps/mobile/src/features/biometrics/biometricsSaga.ts b/apps/mobile/src/features/biometrics/biometricsSaga.ts new file mode 100644 index 00000000..b0f1b422 --- /dev/null +++ b/apps/mobile/src/features/biometrics/biometricsSaga.ts @@ -0,0 +1,82 @@ +import { PayloadAction } from '@reduxjs/toolkit' +import { + AuthenticationType, + hasHardwareAsync, + isEnrolledAsync, + supportedAuthenticationTypesAsync, +} from 'expo-local-authentication' +import { SagaIterator, Task } from 'redux-saga' +import { BiometricAuthenticationStatus, tryLocalAuthenticate } from 'src/features/biometrics/biometrics-utils' +import { + setAuthenticationStatus, + setDeviceSupportsBiometrics, + const { deviceSupportsBiometrics, isEnrolled, isBiometricsDisabledInOSSettings, supportedAuthenticationTypes } = + yield* call(checkBiometricsSupport) + // @ts-expect-error -- `all` doesn't accept the type of `put` + yield* all([ + put(setDeviceSupportsBiometrics(deviceSupportsBiometrics)), + put(setIsEnrolled(isEnrolled)), + isBiometricsDisabledInOSSettings: boolean + supportedAuthenticationTypes: AuthenticationType[] +}> { + const [deviceSupportsBiometrics, isEnrolled, supportedAuthenticationTypes] = await Promise.all([ + // Contrary to what the name suggests, it returns false when biometrics are disabled in the settings + hasHardwareAsync(), + isEnrolledAsync(), + supportedAuthenticationTypesAsync(), + ]) + + const isBiometricsDisabledInOSSettings = + (supportedAuthenticationTypes.includes(AuthenticationType.FINGERPRINT) || + supportedAuthenticationTypes.includes(AuthenticationType.FACIAL_RECOGNITION)) && + !isEnrolled + + return { + deviceSupportsBiometrics, + isEnrolled, + supportedAuthenticationTypes, + isBiometricsDisabledInOSSettings, + } +} + +function* checkBiometricsSupport(): SagaIterator<{ + deviceSupportsBiometrics: boolean + isEnrolled: boolean + isBiometricsDisabledInOSSettings: boolean + supportedAuthenticationTypes: AuthenticationType[] +}> { + return yield* call(getAllBiometricsSupport) +} + +function* handleAuthentication(action: PayloadAction): SagaIterator { + const { onSuccess, onFailure, params } = action.payload + + yield* put(setAuthenticationStatus(BiometricAuthenticationStatus.Authenticating)) + + const result = yield* call(tryLocalAuthenticate) + const isSuccessful = biometricAuthenticationSuccessful(result) || biometricAuthenticationDisabledByOS(result) + + if (isSuccessful) { + yield* put(setAuthenticationStatus(BiometricAuthenticationStatus.Authenticated)) + if (onSuccess) { + yield* call(onSuccess, params) + } + return + } else { + yield* put(setAuthenticationStatus(BiometricAuthenticationStatus.Rejected)) + + if (onFailure) { + yield* call(onFailure) + } + } +} + +export function biometricAuthenticationSuccessful(status: BiometricAuthenticationStatus): boolean { + return status === BiometricAuthenticationStatus.Authenticated +} + +function biometricAuthenticationDisabledByOS(status: BiometricAuthenticationStatus): boolean { + return ( + status === BiometricAuthenticationStatus.Unsupported || status === BiometricAuthenticationStatus.MissingEnrollment + ) +} diff --git a/apps/mobile/src/features/biometrics/biometricsSlice.ts b/apps/mobile/src/features/biometrics/biometricsSlice.ts new file mode 100644 index 00000000..a21ea55c --- /dev/null +++ b/apps/mobile/src/features/biometrics/biometricsSlice.ts @@ -0,0 +1,80 @@ +import { createSelector, createSlice, PayloadAction } from '@reduxjs/toolkit' +import { AuthenticationType } from 'expo-local-authentication' +import { BiometricAuthenticationStatus } from 'src/features/biometrics/biometrics-utils' + +//------------------------------------------------------------------------------------------------ +// Biometrics State +//------------------------------------------------------------------------------------------------ + +// oxlint-disable-next-line import/no-unused-modules +export interface BiometricsState { + authenticationStatus: BiometricAuthenticationStatus + deviceSupportsBiometrics: boolean | undefined + lastAuthenticationTime: number | undefined + isEnrolled: boolean | undefined + isBiometricsDisabledInOSSettings: undefined, + supportedAuthenticationTypes: undefined, +} + +export interface TriggerAuthenticationPayload { + onSuccess?: (params?: T) => void + onFailure?: () => void + params?: T +} + +const biometricsSlice = createSlice({ + name: 'biometrics', + initialState, + reducers: { + setAuthenticationStatus: (state, action: PayloadAction) => { + state.authenticationStatus = action.payload + if (action.payload === BiometricAuthenticationStatus.Authenticated) { + state.lastAuthenticationTime = Date.now() + } + }, + setDeviceSupportsBiometrics: (state, action: PayloadAction) => { + state.deviceSupportsBiometrics = action.payload + }, + setIsEnrolled: (state, action: PayloadAction) => { + state.isEnrolled = action.payload + }, + setIsBiometricsDisabledInOSSettings, + setSupportedAuthenticationTypes, + triggerAuthentication, +} = biometricsSlice.actions + +export const biometricsReducer = biometricsSlice.reducer + +//------------------------------ +// Biometrics Selectors +//------------------------------ + +export const selectDeviceSupportsBiometrics = (state: { biometrics: BiometricsState }): boolean | undefined => + state.biometrics.deviceSupportsBiometrics + +const selectIsEnrolled = (state: { biometrics: BiometricsState }): boolean | undefined => state.biometrics.isEnrolled + +export const selectIsBiometricsDisabledInOSSettings = (state: { biometrics: BiometricsState }): boolean | undefined => + state.biometrics.isBiometricsDisabledInOSSettings + +const selectSupportedAuthenticationTypes = (state: { biometrics: BiometricsState }): AuthenticationType[] | undefined => + state.biometrics.supportedAuthenticationTypes + +export const selectAuthenticationStatus = (state: { biometrics: BiometricsState }): BiometricAuthenticationStatus => + state.biometrics.authenticationStatus + +export const selectAuthenticationStatusIsAuthenticated = (state: { biometrics: BiometricsState }): boolean => + selectAuthenticationStatus(state) === BiometricAuthenticationStatus.Authenticated + +export const selectOsBiometricAuthEnabled = createSelector( + [selectDeviceSupportsBiometrics, selectIsEnrolled], + (deviceSupportsBiometrics, isEnrolled) => deviceSupportsBiometrics && isEnrolled, +) + +export const selectOsBiometricAuthSupported = createSelector( + [selectSupportedAuthenticationTypes], + (supportedAuthenticationTypes) => ({ + touchId: supportedAuthenticationTypes?.includes(AuthenticationType.FINGERPRINT) ?? false, + faceId: supportedAuthenticationTypes?.includes(AuthenticationType.FACIAL_RECOGNITION) ?? false, + }), +) diff --git a/apps/mobile/src/features/biometrics/useBiometricAppSettings.tsx b/apps/mobile/src/features/biometrics/useBiometricAppSettings.tsx new file mode 100644 index 00000000..1d852e51 --- /dev/null +++ b/apps/mobile/src/features/biometrics/useBiometricAppSettings.tsx @@ -0,0 +1,6 @@ +import { useSelector } from 'react-redux' +import { BiometricSettingsState, selectBiometricSettings } from 'src/features/biometricsSettings/slice' + +export function useBiometricAppSettings(): BiometricSettingsState { + return useSelector(selectBiometricSettings) +} diff --git a/apps/mobile/src/features/biometrics/useBiometricAppSpeedBump.tsx b/apps/mobile/src/features/biometrics/useBiometricAppSpeedBump.tsx new file mode 100644 index 00000000..e3f5f363 --- /dev/null +++ b/apps/mobile/src/features/biometrics/useBiometricAppSpeedBump.tsx @@ -0,0 +1,34 @@ +import { useCallback } from 'react' +import { useBiometricAppSettings } from 'src/features/biometrics/useBiometricAppSettings' +import { useBiometricPrompt } from 'src/features/biometricsSettings/hooks' + +/** + * Helper hook to handle biometric speed bump before executing the given callback. + * + * @param onVerified - Callback to execute after biometric verification. + */ +export function useBiometricAppSpeedBump(onVerified: () => void): { + /** + * Function to trigger biometric verification if enabled before + * executing the onVerified callback. + */ + onBiometricContinue: () => Promise +} { + const { + requiredForAppAccess: biometricAuthRequiredForAppAccess, + requiredForTransactions: biometricAuthRequiredForTransactions, + } = useBiometricAppSettings() + const { trigger: biometricTrigger } = useBiometricPrompt(onVerified) + + const onBiometricContinue = useCallback(async (): Promise => { + if (biometricAuthRequiredForAppAccess || biometricAuthRequiredForTransactions) { + await biometricTrigger() + } else { + onVerified() + } + }, [biometricAuthRequiredForAppAccess, biometricAuthRequiredForTransactions, biometricTrigger, onVerified]) + + return { + onBiometricContinue, + } +} diff --git a/apps/mobile/src/features/biometrics/useBiometricsAlert.ts b/apps/mobile/src/features/biometrics/useBiometricsAlert.ts new file mode 100644 index 00000000..b74aaf80 --- /dev/null +++ b/apps/mobile/src/features/biometrics/useBiometricsAlert.ts @@ -0,0 +1,29 @@ +import type { TFunction } from 'i18next' +import { Alert } from 'react-native' +import { openSettings } from 'react-native-permissions' +import { enroll } from 'src/features/biometrics/biometrics-utils' +import { isIOS } from 'utilities/src/platform' + +type ShowBiometricsAlert = (biometricsMethod: string) => void + +export const useBiometricsAlert = (ctx: { t: TFunction }): { showBiometricsAlert: ShowBiometricsAlert } => { + const { t } = ctx + + const showBiometricsAlert: ShowBiometricsAlert = (biometricsMethod) => { + if (isIOS) { + Alert.alert( + t('onboarding.security.alert.biometrics.title.ios', { biometricsMethod }), + t('onboarding.security.alert.biometrics.message.ios', { biometricsMethod }), + [{ text: t('common.navigation.systemSettings'), onPress: openSettings }, { text: t('common.button.notNow') }], + ) + } else { + Alert.alert( + t('onboarding.security.alert.biometrics.title.android'), + t('onboarding.security.alert.biometrics.message.android'), + [{ text: t('onboarding.security.button.setup'), onPress: enroll }, { text: t('common.button.notNow') }], + ) + } + } + + return { showBiometricsAlert } +} diff --git a/apps/mobile/src/features/biometrics/useBiometricsState.ts b/apps/mobile/src/features/biometrics/useBiometricsState.ts new file mode 100644 index 00000000..0cc0759d --- /dev/null +++ b/apps/mobile/src/features/biometrics/useBiometricsState.ts @@ -0,0 +1,16 @@ +import { useCallback } from 'react' +import { useDispatch, useSelector } from 'react-redux' +import { BiometricAuthenticationStatus } from 'src/features/biometrics/biometrics-utils' +import { + selectAuthenticationStatus, + selectDeviceSupportsBiometrics, + isBiometricsDisabledInOSSettings: boolean | undefined +} + +export function useBiometricsState(): UseBiometricsStateResult { + const dispatch = useDispatch() + const authenticationStatus = useSelector(selectAuthenticationStatus) + const deviceSupportsBiometrics = useSelector(selectDeviceSupportsBiometrics) + isBiometricsDisabledInOSSettings, + } +} diff --git a/apps/mobile/src/features/biometrics/useDeviceSupportsBiometricAuth.tsx b/apps/mobile/src/features/biometrics/useDeviceSupportsBiometricAuth.tsx new file mode 100644 index 00000000..fb77e2ec --- /dev/null +++ b/apps/mobile/src/features/biometrics/useDeviceSupportsBiometricAuth.tsx @@ -0,0 +1,12 @@ +import { useSelector } from 'react-redux' +import { selectOsBiometricAuthSupported } from 'src/features/biometrics/biometricsSlice' + +/** + * Check function of biometric device support + * @returns object representing biometric auth support by type + */ + +export function useDeviceSupportsBiometricAuth(): { touchId: boolean; faceId: boolean } { + // check if device supports biometric authentication + return useSelector(selectOsBiometricAuthSupported) +} diff --git a/apps/mobile/src/features/biometrics/useOsBiometricAuthEnabled.tsx b/apps/mobile/src/features/biometrics/useOsBiometricAuthEnabled.tsx new file mode 100644 index 00000000..fbcfbcb3 --- /dev/null +++ b/apps/mobile/src/features/biometrics/useOsBiometricAuthEnabled.tsx @@ -0,0 +1,11 @@ +import { useSelector } from 'react-redux' +import { selectOsBiometricAuthEnabled } from 'src/features/biometrics/biometricsSlice' + +/** + * Hook to determine whether biometric auth is enabled in OS settings + * @returns if Face ID or Touch ID is enabled + */ + +export function useOsBiometricAuthEnabled(): boolean | undefined { + return useSelector(selectOsBiometricAuthEnabled) +} diff --git a/apps/mobile/src/features/biometricsSettings/hooks.tsx b/apps/mobile/src/features/biometricsSettings/hooks.tsx new file mode 100644 index 00000000..26296e01 --- /dev/null +++ b/apps/mobile/src/features/biometricsSettings/hooks.tsx @@ -0,0 +1,87 @@ +import { hasHardwareAsync, isEnrolledAsync } from 'expo-local-authentication' +import { useCallback } from 'react' +import { useDispatch } from 'react-redux' +import { triggerAuthentication } from 'src/features/biometrics/biometricsSlice' +import { isAndroid } from 'utilities/src/platform' + +type TriggerArgs = { + params?: T + successCallback?: (params?: T) => void + failureCallback?: () => void +} + +/** + * Hook shortcut to use the biometric prompt. + * + * It can be used by either declaring the success/failure callbacks at the time you call the hook, + * or by declaring them when you call the trigger function: + * + * Example 1: + * + * ```ts + * const { trigger } = useBiometricPrompt(() => { success() }, () => { failure() }) + * triger({ + * params: { ... }, + * }) + * ``` + * + * Example 2: + * + * ```ts + * const { trigger } = useBiometricPrompt() + * triger({ + * successCallback: () => { success() }, + * failureCallback: () => { success() }, + * params: { ... }, + * }) + * ``` + * + * TODO(MOB-2523): standardize usage of this hook and remove the style of Example 1. + * + * @returns trigger Trigger the OS biometric flow and invokes successCallback on success. + */ +export function useBiometricPrompt( + successCallback?: (params?: T) => void, + failureCallback?: () => void, +): { + trigger: (args?: TriggerArgs) => Promise +} { + const dispatch = useDispatch() + + const trigger = useCallback( + async (args?: TriggerArgs): Promise => { + dispatch( + triggerAuthentication({ + onSuccess: (params?: unknown) => { + const typedParams = params as T | undefined + if (args?.successCallback) { + args.successCallback(typedParams) + } else if (successCallback) { + successCallback(typedParams) + } + }, + onFailure: args?.failureCallback ?? failureCallback, + params: args?.params, + }), + ) + }, + [dispatch, successCallback, failureCallback], + ) + + return { trigger } +} + +// TODO: remove +export const checkOsBiometricAuthEnabled = async (): Promise => { + const [compatible, enrolled] = await Promise.all([hasHardwareAsync(), isEnrolledAsync()]) + return compatible && enrolled +} + +export function useBiometricName(isTouchIdSupported: boolean, shouldCapitalize?: boolean): string { + if (isAndroid) { + return shouldCapitalize ? 'Biometrics' : 'biometrics' + } + + // iOS is always capitalized + return isTouchIdSupported ? 'Touch ID' : 'Face ID' +} diff --git a/apps/mobile/src/features/biometricsSettings/slice.ts b/apps/mobile/src/features/biometricsSettings/slice.ts new file mode 100644 index 00000000..05f2caea --- /dev/null +++ b/apps/mobile/src/features/biometricsSettings/slice.ts @@ -0,0 +1,49 @@ +import { createSlice, type PayloadAction } from '@reduxjs/toolkit' +import { setFinishedOnboarding } from 'wallet/src/features/wallet/slice' + +export enum BiometricSettingType { + RequiredForAppAccess = 0, + RequiredForTransactions = 1, +} + +export interface BiometricSettingsState { + requiredForAppAccess: boolean + requiredForTransactions: boolean +} + +export const initialBiometricsSettingsState: BiometricSettingsState = { + requiredForAppAccess: false, + requiredForTransactions: false, +} + +const slice = createSlice({ + name: 'biometricSettings', + initialState: initialBiometricsSettingsState, + reducers: { + setRequiredForAppAccess: (state, action: PayloadAction) => { + state.requiredForAppAccess = action.payload + }, + setRequiredForTransactions: (state, action: PayloadAction) => { + state.requiredForTransactions = action.payload + }, + resetBiometricSettings: () => initialBiometricsSettingsState, + }, + extraReducers: (builder) => { + builder.addCase(setFinishedOnboarding, (state, action) => { + // disable biometrics if user has no wallets + if (!action.payload.finishedOnboarding) { + state.requiredForAppAccess = false + state.requiredForTransactions = false + } + }) + }, +}) + +export const { setRequiredForAppAccess, setRequiredForTransactions, resetBiometricSettings } = slice.actions + +export const biometricSettingsReducer = slice.reducer + +export const selectBiometricSettings = (state: { biometricSettings: BiometricSettingsState }): BiometricSettingsState => + state.biometricSettings +export const selectRequiredForAppAccess = (state: { biometricSettings: BiometricSettingsState }): boolean => + selectBiometricSettings(state).requiredForAppAccess diff --git a/apps/mobile/src/features/datadog/DatadogContext.tsx b/apps/mobile/src/features/datadog/DatadogContext.tsx new file mode 100644 index 00000000..f5ed523a --- /dev/null +++ b/apps/mobile/src/features/datadog/DatadogContext.tsx @@ -0,0 +1,23 @@ +import { createContext, useContext } from 'react' + +/** + * Until Datadog provides a way to check if the SDK is initialized, this context is + * used to track the status of the SDK. + */ +type DatadogContextType = { + isInitialized: boolean + setInitialized: (initialized: boolean) => void +} + +export const DatadogContext = createContext(undefined) + +/** + * Hook to get the status of the Datadog SDK. + */ +export const useDatadogStatus = (): DatadogContextType => { + const context = useContext(DatadogContext) + if (!context) { + throw new Error('useDatadogStatus must be used within a DatadogProvider') + } + return context +} diff --git a/apps/mobile/src/features/datadog/DatadogProviderWrapper.tsx b/apps/mobile/src/features/datadog/DatadogProviderWrapper.tsx new file mode 100644 index 00000000..972ba888 --- /dev/null +++ b/apps/mobile/src/features/datadog/DatadogProviderWrapper.tsx @@ -0,0 +1,133 @@ +import { + BatchSize, + DatadogProvider, + DatadogProviderConfiguration, + DdRum, + SdkVerbosity, + TrackingConsent, + UploadFrequency, +} from '@datadog/mobile-react-native' +import { type ErrorEventMapper } from '@datadog/mobile-react-native/lib/typescript/rum/eventMappers/errorEventMapper' +import { + DatadogIgnoredErrorsConfigKey, + DatadogIgnoredErrorsValType, + DynamicConfigs, + getDynamicConfigValue, +} from '@universe/gating' +import { PropsWithChildren, default as React, useEffect, useState } from 'react' +import { DatadogContext } from 'src/features/datadog/DatadogContext' +import { config } from 'uniswap/src/config' +import { datadogEnabledBuild, isTestRun, localDevDatadogEnabled } from 'utilities/src/environment/constants' +import { setAttributesToDatadog } from 'utilities/src/logger/datadog/Datadog' +import { getDatadogEnvironment } from 'utilities/src/logger/datadog/env' +import { logger } from 'utilities/src/logger/logger' + +// In case Statsig is not available +export const MOBILE_DEFAULT_DATADOG_SESSION_SAMPLE_RATE = 10 // percent + +// Configuration for Datadog's automatic monitoring features: +// - Error tracking: Captures and reports application errors +// - User interactions: Monitors user events and actions +// - Resource tracking: Traces network requests and API calls +// Note: Can buffer up to 100 RUM events before SDK initialization +// https://docs.datadoghq.com/real_user_monitoring/mobile_and_tv_monitoring/react_native/advanced_configuration/#delaying-the-initialization +const datadogAutoInstrumentation = { + trackErrors: datadogEnabledBuild, + trackInteractions: datadogEnabledBuild, + trackResources: datadogEnabledBuild, +} + +async function initializeDatadog(sessionSamplingRate: number): Promise { + const datadogConfig: DatadogProviderConfiguration = { + clientToken: config.datadogClientToken, + env: getDatadogEnvironment(), + applicationId: config.datadogProjectId, + // @ts-expect-error - Favored getting types from DatadogProviderConfiguration over fixing ths type + trackingConsent: undefined, + site: 'US1', + longTaskThresholdMs: 100, + nativeCrashReportEnabled: true, + verbosity: SdkVerbosity.INFO, + errorEventMapper: (event: ReturnType): ReturnType | null => { + const ignoredErrors = getDynamicConfigValue< + DynamicConfigs.DatadogIgnoredErrors, + DatadogIgnoredErrorsConfigKey, + DatadogIgnoredErrorsValType + >({ + config: DynamicConfigs.DatadogIgnoredErrors, + key: DatadogIgnoredErrorsConfigKey.Errors, + defaultValue: [], + }) + + const ignoredError = ignoredErrors.find(({ messageContains }) => event?.message.includes(messageContains)) + if (ignoredError) { + return Math.random() < ignoredError.sampleRate ? event : null + } + + return event + }, + sessionSamplingRate, + } + + // oxlint-disable-next-line typescript/no-unnecessary-condition + if (localDevDatadogEnabled) { + Object.assign(datadogConfig, { + sessionSamplingRate: 100, + uploadFrequency: UploadFrequency.FREQUENT, + batchSize: BatchSize.SMALL, + verbosity: SdkVerbosity.DEBUG, + trackingConsent: TrackingConsent.GRANTED, + }) + } + + if (config.isE2ETest) { + Object.assign(datadogConfig, { + sessionSamplingRate: 100, + trackingConsent: TrackingConsent.GRANTED, + verbosity: SdkVerbosity.DEBUG, + }) + } + + await DatadogProvider.initialize(datadogConfig) + + setAttributesToDatadog({ + isE2ETest: config.isE2ETest, + }).catch(() => undefined) +} + +/** + * Wrapper component to provide Datadog to the app with our mobile app's + * specific configuration. + */ +export function DatadogProviderWrapper({ + children, + sessionSampleRate, +}: PropsWithChildren<{ sessionSampleRate: number | undefined }>): JSX.Element { + const [isInitialized, setInitialized] = useState(false) + + useEffect(() => { + if ((datadogEnabledBuild || config.isE2ETest) && sessionSampleRate !== undefined) { + initializeDatadog(sessionSampleRate).catch(() => undefined) + } + }, [sessionSampleRate]) + + if (isTestRun) { + return <>{children} + } + logger.setDatadogEnabled(true) + return ( + + { + const sessionId = await DdRum.getCurrentSessionId() + // we do not want to log anything if session is not sampled + logger.setDatadogEnabled(sessionId !== undefined) + setInitialized(true) + }} + > + {children} + + + ) +} diff --git a/apps/mobile/src/features/datadog/user.ts b/apps/mobile/src/features/datadog/user.ts new file mode 100644 index 00000000..7fcc5124 --- /dev/null +++ b/apps/mobile/src/features/datadog/user.ts @@ -0,0 +1,11 @@ +import { DdSdkReactNative } from '@datadog/mobile-react-native' +import { getUniqueIdSync } from 'react-native-device-info' +import { MobileUserPropertyName } from 'uniswap/src/features/telemetry/user' + +export function setDatadogUserWithUniqueId(activeAddress: Maybe
, uniswapIdentifier?: string | null): void { + DdSdkReactNative.setUser({ + id: getUniqueIdSync(), + ...(activeAddress ? { [MobileUserPropertyName.ActiveWalletAddress]: activeAddress } : {}), + ...(uniswapIdentifier ? { [MobileUserPropertyName.UniswapIdentifier]: uniswapIdentifier } : {}), + }).catch(() => undefined) +} diff --git a/apps/mobile/src/features/deepLinking/README.md b/apps/mobile/src/features/deepLinking/README.md new file mode 100644 index 00000000..da1477fe --- /dev/null +++ b/apps/mobile/src/features/deepLinking/README.md @@ -0,0 +1,311 @@ +# Deep Link Support + +The Uniswap mobile app supports various deep link types to enable seamless navigation from external sources. Deep links allow 3rd parties to prompt the app to open to specific screens when it is installed on their device. If the app isn't installed, it will open that page in the browser. + +## Supported Deep Link Types + +The app supports several categories of deep links: + +1. **Universal Links** - Web app share links (`https://app.uniswap.org/...`) +2. **Screen-based Links** - Direct navigation to specific screens (`https://uniswap.org/app?screen=...`) +3. **Protocol Links** - WalletConnect and other protocol integrations +4. **Widget Links** - Embedded widget interactions +5. **Special Function Links** - Fiat on/off-ramp, token details, etc. + +Most screen-based deep links require a valid `userAddress` parameter since the wallet supports multiple imported addresses. + +## Universal Links (Uniswap Web App Share Links) + +These links allow sharing specific content from the Uniswap web app that opens directly in the mobile app if the app is installed. + +### Token Share Links + +Opens a token details page. Supports both `/tokens/` and `/explore/tokens/` paths. + +Format: `https://app.uniswap.org/tokens/{network}/{contractAddress}` or `https://app.uniswap.org/explore/tokens/{network}/{contractAddress}` + +Example: + +```url +https://app.uniswap.org/tokens/ethereum/0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984 +https://app.uniswap.org/explore/tokens/unichain/0x8f187aA05619a017077f5308904739877ce9eA21 +``` + +### Top Tokens Explore Page + +Opens the top tokens page for a specific network with optional metric filtering. + +Format: `https://app.uniswap.org/tokens/{network}?metric={metric}` or `https://app.uniswap.org/explore/tokens/{network}?metric={metric}` + +Parameters: + +- `metric`: the metric to filter the top tokens by. Can be `volume`, `market_cap`, `total_value_locked`, `price_percent_change_1_day_asc`, or `price_percent_change_1_day_desc` + +Example: + +```url +https://app.uniswap.org/explore/tokens/unichain?metric=volume +``` + +### Address/Wallet Links + +Opens a wallet profile page. If the address matches an imported wallet, it switches to that account. Otherwise, it opens the external profile view. + +Format: `https://app.uniswap.org/portfolio/{walletAddress}` + +Example: + +```url +https://app.uniswap.org/portfolio/0x1234567890123456789012345678901234567890 +``` + +### Swap Links + +Opens the swap interface with pre-filled token pairs and amounts. + +Format: `https://app.uniswap.org/swap?inputCurrency={currency}&outputCurrency={currency}&chain={network}&value={amount}&field={INPUT|OUTPUT}` + +Parameters: + +- `inputCurrency`: Input token address, "ETH", "NATIVE", or native token representation +- `outputCurrency`: Output token address, "ETH", "NATIVE", or native token representation +- `chain`: Network name (e.g., "ethereum", "polygon", "arbitrum", "unichain") +- `outputChain`: (Optional) Different output chain for cross-chain swaps +- `value`: (Optional) Amount to swap +- `field`: (Optional) Whether the amount refers to "INPUT" or "OUTPUT" token + +Examples: + +```url +https://app.uniswap.org/swap?inputCurrency=ETH&outputCurrency=0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984&chain=ethereum&value=1&field=INPUT +https://app.uniswap.org/swap?inputCurrency=0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359&outputCurrency=NATIVE&chain=polygon&value=100&field=OUTPUT +``` + +### Buy Links + +Opens the fiat on-ramp interface with pre-filled purchase parameters. + +Format: `https://app.uniswap.org/buy?value={amount}¤cyCode={currency}&isTokenInputMode={boolean}&providers={providers}` + +Parameters: + +- `value`: (Optional) Pre-filled purchase amount +- `currencyCode`: (Optional) Target crypto currency code (e.g. "ETH", "UNI_UNICHAIN", "USDC_BASE") +- `isTokenInputMode`: (Optional) Set to "true" for token input mode, "false" for fiat input mode +- `providers`: (Optional) Comma-separated list of preferred providers. We'll show only quotes from these providers. If no quotes are available from these providers, we'll show quotes from all available providers. + +Examples: + +```url +https://app.uniswap.org/buy?value=100¤cyCode=USDC_UNICHAIN +https://app.uniswap.org/buy?value=0.5¤cyCode=ETH&providers=moonpay,coinbasepay&isTokenInputMode=true +``` + +## Screen-based Deep Links + +These links use query parameters to navigate to specific screens with the prefix `https://uniswap.org/app`. + +### Activity Screen + +Routes to activity screen for given `userAddress`. + +Example: + +```url +https://uniswap.org/app?screen=transaction&userAddress=0x123...789 +``` + +### Fiat On-ramp Return Screen + +Shows transaction details after completing a fiat on-ramp purchase. + +Example: + +```url +https://uniswap.org/app?screen=transaction&userAddress=0x123...789&fiatOnRamp=true +``` + +### Fiat Off-ramp Return Screen + +Shows transaction details after completing a fiat off-ramp sale. + +Example: + +```url +https://uniswap.org/app?screen=transaction&userAddress=0x123...789&fiatOffRamp=true +``` + +### Swap Screen + +Routes to the swap screen with pre-populated swap details. + +Format: `https://uniswap.org/app?screen=swap&userAddress={address}&inputCurrencyId={chainId-tokenAddress}&outputCurrencyId={chainId-tokenAddress}¤cyField={input|output}&amount={amount}` + +Parameters: + +- `userAddress`: The user's wallet address (required) +- `inputCurrencyId`: Input currency in format `{chainId}-{tokenAddress}` +- `outputCurrencyId`: Output currency in format `{chainId}-{tokenAddress}` +- `currencyField`: Either "input" or "output" to specify which amount field is being set +- `amount`: The currency amount to swap + +Examples: + +```url +# Swap 100 Ethereum mainnet DAI for Ethereum mainnet UNI +https://uniswap.org/app?screen=swap&userAddress=0x123...789&inputCurrencyId=1-0x6B175474E89094C44Da98b954EedeAC495271d0F&outputCurrencyId=1-0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984¤cyField=input&amount=100 + +# Swap Polygon DAI for 100 Polygon UNI +https://uniswap.org/app?screen=swap&userAddress=0x123...789&inputCurrencyId=137-0x6B175474E89094C44Da98b954EedeAC495271d0F&outputCurrencyId=137-0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984¤cyField=output&amount=100 +``` + +### Buy Screen + +Routes to the fiat on-ramp interface with pre-filled purchase parameters. + +Format: `https://uniswap.org/app/buy?value={amount}¤cyCode={currency}&isTokenInputMode={boolean}&providers={providers}` + +Parameters: + +- `value`: (Optional) Pre-filled purchase amount. Interpretation depends on `isTokenInputMode`. +- `currencyCode`: (Optional) Target **crypto** currency code (e.g., "ETH", "USDC_UNICHAIN", "USDC_BASE"). This is always the crypto token you want to buy. +- `isTokenInputMode`: (Optional) Controls how `value` is interpreted: + - `false` (default): `value` represents an amount in the **user's app fiat currency** (e.g., if user has USD configured, 100 = $100 worth of crypto; if EUR, 100 = €100 worth). The fiat currency is determined by the user's app settings and falls back to USD if not supported. + - `true`: `value` represents the **crypto token amount** (e.g., 0.5 = 0.5 ETH) +- `providers`: (Optional) Comma-separated list of preferred providers. We'll show only quotes from these providers. If no quotes are available from these providers, we'll show quotes from all available providers. + +Examples: + +```url +# Buy 100 units of user's fiat currency worth of ETH (e.g., $100 if USD, €100 if EUR) +https://uniswap.org/app/buy?value=100¤cyCode=ETH + +# Buy exactly 0.5 ETH (token input mode) +https://uniswap.org/app/buy?value=0.5¤cyCode=ETH&isTokenInputMode=true + +# Buy 250 units of user's fiat currency worth of USDC on Unichain using specific providers +https://uniswap.org/app/buy?value=250¤cyCode=USDC_UNICHAIN&providers=moonpay,coinbasepay +``` + +## Special Function Deep Links + +### Token Details + +Opens a specific token details page using a currency ID of format `{chainId}-{tokenAddress}`. + +Format: `https://uniswap.org/app/tokendetails?currencyId={currencyId}` + +Example: + +```url +https://uniswap.org/app/tokendetails?currencyId=1-0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984 +``` + +### Fiat On-ramp (Legacy) + +Opens the fiat on-ramp modal for purchasing crypto using the legacy format with user address requirements. + +Format: `https://uniswap.org/app/fiatonramp?userAddress={address}&moonpayOnly={boolean}&moonpayCurrencyCode={currency}&amount={amount}` + +Parameters: + +- `userAddress`: (optional if `moonpayOnly=true`) The user's wallet address +- `moonpayOnly`: (optional) Set to `true` to show only Moonpay options +- `moonpayCurrencyCode`: (optional) Currency code for Moonpay (eth, usdc, etc.) +- `amount`: (optional) Pre-filled amount + +Example: + +```url +https://uniswap.org/app/fiatonramp?userAddress=0x123...789&moonpayCurrencyCode=eth&amount=100 +``` + +**Note**: For modern buy links, see [Buy Links](#buy-links) in the Universal Links section or [Buy Screen](#buy-screen) in the Screen-based Deep Links section. + +## Protocol Deep Links + +### WalletConnect + +Multiple formats are supported for WalletConnect deep links: + +1. **Direct WalletConnect URI**: `wc:{uri}` +2. **Uniswap scheme with WalletConnect**: `uniswap://wc:{uri}` +3. **Universal WalletConnect**: `https://uniswap.org/wc?uri={encodedUri}` +4. **WalletConnect as parameter**: `uniswap://wc?uri={encodedUri}` + +### Scantastic (QR Code Scanning) + +Custom protocol for QR code scanning functionality. + +Format: `uniswap://scantastic?{queryParams}` + +### UwU Link + +Custom protocol for transaction requests. + +Format: `uniswap://uwu?{encodedData}` + +### Widget Links + +Deep links from embedded Uniswap widgets. + +Format: `uniswap://widget/{path}` + +## Implementation Details + +This section contains technical implementation details for developers working on deep link handling. + +### Swap Link Implementation + +The app processes swap links through `handleSwapLinkSaga.ts`, which provides robust handling for swap-related deep links with automatic testnet mode detection and error recovery. + +**Features:** + +- **Automatic Parameter Parsing**: Extracts and validates swap parameters from URLs +- **Testnet Mode Alignment**: Automatically detects if the swap involves testnet tokens and prompts for testnet mode switch if needed +- **Graceful Error Handling**: Falls back to opening an empty swap modal if parsing fails +- **Transaction State Creation**: Builds complete swap form state from parsed parameters + +**Process Flow:** + +1. **Parse URL**: Extracts swap parameters using the provided parsing function +2. **Create Form State**: Builds swap transaction state from parsed parameters +3. **Testnet Detection**: Checks if input/output assets are on testnet chains +4. **Mode Alignment**: Compares current testnet mode with required mode +5. **Navigation**: Opens swap modal with pre-filled parameters +6. **Mode Switch**: Prompts testnet switch modal if alignment is needed + +**Error Recovery:** + +If swap link parsing fails, the saga will: + +- Log the error with appropriate context +- Navigate to an empty swap modal as fallback +- Ensure the user can still perform swaps manually + +### Buy Link Implementation + +The app supports fiat on-ramp deep links through the `handleBuyLink()` function, which opens the FiatOnRampAggregator modal with pre-filled purchase parameters. + +**Implementation Features:** + +- **Provider Filtering**: Supports specifying preferred fiat on-ramp providers (converted to uppercase internally) +- **Amount Pre-filling**: Can pre-populate purchase amounts +- **Currency Selection**: Supports different fiat currencies +- **Input Mode Control**: Can specify token vs fiat input mode +- **Modal Management**: Properly dismisses existing modals before navigation + +**Parameter Processing:** + +- `value`: Pre-filled purchase amount +- `currencyCode`: Target currency code (e.g., "USD", "EUR") +- `isTokenInputMode`: Whether to show token input mode (`"true"` or `"false"`) +- `providers`: Comma-separated list of preferred providers (converted to uppercase) + +## Error Handling + +- Invalid or malformed deep links will be logged and ignored +- Missing required parameters will result in navigation to the Home screen +- Unsupported WalletConnect v1 links show an error alert +- Invalid UwU Link requests show appropriate error messages +- Korea-specific restrictions apply to fiat on-ramp links diff --git a/apps/mobile/src/features/deepLinking/configUtils.ts b/apps/mobile/src/features/deepLinking/configUtils.ts new file mode 100644 index 00000000..e182bed8 --- /dev/null +++ b/apps/mobile/src/features/deepLinking/configUtils.ts @@ -0,0 +1,18 @@ +import { DynamicConfigs, getDynamicConfigValue, UwULinkAllowlist, UwuLinkConfigKey } from '@universe/gating' +import { isUwULinkAllowlistType } from 'uniswap/src/features/gating/typeGuards' + +/** + * Gets the UwuLink allowlist from dynamic config. + * This function wraps getDynamicConfigValue for easier testing. + */ +export function getUwuLinkAllowlist(): UwULinkAllowlist { + return getDynamicConfigValue({ + config: DynamicConfigs.UwuLink, + key: UwuLinkConfigKey.Allowlist, + defaultValue: { + contracts: [], + tokenRecipients: [], + }, + customTypeGuard: isUwULinkAllowlistType, + }) +} diff --git a/apps/mobile/src/features/deepLinking/constants.ts b/apps/mobile/src/features/deepLinking/constants.ts new file mode 100644 index 00000000..a7bc49f6 --- /dev/null +++ b/apps/mobile/src/features/deepLinking/constants.ts @@ -0,0 +1,7 @@ +import { uniswapUrls } from 'uniswap/src/constants/urls' + +export const UNISWAP_URL_SCHEME = 'uniswap://' +export const UNISWAP_URL_SCHEME_WALLETCONNECT_AS_PARAM = 'uniswap://wc?uri=' +export const UNISWAP_URL_SCHEME_SCANTASTIC = 'uniswap://scantastic?' +export const UNISWAP_URL_SCHEME_E2E_OVERRIDE_GATES = 'uniswap://e2e/override-gates' +export const UNISWAP_WALLETCONNECT_URL = uniswapUrls.appBaseUrl + '/wc?uri=' diff --git a/apps/mobile/src/features/deepLinking/deepLinkUtils.test.ts b/apps/mobile/src/features/deepLinking/deepLinkUtils.test.ts new file mode 100644 index 00000000..0cc51ce8 --- /dev/null +++ b/apps/mobile/src/features/deepLinking/deepLinkUtils.test.ts @@ -0,0 +1,37 @@ +import { DeepLinkAction, parseDeepLinkUrl } from 'src/features/deepLinking/deepLinkUtils' + +// Mock the logger +jest.mock('utilities/src/logger/logger', () => ({ + logger: { + error: jest.fn(), + }, +})) + +describe('getDeepLinkAction', () => { + it.each` + url | expected + ${'https://app.uniswap.org/app?screen=transaction&fiatOnRamp=true&userAddress=0x123'} | ${DeepLinkAction.UniswapWebLink} + ${'uniswap://wc?uri=wc:123@2?relay-protocol=irn&symKey=51e'} | ${DeepLinkAction.WalletConnectAsParam} + ${'uniswap://wc:123@2?relay-protocol=irn&symKey=51e'} | ${DeepLinkAction.UniswapWalletConnect} + ${'uniswap://widget/#/tokens/ethereum/0x...'} | ${DeepLinkAction.UniswapWidget} + ${'uniswap://scantastic?param=value'} | ${DeepLinkAction.Scantastic} + ${'uniswap://uwulink?param=value'} | ${DeepLinkAction.UwuLink} + ${'https://uniswap.org/app?screen=transaction&fiatOnRamp=true&userAddress=0x123'} | ${DeepLinkAction.ShowTransactionAfterFiatOnRamp} + ${'https://uniswap.org/app?screen=transaction&fiatOffRamp=true&userAddress=0x123'} | ${DeepLinkAction.ShowTransactionAfterFiatOffRampScreen} + ${'https://uniswap.org/app?screen=transaction&userAddress=0x123'} | ${DeepLinkAction.TransactionScreen} + ${'https://uniswap.org/app?screen=swap&userAddress=0x123'} | ${DeepLinkAction.SwapScreen} + ${'uniswap://unsupported'} | ${DeepLinkAction.SkipNonWalletConnect} + ${'https://uniswap.org/app/wc?uri=wc:123'} | ${DeepLinkAction.UniversalWalletConnectLink} + ${'wc:123@2?relay-protocol=irn&symKey=51e'} | ${DeepLinkAction.WalletConnect} + ${'https://uniswap.org/app?screen=unknown'} | ${DeepLinkAction.Unknown} + ${'uniswap://app/fiatonramp?userAddress=0x123&source=push'} | ${DeepLinkAction.FiatOnRampScreen} + ${'uniswap://app/fiatonramp?source=push&moonpayOnly=true&moonpayCurrencyCode=usdc&amount=100'} | ${DeepLinkAction.FiatOnRampScreen} + ${'uniswap://app/tokendetails?currencyId=10-0x6fd9d7ad17242c41f7131d257212c54a0e816691&source=push'} | ${DeepLinkAction.TokenDetails} + ${'https://cryptothegame.com/'} | ${DeepLinkAction.UniswapExternalBrowserLink} + ${'https://support.uniswap.org/hc/en-us/articles/test-article-123'} | ${DeepLinkAction.UniswapExternalBrowserLink} + ${'https://blog.uniswap.org/article'} | ${DeepLinkAction.UniswapExternalBrowserLink} + ${'https://uniswapx.uniswap.org/'} | ${DeepLinkAction.UniswapExternalBrowserLink} + `('url=$url should return expected=$expected', ({ url, expected }) => { + expect(parseDeepLinkUrl(url).action).toEqual(expected) + }) +}) diff --git a/apps/mobile/src/features/deepLinking/deepLinkUtils.ts b/apps/mobile/src/features/deepLinking/deepLinkUtils.ts new file mode 100644 index 00000000..39051f18 --- /dev/null +++ b/apps/mobile/src/features/deepLinking/deepLinkUtils.ts @@ -0,0 +1,359 @@ +import { getScantasticQueryParams } from 'src/components/Requests/ScanSheet/util' +import { UNISWAP_URL_SCHEME_UWU_LINK } from 'src/components/Requests/Uwulink/utils' +import { + UNISWAP_URL_SCHEME, + UNISWAP_URL_SCHEME_E2E_OVERRIDE_GATES, + UNISWAP_URL_SCHEME_SCANTASTIC, + UNISWAP_URL_SCHEME_WALLETCONNECT_AS_PARAM, + UNISWAP_WALLETCONNECT_URL, +} from 'src/features/deepLinking/constants' +import { UNISWAP_WEB_HOSTNAME } from 'uniswap/src/constants/urls' +import { isCurrencyIdValid } from 'uniswap/src/utils/currencyId' +import { logger } from 'utilities/src/logger/logger' + +const UNISWAP_URL_SCHEME_WIDGET = 'uniswap://widget/' +const WALLETCONNECT_URI_SCHEME = 'wc:' // https://eips.ethereum.org/EIPS/eip-1328 + +export enum DeepLinkAction { + UniswapWebLink = 'uniswapWebLink', + UniswapExternalBrowserLink = 'uniswapExternalBrowserLink', + UniswapWalletConnect = 'uniswapWalletConnect', + WalletConnectAsParam = 'walletConnectAsParam', + UniswapWidget = 'uniswapWidget', + Scantastic = 'scantastic', + TransactionScreen = 'transactionScreen', + ShowTransactionAfterFiatOnRamp = 'fiatOnRamp', + ShowTransactionAfterFiatOffRampScreen = 'fiatOffRamp', + SwapScreen = 'swapScreen', + UwuLink = 'uwuLink', + SkipNonWalletConnect = 'skipNonWalletConnect', + UniversalWalletConnectLink = 'universalWalletConnectLink', + WalletConnect = 'walletConnect', + E2EOverrideGates = 'e2eOverrideGates', + Error = 'error', + Unknown = 'unknown', + TokenDetails = 'tokenDetails', + FiatOnRampScreen = 'fiatOnRampScreen', +} + +/** + * Base payload for all deep link actions. + * + * @param url - The URL object of the deep link. + * @param screen - The "screen" to be displayed. This will be superseded + * by using the pathname of the URL instead as "screen" as a query param + * is less flexible and currently navigating to parts of a screen. + * @param source - The source of the deep link such as a push notification + */ +type BasePayload = { url: URL; screen: string; source: string } + +/** + * Payload for all deep link actions that include a WalletConnect URI. + * + * @param wcUri - The WalletConnect URI. + */ +type PayloadWithWcUri = BasePayload & { wcUri: string } + +/** + * Payload for all deep link actions that include a user address. + * + * @param userAddress - The user address. + */ +type PayloadWithUserAddress = BasePayload & { userAddress: string } + +/** + * Payload for all deep link actions that include Scantastic query params. + * + * @param scantasticQueryParams - The Scantastic query params. + */ +type PayloadWithScantasticParams = BasePayload & { scantasticQueryParams: string } + +/** + * Payload for all deep link actions that include fiat onramp params. + * + * @param userAddress - The user address (optional when moonpayOnly is true). + * @param moonpayOnly - Show the moonpay only mode. + * @param moonpayCurrencyCode - The moonpay currency code (eth, usdc, etc). + * @param amount - The input amount to prefill + */ +export type PayloadWithFiatOnRampParams = BasePayload & { + userAddress?: string + moonpayOnly?: boolean + moonpayCurrencyCode?: string + amount?: string +} + +export type DeepLinkActionResult = + | { action: DeepLinkAction.UniswapWebLink; data: BasePayload & { urlPath: string } } + | { action: DeepLinkAction.UniswapExternalBrowserLink; data: BasePayload & { urlPath: string } } + | { action: DeepLinkAction.WalletConnectAsParam; data: PayloadWithWcUri } + | { action: DeepLinkAction.UniswapWalletConnect; data: PayloadWithWcUri } + | { action: DeepLinkAction.UniswapWidget; data: BasePayload } + | { action: DeepLinkAction.Scantastic; data: PayloadWithScantasticParams } + | { action: DeepLinkAction.UwuLink; data: BasePayload } + | { action: DeepLinkAction.ShowTransactionAfterFiatOnRamp; data: PayloadWithUserAddress } + | { action: DeepLinkAction.ShowTransactionAfterFiatOffRampScreen; data: PayloadWithUserAddress } + | { action: DeepLinkAction.TransactionScreen; data: PayloadWithUserAddress } + | { action: DeepLinkAction.SwapScreen; data: PayloadWithUserAddress } + | { action: DeepLinkAction.SkipNonWalletConnect; data: BasePayload } + | { action: DeepLinkAction.UniversalWalletConnectLink; data: PayloadWithWcUri } + | { action: DeepLinkAction.WalletConnect; data: BasePayload & { wcUri: string } } + | { action: DeepLinkAction.TokenDetails; data: BasePayload & { currencyId: string } } + | { action: DeepLinkAction.FiatOnRampScreen; data: PayloadWithFiatOnRampParams } + | { + action: DeepLinkAction.E2EOverrideGates + data: BasePayload & { enable: string[] } + } + | { action: DeepLinkAction.Error; data: BasePayload } + | { action: DeepLinkAction.Unknown; data: BasePayload } + +type DeepLinkHandler = (url: URL, data: BasePayload) => DeepLinkActionResult + +/** + * Parses a deep link URL and returns the action to be taken as well as + * any additional data that may be needed to handle the deep link. + * + * @param urlString - The URL to parse. + */ +// oxlint-disable-next-line complexity +export function parseDeepLinkUrl(urlString: string): DeepLinkActionResult { + const url = new URL(urlString) + const screen = url.searchParams.get('screen') ?? 'other' + const source = url.searchParams.get('source') ?? 'unknown' + const data = { url, screen, source } + + for (const [prefix, handler] of Object.entries(handlers)) { + if (urlString.startsWith(prefix) || url.hostname === prefix) { + return handler(url, data) + } + } + + if (isValidUniswapExternalWebLink(urlString)) { + return { action: DeepLinkAction.UniswapExternalBrowserLink, data: { ...data, urlPath: url.pathname } } + } + + const urlPath = url.pathname + const userAddress = url.searchParams.get('userAddress') ?? undefined + const fiatOnRamp = url.searchParams.get('fiatOnRamp') === 'true' + const fiatOffRamp = url.searchParams.get('fiatOffRamp') === 'true' + + switch (urlPath) { + case '/tokendetails': { + const currencyId = url.searchParams.get('currencyId') + if (!currencyId) { + return logAndReturnError({ + errorMsg: 'No currencyId found', + action: DeepLinkAction.TokenDetails, + urlString, + data, + }) + } + if (!isCurrencyIdValid(currencyId)) { + return logAndReturnError({ + errorMsg: 'Invalid currencyId found', + action: DeepLinkAction.TokenDetails, + urlString, + data, + }) + } + return { + action: DeepLinkAction.TokenDetails, + data: { ...data, currencyId }, + } + } + case '/fiatonramp': { + const moonpayOnly = url.searchParams.get('moonpayOnly') === 'true' + const moonpayCurrencyCode = url.searchParams.get('moonpayCurrencyCode') ?? undefined + const amount = url.searchParams.get('amount') ?? undefined + + if (!moonpayOnly && !userAddress) { + return logAndReturnError({ + errorMsg: `No userAddress or moonpayOnly param found`, + action: DeepLinkAction.FiatOnRampScreen, + urlString, + data, + }) + } + return { + action: DeepLinkAction.FiatOnRampScreen, + data: { ...data, userAddress, moonpayOnly, moonpayCurrencyCode, amount }, + } + } + } + + switch (screen.toLowerCase()) { + case 'transaction': + if (fiatOnRamp) { + if (!userAddress) { + return logAndReturnError({ + errorMsg: 'No userAddress found', + action: DeepLinkAction.ShowTransactionAfterFiatOnRamp, + urlString, + data, + }) + } + return { action: DeepLinkAction.ShowTransactionAfterFiatOnRamp, data: { ...data, userAddress } } + } + if (fiatOffRamp) { + if (!userAddress) { + return logAndReturnError({ + errorMsg: 'No userAddress found', + action: DeepLinkAction.ShowTransactionAfterFiatOffRampScreen, + urlString, + data, + }) + } + return { action: DeepLinkAction.ShowTransactionAfterFiatOffRampScreen, data: { ...data, userAddress } } + } + if (!userAddress) { + return logAndReturnError({ + errorMsg: 'No userAddress found', + action: DeepLinkAction.TransactionScreen, + urlString, + data, + }) + } + return { action: DeepLinkAction.TransactionScreen, data: { ...data, userAddress } } + case 'swap': + if (!userAddress) { + return logAndReturnError({ + errorMsg: 'No userAddress found', + action: DeepLinkAction.SwapScreen, + urlString, + data, + }) + } + return { action: DeepLinkAction.SwapScreen, data: { ...data, userAddress } } + } + + // Skip non-wallet connect deep links after this point + if (urlString.startsWith(UNISWAP_URL_SCHEME)) { + return { action: DeepLinkAction.SkipNonWalletConnect, data } + } + + if (urlString.startsWith(UNISWAP_WALLETCONNECT_URL)) { + const wcUri = urlString.split(UNISWAP_WALLETCONNECT_URL).pop() + return wcUri + ? { action: DeepLinkAction.UniversalWalletConnectLink, data: { ...data, wcUri } } + : logAndReturnError({ + errorMsg: 'No WC URI found', + action: DeepLinkAction.UniversalWalletConnectLink, + urlString, + data, + }) + } + + if (urlString.startsWith(WALLETCONNECT_URI_SCHEME)) { + const wcUri = decodeURIComponent(urlString) + return { action: DeepLinkAction.WalletConnect, data: { ...data, wcUri } } + } + + return { action: DeepLinkAction.Unknown, data } +} +const handlers: Record = { + [UNISWAP_WEB_HOSTNAME]: (url, data) => { + const urlParts = url.href.split(`${UNISWAP_WEB_HOSTNAME}/`) + const urlPath = urlParts.length >= 1 ? urlParts[1] : '' + return { action: DeepLinkAction.UniswapWebLink, data: { ...data, urlPath: urlPath ?? '' } } + }, + [UNISWAP_URL_SCHEME_WALLETCONNECT_AS_PARAM]: (url, data) => { + const wcUri = extractWalletConnectUri(url.href, UNISWAP_URL_SCHEME_WALLETCONNECT_AS_PARAM) + return wcUri + ? { action: DeepLinkAction.WalletConnectAsParam, data: { ...data, wcUri } } + : logAndReturnError({ + errorMsg: 'No WC URI found', + action: DeepLinkAction.WalletConnectAsParam, + urlString: url.href, + data, + }) + }, + [UNISWAP_URL_SCHEME + WALLETCONNECT_URI_SCHEME]: (url, data) => { + const wcUri = extractWalletConnectUri(url.href, UNISWAP_URL_SCHEME) + return wcUri + ? { action: DeepLinkAction.UniswapWalletConnect, data: { ...data, wcUri } } + : logAndReturnError({ + errorMsg: 'No WC URI found', + action: DeepLinkAction.UniswapWalletConnect, + urlString: url.href, + data, + }) + }, + [UNISWAP_URL_SCHEME_WIDGET]: (_, data) => ({ + action: DeepLinkAction.UniswapWidget, + data, + }), + [UNISWAP_URL_SCHEME_SCANTASTIC]: (url, data) => { + const scantasticQueryParams = getScantasticQueryParams(url.href) + return scantasticQueryParams + ? { action: DeepLinkAction.Scantastic, data: { ...data, scantasticQueryParams } } + : logAndReturnError({ + errorMsg: 'No Scantastic query params found', + action: DeepLinkAction.Scantastic, + urlString: url.href, + data, + }) + }, + [UNISWAP_URL_SCHEME_UWU_LINK]: (_, data) => ({ + action: DeepLinkAction.UwuLink, + data, + }), + [UNISWAP_URL_SCHEME_E2E_OVERRIDE_GATES]: (url, data) => { + const enableParam = url.searchParams.get('enable') ?? url.searchParams.get('gates') + const enable = enableParam ? enableParam.split(',').filter(Boolean) : [] + return { action: DeepLinkAction.E2EOverrideGates, data: { ...data, enable } } + }, +} + +const UNISWAP_EXTERNAL_WEB_LINK_VALID_REGEXES = [ + // oxlint-disable-next-line security/detect-unsafe-regex + /^https:\/\/([a-zA-Z0-9-]+)\.uniswap\.org(\/.*)?$/, + // oxlint-disable-next-line security/detect-unsafe-regex + /^https:\/\/cryptothegame\.com(\/.*)?$/, +] + +function isValidUniswapExternalWebLink(urlString: string): boolean { + return UNISWAP_EXTERNAL_WEB_LINK_VALID_REGEXES.some((regex) => regex.test(urlString)) +} + +/** + * Extracts the WalletConnect URI from a URL string. + * + * Example: "uri=wc:af098@2?relay-protocol=irn&symKey=51e" + * + * @param urlString - The URL string to extract the WalletConnect URI from. + * @param prefix - The prefix to split the URL string by. + * @returns The WalletConnect URI or null if not found. + */ +function extractWalletConnectUri(urlString: string, prefix: string): string | null { + const wcUri = urlString.split(prefix)[1] + return wcUri ? decodeURIComponent(wcUri) : null +} + +/** + * Logs an error and returns an error action result. + * + * @param errorMsg - The error message to log. + * @param action - The action to return. + * @param urlString - The URL string that caused the error. + * @param data - The data associated with the deep link. + * @returns The error action result. + */ +function logAndReturnError({ + errorMsg, + action, + urlString, + data, +}: { + errorMsg: string + action: DeepLinkAction + urlString: string + data: DeepLinkActionResult['data'] +}): DeepLinkActionResult { + logger.error(`${errorMsg} for action=${action} in deep link url=${urlString}`, { + tags: { + file: 'deepLinkUtils', + function: 'parseDeepLinkUrl', + }, + }) + return { action: DeepLinkAction.Error, data } +} diff --git a/apps/mobile/src/features/deepLinking/handleDeepLinkSaga.test.ts b/apps/mobile/src/features/deepLinking/handleDeepLinkSaga.test.ts new file mode 100644 index 00000000..65a0a50b --- /dev/null +++ b/apps/mobile/src/features/deepLinking/handleDeepLinkSaga.test.ts @@ -0,0 +1,565 @@ +import { expectSaga, RunResult } from 'redux-saga-test-plan' +import { call, delay } from 'redux-saga/effects' +import { navigationRef } from 'src/app/navigation/navigationRef' +import { navigate } from 'src/app/navigation/rootNavigation' +import { DeepLinkAction } from 'src/features/deepLinking/deepLinkUtils' +import { + handleDeepLink, + handleGoToTokenDetailsDeepLink, + handleWalletConnectDeepLink, + ONRAMP_DEEPLINK_DELAY, + parseAndValidateUserAddress, +} from 'src/features/deepLinking/handleDeepLinkSaga' +import { handleOnRampReturnLink } from 'src/features/deepLinking/handleOnRampReturnLinkSaga' +import { handleTransactionLink } from 'src/features/deepLinking/handleTransactionLinkSaga' +import { handleUniswapAppDeepLink } from 'src/features/deepLinking/handleUniswapAppDeepLink' +import { LinkSource } from 'src/features/deepLinking/types' +import { openModal } from 'src/features/modals/modalSlice' +import { waitForWcWeb3WalletIsReady } from 'src/features/walletConnect/walletConnectClient' +import { UNISWAP_WEB_URL } from 'uniswap/src/constants/urls' +import { MobileEventName, ModalName } from 'uniswap/src/features/telemetry/constants' +import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { + SAMPLE_CURRENCY_ID_1, + SAMPLE_CURRENCY_ID_2, + SAMPLE_SEED_ADDRESS_1, + SAMPLE_SEED_ADDRESS_2, +} from 'uniswap/src/test/fixtures' +import { MobileScreens } from 'uniswap/src/types/screens/mobile' +import { setAccountAsActive } from 'wallet/src/features/wallet/slice' +import { signerMnemonicAccount } from 'wallet/src/test/fixtures' + +jest.mock('@walletconnect/utils', () => ({ + parseUri: jest.fn(() => ({ + version: 2, + })), +})) +jest.mock('expo-web-browser', () => ({ + dismissBrowser: jest.fn(), + openBrowserAsync: jest.fn(), + WebBrowserPresentationStyle: { + FULL_SCREEN: 'fullScreen', + }, +})) +jest.mock('@universe/gating', () => ({ + ...jest.requireActual('@universe/gating'), + getStatsigClient: jest.fn(() => ({ + checkGate: jest.fn(() => false), // Always return false to avoid Korea gate redirects + })), + getFeatureFlag: jest.fn(() => false), // Default to false for feature flags +})) + +const account = signerMnemonicAccount() + +const swapUrl = `https://uniswap.org/app?screen=swap&userAddress=${account.address}&inputCurrencyId=${SAMPLE_CURRENCY_ID_1}&outputCurrencyId=${SAMPLE_CURRENCY_ID_2}¤cyField=INPUT` +const transactionUrl = `https://uniswap.org/app?screen=transaction&userAddress=${account.address}` +const swapDeepLinkPayload = { url: swapUrl, coldStart: false } +const transactionDeepLinkPayload = { url: transactionUrl, coldStart: false } +const unsupportedScreenDeepLinkPayload = { + url: `https://uniswap.org/app?screen=send&userAddress=${account.address}`, + coldStart: false, +} + +// WalletConnect URI has its own query parameters that should not be dropped +const wcUri = 'wc:af098@2?relay-protocol=irn&symKey=51e' +// oxlint-disable-next-line jest/no-export -- suppressed +export const wcUniversalLinkUrl = `https://uniswap.org/app/wc?uri=${wcUri}` +// oxlint-disable-next-line jest/no-export -- suppressed +export const wcAsParamInUniwapScheme = `uniswap://wc?uri=${wcUri}` +// oxlint-disable-next-line jest/no-export -- suppressed +export const wcInUniwapScheme = `uniswap://${wcUri}` +const invalidUrlSchemeUrl = `uniswap://invalid?param=pepe` + +const stateWithActiveAccountAddress = { + wallet: { + accounts: { + [account.address]: account, + }, + activeAccountAddress: account.address, + }, + userSettings: { + isTestnetModeEnabled: false, + currentLanguage: 'en-US', + currentCurrency: 'USD', + hideSmallBalances: false, + hideSpamTokens: true, + hapticsEnabled: true, + }, +} + +describe(handleDeepLink, () => { + beforeAll(() => { + jest.spyOn(navigationRef, 'isReady').mockReturnValue(true) + jest.spyOn(navigationRef, 'navigate').mockReturnValue(undefined) + jest.spyOn(navigationRef, 'getState').mockReturnValue({ + key: 'root', + index: 0, + routes: [{ name: MobileScreens.Home, key: 'home' }], + routeNames: [MobileScreens.Home], + history: [], + type: 'stack', + stale: false, + }) + jest.spyOn(navigationRef, 'canGoBack').mockReturnValue(false) + }) + + it('Routes to the swap deep link handler if screen=swap and userAddress is valid', () => { + const payload = swapDeepLinkPayload + return expectSaga(handleDeepLink, { payload, type: '' }) + .withState(stateWithActiveAccountAddress) + .call(parseAndValidateUserAddress, account.address) + .put(setAccountAsActive(account.address)) + .call(sendAnalyticsEvent, MobileEventName.DeepLinkOpened, { + action: DeepLinkAction.SwapScreen, + url: payload.url, + screen: 'swap', + is_cold_start: payload.coldStart, + source: 'unknown', + }) + .silentRun() + }) + + it('Routes to the transaction deep link handler if screen=transaction and userAddress is valid', () => { + const payload = transactionDeepLinkPayload + return expectSaga(handleDeepLink, { payload, type: '' }) + .withState(stateWithActiveAccountAddress) + .call(handleTransactionLink) + .call(sendAnalyticsEvent, MobileEventName.DeepLinkOpened, { + action: DeepLinkAction.TransactionScreen, + url: payload.url, + screen: 'transaction', + is_cold_start: payload.coldStart, + source: 'unknown', + }) + .silentRun() + }) + + it('Fails if the screen param is not supported', () => { + jest.spyOn(console, 'error').mockImplementation(() => undefined) + + const payload = unsupportedScreenDeepLinkPayload + return expectSaga(handleDeepLink, { payload, type: '' }) + .withState(stateWithActiveAccountAddress) + .call(sendAnalyticsEvent, MobileEventName.DeepLinkOpened, { + action: DeepLinkAction.Unknown, + url: payload.url, + screen: 'send', + is_cold_start: payload.coldStart, + source: 'unknown', + }) + .silentRun() + }) + + it('Fails if the userAddress does not exist in the wallet', () => { + return expectSaga(handleDeepLink, { payload: swapDeepLinkPayload, type: '' }) + .withState({ + wallet: { + accounts: {}, + activeAccountAddress: null, + }, + }) + .returns(undefined) + .silentRun() + }) + + it('Handles WalletConnect connection using Universal Link URL', () => { + const payload = { url: wcUniversalLinkUrl, coldStart: false } + return expectSaga(handleDeepLink, { + payload, + type: '', + }) + .withState(stateWithActiveAccountAddress) + .provide([[call(waitForWcWeb3WalletIsReady), undefined]]) + .call(handleWalletConnectDeepLink, wcUri) + .call(sendAnalyticsEvent, MobileEventName.DeepLinkOpened, { + action: DeepLinkAction.UniversalWalletConnectLink, + url: payload.url, + screen: 'other', + is_cold_start: payload.coldStart, + source: 'unknown', + }) + .returns(undefined) + .silentRun() + }) + + it('Handles WalletConnect connection using Uniswap URL scheme with WalletConnect URI as query param', () => { + const payload = { url: wcAsParamInUniwapScheme, coldStart: false } + return expectSaga(handleDeepLink, { + payload, + type: '', + }) + .withState(stateWithActiveAccountAddress) + .provide([[call(waitForWcWeb3WalletIsReady), undefined]]) + .call(handleWalletConnectDeepLink, wcUri) + .call(sendAnalyticsEvent, MobileEventName.DeepLinkOpened, { + action: DeepLinkAction.WalletConnectAsParam, + url: payload.url, + screen: 'other', + is_cold_start: payload.coldStart, + source: 'unknown', + }) + .returns(undefined) + .silentRun() + }) + + it('Handles WalletConnect connection using Uniswap URL scheme with WalletConnect URI', () => { + const payload = { url: wcInUniwapScheme, coldStart: false } + return expectSaga(handleDeepLink, { + payload, + type: '', + }) + .withState(stateWithActiveAccountAddress) + .provide([[call(waitForWcWeb3WalletIsReady), undefined]]) + .call(handleWalletConnectDeepLink, wcUri) + .call(sendAnalyticsEvent, MobileEventName.DeepLinkOpened, { + action: DeepLinkAction.UniswapWalletConnect, + url: payload.url, + screen: 'other', + is_cold_start: payload.coldStart, + source: 'unknown', + }) + .returns(undefined) + .silentRun() + }) + + it('Handles WalletConnect connection using WalletConnect URI', () => { + const payload = { url: wcUri, coldStart: false } + return expectSaga(handleDeepLink, { + payload, + type: '', + }) + .withState(stateWithActiveAccountAddress) + .provide([[call(waitForWcWeb3WalletIsReady), undefined]]) + .call(handleWalletConnectDeepLink, wcUri) + .call(sendAnalyticsEvent, MobileEventName.DeepLinkOpened, { + action: DeepLinkAction.WalletConnect, + url: payload.url, + screen: 'other', + is_cold_start: payload.coldStart, + source: 'unknown', + }) + .silentRun() + }) + + it('Fails arbitrary URL scheme deep link', () => { + return expectSaga(handleDeepLink, { + payload: { url: invalidUrlSchemeUrl, coldStart: false }, + type: '', + }) + .withState(stateWithActiveAccountAddress) + .returns(undefined) + .silentRun() + }) + + it('Handles Share Token Item Universal Link', async () => { + const path = `tokens/ethereum/${SAMPLE_SEED_ADDRESS_1}` + const pathUrl = `${UNISWAP_WEB_URL}/${path}` + const hashedUrl = `${UNISWAP_WEB_URL}/#/${path}` + const expectedModalState = { + currencyId: `1-${SAMPLE_SEED_ADDRESS_1}`, + } + + await expectSaga(handleDeepLink, { + payload: { + url: hashedUrl, + coldStart: false, + }, + type: '', + }) + .withState(stateWithActiveAccountAddress) + .call(handleUniswapAppDeepLink, { + path: `#/${path}`, + url: hashedUrl, + linkSource: LinkSource.Share, + }) + .call(navigate, MobileScreens.TokenDetails, expectedModalState) + .returns(undefined) + .silentRun() + + await expectSaga(handleDeepLink, { + payload: { + url: pathUrl, + coldStart: false, + }, + type: '', + }) + .withState(stateWithActiveAccountAddress) + .call(handleUniswapAppDeepLink, { + path, + url: pathUrl, + linkSource: LinkSource.Share, + }) + .call(navigate, MobileScreens.TokenDetails, expectedModalState) + .returns(undefined) + .silentRun() + }) + + it('Handles Share currently active Account Address Universal Link', () => { + const hash = `#/portfolio/${account.address}` + const url = `${UNISWAP_WEB_URL}/${hash}` + return expectSaga(handleDeepLink, { + payload: { + url, + coldStart: false, + }, + type: '', + }) + .withState(stateWithActiveAccountAddress) + .call(handleUniswapAppDeepLink, { + path: hash, + url, + linkSource: LinkSource.Share, + }) + .returns(undefined) + .silentRun() + }) + + it('Handles Share already added Account Address Universal Link', () => { + const hash = `#/portfolio/${SAMPLE_SEED_ADDRESS_2}` + const url = `${UNISWAP_WEB_URL}/${hash}` + return expectSaga(handleDeepLink, { + payload: { + url, + coldStart: false, + }, + type: '', + }) + .withState({ + wallet: { + accounts: { + [account.address]: account, + [SAMPLE_SEED_ADDRESS_2]: account, + }, + activeAccountAddress: account.address, + }, + }) + .call(handleUniswapAppDeepLink, { + path: hash, + url, + linkSource: LinkSource.Share, + }) + .put(setAccountAsActive(SAMPLE_SEED_ADDRESS_2)) + .returns(undefined) + .silentRun() + }) + + it('Handles Share external Account Address Universal Link', async () => { + const path = `portfolio/${SAMPLE_SEED_ADDRESS_2}` + const pathUrl = `${UNISWAP_WEB_URL}/${path}` + const hashedUrl = `${UNISWAP_WEB_URL}/#/${path}` + const expectedModalState = { + address: SAMPLE_SEED_ADDRESS_2, + } + + await expectSaga(handleDeepLink, { + payload: { + url: hashedUrl, + coldStart: false, + }, + type: '', + }) + .withState(stateWithActiveAccountAddress) + .call(handleUniswapAppDeepLink, { + path: `#/${path}`, + url: hashedUrl, + linkSource: LinkSource.Share, + }) + .call(navigate, MobileScreens.ExternalProfile, expectedModalState) + .returns(undefined) + .silentRun() + + await expectSaga(handleDeepLink, { + payload: { + url: pathUrl, + coldStart: false, + }, + type: '', + }) + .withState(stateWithActiveAccountAddress) + .call(handleUniswapAppDeepLink, { + path, + url: pathUrl, + linkSource: LinkSource.Share, + }) + .call(navigate, MobileScreens.ExternalProfile, expectedModalState) + .returns(undefined) + .silentRun() + }) + + it('Handles show transaction after fiat onramp', () => { + const payload = { + url: `https://uniswap.org/app?screen=transaction&fiatOnRamp=true&userAddress=${account.address}`, + coldStart: false, + } + return expectSaga(handleDeepLink, { + payload, + type: '', + }) + .withState(stateWithActiveAccountAddress) + .call(handleOnRampReturnLink) + .call(sendAnalyticsEvent, MobileEventName.DeepLinkOpened, { + action: DeepLinkAction.ShowTransactionAfterFiatOnRamp, + url: payload.url, + screen: 'transaction', + is_cold_start: payload.coldStart, + source: 'unknown', + }) + .returns(undefined) + .silentRun() + }) + it('Handles show transaction after fiat off ramp', () => { + const payload = { + url: `https://uniswap.org/app?screen=transaction&fiatOffRamp=true&userAddress=${account.address}`, + coldStart: false, + } + return ( + expectSaga(handleDeepLink, { + payload, + type: '', + }) + .withState(stateWithActiveAccountAddress) + // FIXME: The URL object is the same URL but a different instance + // .call(handleOffRampReturnLink, new URL(payload.url)) + .call(sendAnalyticsEvent, MobileEventName.DeepLinkOpened, { + action: DeepLinkAction.ShowTransactionAfterFiatOffRampScreen, + url: payload.url, + screen: 'transaction', + is_cold_start: payload.coldStart, + source: 'unknown', + }) + .returns(undefined) + .silentRun() + ) + }) + it('Handles show transaction', () => { + const payload = { + url: `https://uniswap.org/app?screen=transaction&userAddress=${account.address}`, + coldStart: false, + } + return expectSaga(handleDeepLink, { + payload, + type: '', + }) + .withState(stateWithActiveAccountAddress) + .call(handleTransactionLink) + .call(sendAnalyticsEvent, MobileEventName.DeepLinkOpened, { + action: DeepLinkAction.TransactionScreen, + url: payload.url, + screen: 'transaction', + is_cold_start: payload.coldStart, + source: 'unknown', + }) + .returns(undefined) + .silentRun() + }) + + it('Handles showing token details for a token', () => { + const payload = { + url: `uniswap://app/tokendetails?currencyId=${SAMPLE_CURRENCY_ID_1}&source=push`, + coldStart: false, + } + return expectSaga(handleDeepLink, { + payload, + type: '', + }) + .withState(stateWithActiveAccountAddress) + .call(handleGoToTokenDetailsDeepLink, SAMPLE_CURRENCY_ID_1) + .call(sendAnalyticsEvent, MobileEventName.DeepLinkOpened, { + action: DeepLinkAction.TokenDetails, + url: payload.url, + screen: 'other', + is_cold_start: payload.coldStart, + source: 'push', + }) + .returns(undefined) + .silentRun() + }) + + it('Handles showing fiat onramp', () => { + const payload = { + url: `uniswap://app/fiatonramp?userAddress=${account.address}&source=push`, + coldStart: false, + } + return expectSaga(handleDeepLink, { + payload, + type: '', + }) + .withState(stateWithActiveAccountAddress) + .provide([[delay(ONRAMP_DEEPLINK_DELAY), undefined]]) + .call(parseAndValidateUserAddress, account.address) + .put(setAccountAsActive(account.address)) + .call(sendAnalyticsEvent, MobileEventName.DeepLinkOpened, { + action: DeepLinkAction.FiatOnRampScreen, + url: payload.url, + screen: 'other', + is_cold_start: payload.coldStart, + source: 'push', + }) + .returns(undefined) + .silentRun() + }) + + it('Handles MoonPay exclusive fiat onramp deeplink', () => { + const payload = { + url: `uniswap://app/fiatonramp?moonpayOnly=true&source=moonpay-ad`, + coldStart: false, + } + return expectSaga(handleDeepLink, { + payload, + type: '', + }) + .withState(stateWithActiveAccountAddress) + .provide([[delay(ONRAMP_DEEPLINK_DELAY), undefined]]) + .put( + openModal({ + name: ModalName.FiatOnRampAggregator, + initialState: { + providers: ['MOONPAY'], + prefilledAmount: undefined, + currencyCode: undefined, + }, + }), + ) + .call(sendAnalyticsEvent, MobileEventName.DeepLinkOpened, { + action: DeepLinkAction.FiatOnRampScreen, + url: payload.url, + screen: 'other', + is_cold_start: payload.coldStart, + source: 'moonpay-ad', + }) + .returns(undefined) + .silentRun() + }) + + it('Handles MoonPay exclusive fiat onramp deeplink with token and amount', () => { + const payload = { + url: `uniswap://app/fiatonramp?moonpayOnly=true&moonpayCurrencyCode=eth&amount=100&source=moonpay-ad`, + coldStart: false, + } + return expectSaga(handleDeepLink, { + payload, + type: '', + }) + .withState(stateWithActiveAccountAddress) + .provide([[delay(ONRAMP_DEEPLINK_DELAY), undefined]]) + .put( + openModal({ + name: ModalName.FiatOnRampAggregator, + initialState: { + providers: ['MOONPAY'], + prefilledAmount: '100', + currencyCode: 'eth', + }, + }), + ) + .call(sendAnalyticsEvent, MobileEventName.DeepLinkOpened, { + action: DeepLinkAction.FiatOnRampScreen, + url: payload.url, + screen: 'other', + is_cold_start: payload.coldStart, + source: 'moonpay-ad', + }) + .returns(undefined) + .silentRun() + }) +}) diff --git a/apps/mobile/src/features/deepLinking/handleDeepLinkSaga.ts b/apps/mobile/src/features/deepLinking/handleDeepLinkSaga.ts new file mode 100644 index 00000000..d69881fe --- /dev/null +++ b/apps/mobile/src/features/deepLinking/handleDeepLinkSaga.ts @@ -0,0 +1,348 @@ +import { createAction } from '@reduxjs/toolkit' +import { FeatureFlags, getFeatureFlagName, getOverrideAdapter, getStatsigClient } from '@universe/gating' +import { parseUri } from '@walletconnect/utils' +import { Alert } from 'react-native' +import { navigate } from 'src/app/navigation/rootNavigation' +import { parseScantasticParams } from 'src/components/Requests/ScanSheet/util' +import { + getFormattedUwuLinkTxnRequest, + isAllowedUwuLinkRequest, + parseUwuLinkDataFromDeeplink, +} from 'src/components/Requests/Uwulink/utils' +import { getUwuLinkAllowlist } from 'src/features/deepLinking/configUtils' +import { + DeepLinkAction, + DeepLinkActionResult, + PayloadWithFiatOnRampParams, + parseDeepLinkUrl, +} from 'src/features/deepLinking/deepLinkUtils' +import { handleOffRampReturnLink } from 'src/features/deepLinking/handleOffRampReturnLinkSaga' +import { handleOnRampReturnLink } from 'src/features/deepLinking/handleOnRampReturnLinkSaga' +import { handleSwapLink } from 'src/features/deepLinking/handleSwapLinkSaga' +import { handleTransactionLink } from 'src/features/deepLinking/handleTransactionLinkSaga' +import { handleUniswapAppDeepLink } from 'src/features/deepLinking/handleUniswapAppDeepLink' +import { parseSwapLinkMobileFormatOrThrow } from 'src/features/deepLinking/parseSwapLink' +import { LinkSource } from 'src/features/deepLinking/types' +import { closeAllModals, openModal } from 'src/features/modals/modalSlice' +import { pairWithWalletConnectURI } from 'src/features/walletConnect/utils' +import { waitForWcWeb3WalletIsReady } from 'src/features/walletConnect/walletConnectClient' +import { addRequest, setDidOpenFromDeepLink } from 'src/features/walletConnect/walletConnectSlice' +import { call, delay, put, select, takeLatest } from 'typed-redux-saga' +import { config } from 'uniswap/src/config' +import { AccountType } from 'uniswap/src/features/accounts/types' +import { MobileEventName, ModalName } from 'uniswap/src/features/telemetry/constants' +import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import i18n from 'uniswap/src/i18n' +import { MobileScreens } from 'uniswap/src/types/screens/mobile' +import { UwULinkRequest } from 'uniswap/src/types/walletConnect' +import { openUri } from 'uniswap/src/utils/linking' +import { logger } from 'utilities/src/logger/logger' +import { isAndroid } from 'utilities/src/platform' +import { ScantasticParams } from 'wallet/src/features/scantastic/types' +import { getContractManager, getProviderManager } from 'wallet/src/features/wallet/context' +import { selectAccounts, selectActiveAccount } from 'wallet/src/features/wallet/selectors' +import { setAccountAsActive } from 'wallet/src/features/wallet/slice' + +interface DeepLink { + url: string + coldStart: boolean +} + +export const ONRAMP_DEEPLINK_DELAY = 1500 + +export const openDeepLink = createAction('deeplink/open') + +export function* deepLinkWatcher() { + yield* takeLatest(openDeepLink.type, handleDeepLink) +} + +// oxlint-disable-next-line complexity +export function* handleDeepLink(action: ReturnType) { + try { + const { coldStart } = action.payload + const deepLinkAction = parseDeepLinkUrl(action.payload.url) + + // Handle E2E gate overrides early, before account check, since tests may call this at any point + if (deepLinkAction.action === DeepLinkAction.E2EOverrideGates) { + yield* call(handleE2EOverrideGates, deepLinkAction.data) + return + } + + const activeAccount = yield* select(selectActiveAccount) + + if (!activeAccount) { + if (deepLinkAction.action === DeepLinkAction.UniswapWebLink) { + yield* call(openUri, { uri: deepLinkAction.data.url.toString(), openExternalBrowser: true }) + } + // If there is no active account, we don't want to handle other deep links + } else { + switch (deepLinkAction.action) { + case DeepLinkAction.UniswapWebLink: { + yield* call(handleUniswapAppDeepLink, { + path: deepLinkAction.data.urlPath, + url: deepLinkAction.data.url.href, + linkSource: LinkSource.Share, + }) + break + } + case DeepLinkAction.UniswapExternalBrowserLink: { + yield* call(openUri, { uri: deepLinkAction.data.url.toString(), openExternalBrowser: true }) + break + } + case DeepLinkAction.WalletConnectAsParam: + case DeepLinkAction.UniswapWalletConnect: { + yield* call(handleWalletConnectDeepLink, deepLinkAction.data.wcUri) + break + } + case DeepLinkAction.UniswapWidget: { + yield* call(handleUniswapAppDeepLink, { + path: deepLinkAction.data.url.hash, + url: deepLinkAction.data.url.toString(), + linkSource: LinkSource.Widget, + }) + break + } + case DeepLinkAction.Scantastic: { + yield* call(handleScantasticDeepLink, deepLinkAction.data.scantasticQueryParams) + break + } + case DeepLinkAction.UwuLink: { + yield* call(handleUwuLinkDeepLink, deepLinkAction.data.url.toString()) + break + } + case DeepLinkAction.TransactionScreen: + case DeepLinkAction.ShowTransactionAfterFiatOnRamp: + case DeepLinkAction.ShowTransactionAfterFiatOffRampScreen: + case DeepLinkAction.SwapScreen: { + const validUserAddress = yield* call(parseAndValidateUserAddress, deepLinkAction.data.userAddress) + yield* put(setAccountAsActive(validUserAddress)) + switch (deepLinkAction.action) { + case DeepLinkAction.TransactionScreen: { + yield* call(handleTransactionLink) + break + } + case DeepLinkAction.ShowTransactionAfterFiatOnRamp: { + yield* call(handleOnRampReturnLink) + break + } + case DeepLinkAction.ShowTransactionAfterFiatOffRampScreen: { + yield* call(handleOffRampReturnLink, deepLinkAction.data.url) + break + } + case DeepLinkAction.SwapScreen: { + yield* call(handleSwapLink, deepLinkAction.data.url, parseSwapLinkMobileFormatOrThrow) + break + } + } + break + } + case DeepLinkAction.SkipNonWalletConnect: { + // Set didOpenFromDeepLink so that `returnToPreviousApp()` is enabled during WalletConnect flows + yield* put(setDidOpenFromDeepLink(true)) + break + } + case DeepLinkAction.UniversalWalletConnectLink: { + yield* call(handleWalletConnectDeepLink, deepLinkAction.data.wcUri) + break + } + case DeepLinkAction.WalletConnect: { + yield* call(handleWalletConnectDeepLink, deepLinkAction.data.wcUri) + break + } + case DeepLinkAction.FiatOnRampScreen: { + if (deepLinkAction.data.userAddress) { + const validUserAddress = yield* call(parseAndValidateUserAddress, deepLinkAction.data.userAddress) + yield* put(setAccountAsActive(validUserAddress)) + } + yield* call(handleGoToFiatOnRampDeepLink, deepLinkAction.data) + break + } + case DeepLinkAction.TokenDetails: { + yield* put(closeAllModals()) + yield* call(handleGoToTokenDetailsDeepLink, deepLinkAction.data.currencyId) + break + } + case DeepLinkAction.Unknown: + case DeepLinkAction.Error: { + break + } + } + } + + // Send analytics event consistently regardless of whether there's an active account + yield* _sendAnalyticsEvent(deepLinkAction, coldStart) + } catch (error) { + yield* call(logger.error, error, { + tags: { file: 'handleDeepLinkSaga', function: 'handleDeepLink' }, + extra: { coldStart: action.payload.coldStart, url: action.payload.url }, + }) + } +} + +function* _sendAnalyticsEvent(deepLinkAction: DeepLinkActionResult, coldStart: boolean) { + yield* call(sendAnalyticsEvent, MobileEventName.DeepLinkOpened, { + action: deepLinkAction.action, + url: deepLinkAction.data.url.toString(), + screen: deepLinkAction.data.screen, + is_cold_start: coldStart, + source: deepLinkAction.data.source, + }) +} + +function* handleGoToFiatOnRampDeepLink(data: PayloadWithFiatOnRampParams) { + const disableForKorea = getStatsigClient().checkGate(getFeatureFlagName(FeatureFlags.DisableFiatOnRampKorea)) + if (disableForKorea) { + navigate(ModalName.KoreaCexTransferInfoModal) + } else { + const { moonpayOnly, amount, moonpayCurrencyCode } = data + + // Add delay to fix android onramp issue causing isSheetReady to remain false + yield* delay(isAndroid ? ONRAMP_DEEPLINK_DELAY : 0) + + yield* put( + openModal({ + name: ModalName.FiatOnRampAggregator, + initialState: { + providers: moonpayOnly ? ['MOONPAY'] : undefined, + prefilledAmount: amount, + currencyCode: moonpayCurrencyCode, + }, + }), + ) + } +} + +export function* handleGoToTokenDetailsDeepLink(currencyId: string) { + yield* call(navigate, MobileScreens.TokenDetails, { + currencyId, + }) +} + +export function* handleWalletConnectDeepLink(wcUri: string) { + yield* call(waitForWcWeb3WalletIsReady) + + const wcUriVersion = parseUri(wcUri).version + + if (wcUriVersion === 1) { + Alert.alert( + i18n.t('walletConnect.error.unsupportedV1.title'), + i18n.t('walletConnect.error.unsupportedV1.message'), + [{ text: i18n.t('common.button.ok') }], + ) + return + } + + if (wcUriVersion === 2) { + try { + yield* call(pairWithWalletConnectURI, wcUri) + } catch (error) { + logger.error(error, { + tags: { file: 'handleDeepLinkSaga', function: 'handleWalletConnectDeepLink' }, + extra: { wcUri }, + }) + Alert.alert(i18n.t('walletConnect.error.general.title'), i18n.t('walletConnect.error.general.message')) + } + } + + // Set didOpenFromDeepLink so that `returnToPreviousApp()` is enabled during WalletConnect flows + yield* put(setDidOpenFromDeepLink(true)) +} + +export function* parseAndValidateUserAddress(userAddress: string | null) { + if (!userAddress) { + throw new Error('No `userAddress` provided') + } + + const userAccounts = yield* select(selectAccounts) + const matchingAccount = Object.values(userAccounts).find( + (account) => account.address.toLowerCase() === userAddress.toLowerCase(), + ) + + if (!matchingAccount) { + throw new Error('User address supplied in path does not exist in wallet') + } + + return matchingAccount.address +} + +function* handleScantasticDeepLink(scantasticQueryParams: string): Generator { + const params = parseScantasticParams(scantasticQueryParams) + const scantasticEnabled = getStatsigClient().checkGate(getFeatureFlagName(FeatureFlags.Scantastic)) + + if (!params || !scantasticEnabled) { + Alert.alert(i18n.t('walletConnect.error.scantastic.title'), i18n.t('walletConnect.error.scantastic.message'), [ + { text: i18n.t('common.button.ok') }, + ]) + return + } + + yield* call(launchScantastic, params) +} + +function* launchScantastic(params: ScantasticParams): Generator { + yield* put(closeAllModals()) + yield* call(navigate, ModalName.Scantastic, { params }) +} + +function* handleUwuLinkDeepLink(uri: string): Generator { + try { + const decodedUri = decodeURIComponent(uri) + const uwulinkData = parseUwuLinkDataFromDeeplink(decodedUri) + const parsedUwulinkRequest: UwULinkRequest = JSON.parse(uwulinkData) + + const uwuLinkAllowList = getUwuLinkAllowlist() + + const activeAccount = yield* select(selectActiveAccount) + const isSignerAccount = activeAccount?.type === AccountType.SignerMnemonic + const isAllowed = isAllowedUwuLinkRequest(parsedUwulinkRequest, uwuLinkAllowList) + + if (!isAllowed || !isSignerAccount) { + Alert.alert( + i18n.t('walletConnect.error.uwu.title'), + !isAllowed ? i18n.t('walletConnect.error.uwu.unsupported') : i18n.t('account.wallet.viewOnly.title'), + [ + { + text: i18n.t('common.button.ok'), + }, + ], + ) + return + } + + const providerManager = yield* call(getProviderManager) + const contractManager = yield* call(getContractManager) + + const uwuLinkTxnRequest = yield* call(getFormattedUwuLinkTxnRequest, { + request: parsedUwulinkRequest, + activeAccount, + allowList: { + contracts: [], + tokenRecipients: [], + }, + providerManager, + contractManager, + }) + + yield* put(addRequest(uwuLinkTxnRequest.request)) + } catch { + Alert.alert(i18n.t('walletConnect.error.uwu.title'), i18n.t('walletConnect.error.uwu.scan'), [ + { + text: i18n.t('common.button.ok'), + }, + ]) + return + } +} + +function handleE2EOverrideGates({ enable }: { enable: string[] }): void { + if (!config.isE2ETest) { + return + } + + const overrideAdapter = getOverrideAdapter() + overrideAdapter.removeAllOverrides() + + for (const gate of enable) { + overrideAdapter.overrideGate(gate, true) + } +} diff --git a/apps/mobile/src/features/deepLinking/handleLuxAppDeepLink.test.ts b/apps/mobile/src/features/deepLinking/handleLuxAppDeepLink.test.ts new file mode 100644 index 00000000..9decd186 --- /dev/null +++ b/apps/mobile/src/features/deepLinking/handleLuxAppDeepLink.test.ts @@ -0,0 +1,702 @@ +import { call, put } from 'redux-saga/effects' +import { expectSaga } from 'redux-saga-test-plan' +import { navigate } from 'src/app/navigation/rootNavigation' +import { handleTopTokensDeepLink } from 'src/features/deepLinking/handleTopTokensDeepLink' +import { handleLuxAppDeepLink } from 'src/features/deepLinking/handleLuxAppDeepLink' +import { LinkSource } from 'src/features/deepLinking/types' +import { openModal } from 'src/features/modals/modalSlice' +import { UniverseChainId } from '@l.x/lx/src/features/chains/types' +import { fromLuxWebAppLink } from '@l.x/lx/src/features/chains/utils' +import { BACKEND_NATIVE_CHAIN_ADDRESS_STRING } from '@l.x/lx/src/features/search/utils' +import { MobileEventName, ModalName } from '@l.x/lx/src/features/telemetry/constants' +import { sendAnalyticsEvent } from '@l.x/lx/src/features/telemetry/send' +import { MobileScreens } from '@l.x/lx/src/types/screens/mobile' +import { ShareableEntity } from '@l.x/lx/src/types/sharing' +import { WidgetType } from '@l.x/lx/src/types/widgets' +import { buildCurrencyId, buildNativeCurrencyId } from '@l.x/lx/src/utils/currencyId' +import { setAccountAsActive } from '@luxfi/wallet/src/features/wallet/slice' +import { signerMnemonicAccount } from '@luxfi/wallet/src/test/fixtures' + +const account = signerMnemonicAccount() +const SAMPLE_CONTRACT_ADDRESS = '0x1234567890abcdef1234567890abcdef12345678' +const SAMPLE_CONTRACT_ADDRESS_2 = '0xabcdef1234567890abcdef1234567890abcdef12' +const SAMPLE_TOKEN_ID = '123' +const SAMPLE_CURRENCY_ID = '1-0x1234567890abcdef1234567890abcdef12345678' +const SAMPLE_NATIVE_CURRENCY_ID = '1-ETH' + +const stateWithAccounts = { + wallet: { + accounts: { + [account.address]: account, + [SAMPLE_CONTRACT_ADDRESS_2]: account, + }, + activeAccountAddress: account.address, + }, +} + +describe('handleLxAppDeepLink', () => { + describe('Token deep links', () => { + it('should handle token share with contract address', () => { + const path = `tokens/ethereum/${SAMPLE_CONTRACT_ADDRESS}` + const url = `https://app.lux.org/${path}` + + return expectSaga(handleLuxAppDeepLink, { + path, + url, + linkSource: LinkSource.Share, + }) + .provide([ + [call(fromLuxWebAppLink, 'ethereum'), UniverseChainId.Mainnet], + [call(buildCurrencyId, UniverseChainId.Mainnet, SAMPLE_CONTRACT_ADDRESS), SAMPLE_CURRENCY_ID], + [ + call(navigate, MobileScreens.TokenDetails, { + currencyId: SAMPLE_CURRENCY_ID, + }), + undefined, + ], + [ + call(sendAnalyticsEvent, MobileEventName.ShareLinkOpened, { + entity: ShareableEntity.Token, + url, + }), + undefined, + ], + ]) + .run() + }) + + it('should handle token share with native currency', () => { + const path = `tokens/ethereum/${BACKEND_NATIVE_CHAIN_ADDRESS_STRING}` + const url = `https://app.lux.org/${path}` + + return expectSaga(handleLuxAppDeepLink, { + path, + url, + linkSource: LinkSource.Share, + }) + .provide([ + [call(fromLuxWebAppLink, 'ethereum'), UniverseChainId.Mainnet], + [call(buildNativeCurrencyId, UniverseChainId.Mainnet), SAMPLE_NATIVE_CURRENCY_ID], + [ + call(navigate, MobileScreens.TokenDetails, { + currencyId: SAMPLE_NATIVE_CURRENCY_ID, + }), + undefined, + ], + [ + call(sendAnalyticsEvent, MobileEventName.ShareLinkOpened, { + entity: ShareableEntity.Token, + url, + }), + undefined, + ], + ]) + .run() + }) + + it('should handle widget token link', () => { + const path = `tokens/ethereum/${SAMPLE_CONTRACT_ADDRESS}` + const url = `https://app.lux.org/${path}` + + return expectSaga(handleLuxAppDeepLink, { + path, + url, + linkSource: LinkSource.Widget, + }) + .provide([ + [call(fromLuxWebAppLink, 'ethereum'), UniverseChainId.Mainnet], + [call(buildCurrencyId, UniverseChainId.Mainnet, SAMPLE_CONTRACT_ADDRESS), SAMPLE_CURRENCY_ID], + [ + call(navigate, MobileScreens.TokenDetails, { + currencyId: SAMPLE_CURRENCY_ID, + }), + undefined, + ], + [ + call(sendAnalyticsEvent, MobileEventName.WidgetClicked, { + widget_type: WidgetType.TokenPrice, + url, + }), + undefined, + ], + ]) + .run() + }) + + it('should throw error for token link with invalid network', () => { + const path = `tokens/invalid/${SAMPLE_CONTRACT_ADDRESS}` + const url = `https://app.lux.org/${path}` + + return expect( + expectSaga(handleLuxAppDeepLink, { + path, + url, + linkSource: LinkSource.Share, + }).run(), + ).rejects.toThrow('Network "invalid" can not be mapped') + }) + }) + + describe('Top Tokens deep links', () => { + it('should handle explore top tokens with chain', () => { + const path = 'explore/tokens/unichain' + const url = `https://app.lux.org/${path}?metric=volume` + + return expectSaga(handleLuxAppDeepLink, { + path, + url, + linkSource: LinkSource.Share, + }) + .provide([ + [call(fromLuxWebAppLink, 'unichain'), UniverseChainId.Unichain], + [ + call(handleTopTokensDeepLink, { + chainId: UniverseChainId.Unichain, + url, + }), + undefined, + ], + ]) + .run() + }) + + it('should handle top tokens with chain', () => { + const path = 'tokens/ethereum' + const url = `https://app.lux.org/${path}?metric=volume` + + return expectSaga(handleLuxAppDeepLink, { + path, + url, + linkSource: LinkSource.Share, + }) + .provide([ + [call(fromLuxWebAppLink, 'ethereum'), UniverseChainId.Mainnet], + [ + call(handleTopTokensDeepLink, { + chainId: UniverseChainId.Mainnet, + url, + }), + undefined, + ], + ]) + .run() + }) + + it('should handle explore top tokens without chain', () => { + const path = 'explore/tokens' + const url = `https://app.lux.org/${path}?metric=volume` + + return expectSaga(handleLuxAppDeepLink, { + path, + url, + linkSource: LinkSource.Share, + }) + .provide([ + [ + call(handleTopTokensDeepLink, { + chainId: undefined, + url, + }), + undefined, + ], + ]) + .run() + }) + + it('should handle top tokens without chain', () => { + const path = 'tokens' + const url = `https://app.lux.org/${path}` + + return expectSaga(handleLuxAppDeepLink, { + path, + url, + linkSource: LinkSource.Share, + }) + .provide([ + [ + call(handleTopTokensDeepLink, { + chainId: undefined, + url, + }), + undefined, + ], + ]) + .run() + }) + }) + + describe('Address deep links', () => { + it('should handle external address share', () => { + const externalAddress = '0x1234567890abcdef1234567890abcdef12345679' + const path = `portfolio/${externalAddress}` + const url = `https://app.lx.org/${path}` + + return expectSaga(handleLuxAppDeepLink, { + path, + url, + linkSource: LinkSource.Share, + }) + .withState(stateWithAccounts) + .provide([ + [ + call(navigate, MobileScreens.ExternalProfile, { + address: externalAddress, + }), + undefined, + ], + [ + call(sendAnalyticsEvent, MobileEventName.ShareLinkOpened, { + entity: ShareableEntity.Wallet, + url, + }), + undefined, + ], + ]) + .run() + }) + + it('should handle internal address share by switching to that account', () => { + const path = `portfolio/${SAMPLE_CONTRACT_ADDRESS_2}` + const url = `https://app.lx.org/${path}` + + return expectSaga(handleLuxAppDeepLink, { + path, + url, + linkSource: LinkSource.Share, + }) + .withState(stateWithAccounts) + .provide([ + [ + call(sendAnalyticsEvent, MobileEventName.ShareLinkOpened, { + entity: ShareableEntity.Wallet, + url, + }), + undefined, + ], + ]) + .put(setAccountAsActive(SAMPLE_CONTRACT_ADDRESS_2)) + .run() + }) + + it('should not handle active account address', () => { + const path = `portfolio/${account.address}` + const url = `https://app.lx.org/${path}` + + return expectSaga(handleLuxAppDeepLink, { + path, + url, + linkSource: LinkSource.Share, + }) + .withState(stateWithAccounts) + .provide([ + [ + call(sendAnalyticsEvent, MobileEventName.ShareLinkOpened, { + entity: ShareableEntity.Wallet, + url, + }), + undefined, + ], + ]) + .not.call.fn(navigate) + .not.put.actionType(setAccountAsActive.type) + .run() + }) + + it('should handle portfolio share', () => { + const externalAddress = '0x1234567890abcdef1234567890abcdef12345679' + const path = `portfolio/${externalAddress}` + const url = `https://app.lx.org/${path}` + + return expectSaga(handleLxAppDeepLink, { + path, + url, + linkSource: LinkSource.Share, + }) + .withState(stateWithAccounts) + .provide([ + [ + call(navigate, MobileScreens.ExternalProfile, { + address: externalAddress, + }), + undefined, + ], + [ + call(sendAnalyticsEvent, MobileEventName.ShareLinkOpened, { + entity: ShareableEntity.Wallet, + url, + }), + undefined, + ], + ]) + .run() + }) + }) + + describe('Edge cases and invalid paths', () => { + it('should not handle unrecognized paths', () => { + const path = 'unknown/path' + const url = `https://app.lux.org/${path}` + + return expectSaga(handleLuxAppDeepLink, { + path, + url, + linkSource: LinkSource.Share, + }) + .not.call.fn(navigate) + .not.call.fn(handleTopTokensDeepLink) + .run() + }) + + it('should not handle address with invalid format', () => { + const path = 'portfolio/invalid-address' + const url = `https://app.lx.org/${path}` + + return expectSaga(handleLuxAppDeepLink, { + path, + url, + linkSource: LinkSource.Share, + }) + .not.call.fn(navigate) + .run() + }) + }) + + describe('Buy deep links', () => { + it('should handle buy link with value and currencyCode', () => { + const path = 'buy' + const url = 'https://app.lux.org/buy?value=3¤cyCode=ETH' + + return expectSaga(handleLuxAppDeepLink, { + path, + url, + linkSource: LinkSource.Share, + }) + .provide([ + [ + put( + openModal({ + name: ModalName.FiatOnRampAggregator, + initialState: { + prefilledAmount: '3', + currencyCode: 'ETH', + prefilledIsTokenInputMode: false, + providers: undefined, + }, + }), + ), + undefined, + ], + ]) + .run() + }) + + it('should handle buy link with only value', () => { + const path = 'buy' + const url = 'https://app.lux.org/buy?value=100' + + return expectSaga(handleLuxAppDeepLink, { + path, + url, + linkSource: LinkSource.Share, + }) + .provide([ + [ + put( + openModal({ + name: ModalName.FiatOnRampAggregator, + initialState: { + prefilledAmount: '100', + currencyCode: undefined, + prefilledIsTokenInputMode: false, + providers: undefined, + }, + }), + ), + undefined, + ], + ]) + .run() + }) + + it('should handle buy link with only currencyCode', () => { + const path = 'buy' + const url = 'https://app.lux.org/buy?currencyCode=USDC' + + return expectSaga(handleLuxAppDeepLink, { + path, + url, + linkSource: LinkSource.Share, + }) + .provide([ + [ + put( + openModal({ + name: ModalName.FiatOnRampAggregator, + initialState: { + prefilledAmount: undefined, + currencyCode: 'USDC', + prefilledIsTokenInputMode: false, + providers: undefined, + }, + }), + ), + undefined, + ], + ]) + .run() + }) + + it('should handle buy link with no parameters', () => { + const path = 'buy' + const url = 'https://app.lux.org/buy' + + return expectSaga(handleLuxAppDeepLink, { + path, + url, + linkSource: LinkSource.Share, + }) + .provide([ + [ + put( + openModal({ + name: ModalName.FiatOnRampAggregator, + initialState: { + prefilledAmount: undefined, + currencyCode: undefined, + prefilledIsTokenInputMode: false, + providers: undefined, + }, + }), + ), + undefined, + ], + ]) + .run() + }) + + it('should handle buy link with isTokenInputMode=true', () => { + const path = 'buy' + const url = 'https://app.lux.org/buy?value=50¤cyCode=BTC&isTokenInputMode=true' + + return expectSaga(handleLuxAppDeepLink, { + path, + url, + linkSource: LinkSource.Share, + }) + .provide([ + [ + put( + openModal({ + name: ModalName.FiatOnRampAggregator, + initialState: { + prefilledAmount: '50', + currencyCode: 'BTC', + prefilledIsTokenInputMode: true, + providers: undefined, + }, + }), + ), + undefined, + ], + ]) + .run() + }) + + it('should handle buy link with isTokenInputMode=false', () => { + const path = 'buy' + const url = 'https://app.lux.org/buy?value=25¤cyCode=USDT&isTokenInputMode=false' + + return expectSaga(handleLuxAppDeepLink, { + path, + url, + linkSource: LinkSource.Share, + }) + .provide([ + [ + put( + openModal({ + name: ModalName.FiatOnRampAggregator, + initialState: { + prefilledAmount: '25', + currencyCode: 'USDT', + prefilledIsTokenInputMode: false, + providers: undefined, + }, + }), + ), + undefined, + ], + ]) + .run() + }) + + it('should handle buy link with only isTokenInputMode parameter', () => { + const path = 'buy' + const url = 'https://app.lux.org/buy?isTokenInputMode=true' + + return expectSaga(handleLuxAppDeepLink, { + path, + url, + linkSource: LinkSource.Share, + }) + .provide([ + [ + put( + openModal({ + name: ModalName.FiatOnRampAggregator, + initialState: { + prefilledAmount: undefined, + currencyCode: undefined, + prefilledIsTokenInputMode: true, + providers: undefined, + }, + }), + ), + undefined, + ], + ]) + .run() + }) + + it('should handle buy link with single provider', () => { + const path = 'buy' + const url = 'https://app.lux.org/buy?providers=moonpay' + + return expectSaga(handleLuxAppDeepLink, { + path, + url, + linkSource: LinkSource.Share, + }) + .provide([ + [ + put( + openModal({ + name: ModalName.FiatOnRampAggregator, + initialState: { + prefilledAmount: undefined, + currencyCode: undefined, + prefilledIsTokenInputMode: false, + providers: ['MOONPAY'], + }, + }), + ), + undefined, + ], + ]) + .run() + }) + + it('should handle buy link with multiple providers', () => { + const path = 'buy' + const url = 'https://app.lux.org/buy?providers=moonpay,coinbasepay,stripe' + + return expectSaga(handleLuxAppDeepLink, { + path, + url, + linkSource: LinkSource.Share, + }) + .provide([ + [ + put( + openModal({ + name: ModalName.FiatOnRampAggregator, + initialState: { + prefilledAmount: undefined, + currencyCode: undefined, + prefilledIsTokenInputMode: false, + providers: ['MOONPAY', 'COINBASEPAY', 'STRIPE'], + }, + }), + ), + undefined, + ], + ]) + .run() + }) + + it('should handle buy link with providers in mixed case (converted to uppercase)', () => { + const path = 'buy' + const url = 'https://app.lux.org/buy?providers=MoonPay,coinbasepay,STRIPE' + + return expectSaga(handleLuxAppDeepLink, { + path, + url, + linkSource: LinkSource.Share, + }) + .provide([ + [ + put( + openModal({ + name: ModalName.FiatOnRampAggregator, + initialState: { + prefilledAmount: undefined, + currencyCode: undefined, + prefilledIsTokenInputMode: false, + providers: ['MOONPAY', 'COINBASEPAY', 'STRIPE'], + }, + }), + ), + undefined, + ], + ]) + .run() + }) + + it('should handle buy link with providers and other parameters', () => { + const path = 'buy' + const url = + 'https://app.lux.org/buy?value=100¤cyCode=ETH&isTokenInputMode=true&providers=moonpay,coinbasepay' + + return expectSaga(handleLuxAppDeepLink, { + path, + url, + linkSource: LinkSource.Share, + }) + .provide([ + [ + put( + openModal({ + name: ModalName.FiatOnRampAggregator, + initialState: { + prefilledAmount: '100', + currencyCode: 'ETH', + prefilledIsTokenInputMode: true, + providers: ['MOONPAY', 'COINBASEPAY'], + }, + }), + ), + undefined, + ], + ]) + .run() + }) + + it('should handle buy link with empty providers parameter', () => { + const path = 'buy' + const url = 'https://app.lux.org/buy?providers=' + + return expectSaga(handleLuxAppDeepLink, { + path, + url, + linkSource: LinkSource.Share, + }) + .provide([ + [ + put( + openModal({ + name: ModalName.FiatOnRampAggregator, + initialState: { + prefilledAmount: undefined, + currencyCode: undefined, + prefilledIsTokenInputMode: false, + providers: [], + }, + }), + ), + undefined, + ], + ]) + .run() + }) + }) +}) diff --git a/apps/mobile/src/features/deepLinking/handleLuxAppDeepLink.ts b/apps/mobile/src/features/deepLinking/handleLuxAppDeepLink.ts new file mode 100644 index 00000000..59007f64 --- /dev/null +++ b/apps/mobile/src/features/deepLinking/handleLuxAppDeepLink.ts @@ -0,0 +1,171 @@ +import { fiatOnRampNavigationRef } from 'src/app/navigation/navigationRef' +import { navigate } from 'src/app/navigation/rootNavigation' +import { handleSwapLink } from 'src/features/deepLinking/handleSwapLinkSaga' +import { handleTopTokensDeepLink } from 'src/features/deepLinking/handleTopTokensDeepLink' +import { parseSwapLinkWebFormatOrThrow } from 'src/features/deepLinking/parseSwapLink' +import { LinkSource } from 'src/features/deepLinking/types' +import { dismissAllModalsBeforeNavigation } from 'src/features/deepLinking/utils' +import { openModal } from 'src/features/modals/modalSlice' +import { call, put, select } from 'typed-redux-saga' +import { fromLuxWebAppLink } from '@l.x/lx/src/features/chains/utils' +import { BACKEND_NATIVE_CHAIN_ADDRESS_STRING } from '@l.x/lx/src/features/search/utils' +import { MobileEventName, ModalName } from '@l.x/lx/src/features/telemetry/constants' +import { sendAnalyticsEvent } from '@l.x/lx/src/features/telemetry/send' +import { MobileScreens } from '@l.x/lx/src/types/screens/mobile' +import { ShareableEntity } from '@l.x/lx/src/types/sharing' +import { WidgetType } from '@l.x/lx/src/types/widgets' +import { buildCurrencyId, buildNativeCurrencyId } from '@l.x/lx/src/utils/currencyId' +import { selectAccounts, selectActiveAccountAddress } from '@luxfi/wallet/src/features/wallet/selectors' +import { setAccountAsActive } from '@luxfi/wallet/src/features/wallet/slice' + +const TOKEN_SHARE_LINK_HASH_REGEX = RegExp( + `^(#/)?(?:explore/)?tokens/([\\w\\d]*)/(0x[a-fA-F0-9]{40}|${BACKEND_NATIVE_CHAIN_ADDRESS_STRING})$`, +) +const TOP_TOKENS_LINK_CHAIN_REGEX = /^(?:explore\/)?tokens\/([\w\d]+)/ +const TOP_TOKENS_LINK_REGEX = /^(?:explore\/)?tokens/ +const ADDRESS_SHARE_LINK_HASH_REGEX = /^(#\/)?portfolio\/(0x[a-fA-F0-9]{40})$/ +const SWAP_LINK_HASH_REGEX = /^\/?swap(?:\?)?/ +const BUY_LINK_HASH_REGEX = /^\/?buy(?:\?)?/ + +export function* handleLuxAppDeepLink({ + path, + url, + linkSource, +}: { + path: string + url: string + linkSource: LinkSource +}): Generator { + // Handle Buy links (ex. https://app.lux.org/buy?value=3¤cyCode=ETH) + if (BUY_LINK_HASH_REGEX.test(path)) { + const urlObj = new URL(url) + yield* call(handleBuyLink, urlObj) + return + } + + // Handle Swap links (ex. https://app.lux.org/swap?inputCurrency=ETH&outputCurrency=0x...) + if (SWAP_LINK_HASH_REGEX.test(path)) { + const urlObj = new URL(url) + yield* call(handleSwapLink, urlObj, parseSwapLinkWebFormatOrThrow) + return + } + + // Handle Token share (ex. https://app.lx.org/tokens/ethereum/0x... or https://app.lx.org/explore/tokens/arbitrum/0x...) + if (TOKEN_SHARE_LINK_HASH_REGEX.test(path)) { + yield* call(handleTokenShare, { path, url, linkSource }) + return + } + + // Handle Top Tokens page with or without explore and chain path: + // ex. https://app.lux.org/tokens/unichain?metric=volume or https://app.lux.org/explore/tokens/base?metric=market_cap + // or https://app.lux.org/tokens?metric=volume or https://app.lux.org/explore/tokens?metric=market_cap + if (TOP_TOKENS_LINK_CHAIN_REGEX.test(path) || TOP_TOKENS_LINK_REGEX.test(path)) { + const [, network] = path.match(TOP_TOKENS_LINK_CHAIN_REGEX) || [] + const chainId = network ? fromLuxWebAppLink(network) : undefined + + yield* call(handleTopTokensDeepLink, { chainId, url }) + return + } + + // Handle Address share (ex. https://app.lux.org/address/0x...) + if (ADDRESS_SHARE_LINK_HASH_REGEX.test(path)) { + yield* call(handleAddressShare, { path, url }) + return + } +} + +function* handleTokenShare({ + path, + url, + linkSource, +}: { + path: string + url: string + linkSource: LinkSource +}): Generator { + const [, , network, contractAddress] = path.match(TOKEN_SHARE_LINK_HASH_REGEX) || [] + const chainId = network && fromLuxWebAppLink(network) + + if (!chainId || !contractAddress) { + return + } + + yield* call(dismissAllModalsBeforeNavigation) + + const currencyId = + contractAddress === BACKEND_NATIVE_CHAIN_ADDRESS_STRING + ? buildNativeCurrencyId(chainId) + : buildCurrencyId(chainId, contractAddress) + yield* call(navigate, MobileScreens.TokenDetails, { + currencyId, + }) + if (linkSource === LinkSource.Share) { + yield* call(sendAnalyticsEvent, MobileEventName.ShareLinkOpened, { + entity: ShareableEntity.Token, + url, + }) + } else { + yield* call(sendAnalyticsEvent, MobileEventName.WidgetClicked, { + widget_type: WidgetType.TokenPrice, + url, + }) + } +} + +function* handleAddressShare({ path, url }: { path: string; url: string }): Generator { + const [, , accountAddress] = path.match(ADDRESS_SHARE_LINK_HASH_REGEX) || [] + if (!accountAddress) { + return + } + const accounts = yield* select(selectAccounts) + const activeAccountAddress = yield* select(selectActiveAccountAddress) + if (accountAddress === activeAccountAddress) { + return + } + + const isInternal = Boolean(accounts[accountAddress]) + + yield* call(dismissAllModalsBeforeNavigation) + + if (isInternal) { + yield* put(setAccountAsActive(accountAddress)) + } else { + yield* call(navigate, MobileScreens.ExternalProfile, { + address: accountAddress, + }) + } + yield* call(sendAnalyticsEvent, MobileEventName.ShareLinkOpened, { + entity: ShareableEntity.Wallet, + url, + }) + return +} + +function* handleBuyLink(urlObj: URL): Generator { + const searchParams = urlObj.searchParams + const value = searchParams.get('value') + const currencyCode = searchParams.get('currencyCode') + const isTokenInputMode = searchParams.get('isTokenInputMode') + const providers = searchParams + .get('providers') + ?.split(',') + .map((provider) => provider.trim()) + .filter(Boolean) + .map((provider) => provider.toUpperCase()) + + const ref = fiatOnRampNavigationRef.current + if (!ref || !ref.isFocused()) { + yield* call(dismissAllModalsBeforeNavigation) + } + yield* put( + openModal({ + name: ModalName.FiatOnRampAggregator, + initialState: { + prefilledAmount: value || undefined, + currencyCode: currencyCode || undefined, + prefilledIsTokenInputMode: isTokenInputMode === 'true', + providers, + }, + }), + ) +} diff --git a/apps/mobile/src/features/deepLinking/handleOffRampReturnLinkSaga.ts b/apps/mobile/src/features/deepLinking/handleOffRampReturnLinkSaga.ts new file mode 100644 index 00000000..b73153a6 --- /dev/null +++ b/apps/mobile/src/features/deepLinking/handleOffRampReturnLinkSaga.ts @@ -0,0 +1,113 @@ +import { Alert } from 'react-native' +import { navigate } from 'src/app/navigation/rootNavigation' +import { openModal } from 'src/features/modals/modalSlice' +import { dismissInAppBrowser } from 'src/utils/linking' +import { call, put } from 'typed-redux-saga' +import { AssetType, TradeableAsset } from 'uniswap/src/entities/assets' +import { UniverseChainId } from 'uniswap/src/features/chains/types' +import { FiatOffRampMetaData, OffRampTransferDetailsResponse } from 'uniswap/src/features/fiatOnRamp/types' +import { FiatOffRampEventName, ModalName } from 'uniswap/src/features/telemetry/constants' +import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { TransactionScreen } from 'uniswap/src/features/transactions/components/TransactionModal/TransactionModalContext' +import { forceFetchFiatOnRampTransactions } from 'uniswap/src/features/transactions/slice' +import i18n from 'uniswap/src/i18n' +import { CurrencyField } from 'uniswap/src/types/currency' +import { MobileScreens } from 'uniswap/src/types/screens/mobile' +import { createTransactionId } from 'uniswap/src/utils/createTransactionId' +import { logger } from 'utilities/src/logger/logger' +import { fetchOffRampTransferDetails } from 'wallet/src/features/fiatOnRamp/api' + +export function* handleOffRampReturnLink(url: URL) { + try { + yield* call(_handleOffRampReturnLink, url) + } catch { + Alert.alert(i18n.t('fiatOffRamp.error.populateSend.title'), i18n.t('fiatOffRamp.error.populateSend.description')) + } +} + +function* _handleOffRampReturnLink(url: URL) { + const externalTransactionId = url.searchParams.get('externalTransactionId') + const currencyCode = url.searchParams.get('baseCurrencyCode') + const currencyAmount = url.searchParams.get('baseCurrencyAmount') + const walletAddress = url.searchParams.get('depositWalletAddress') + + const hasValidMoonpayData = currencyCode && currencyAmount && walletAddress + if (!externalTransactionId && !hasValidMoonpayData) { + throw new Error('Missing externalTransactionId or moonpay data in fiat offramp deep link') + } + + let offRampTransferDetails: OffRampTransferDetailsResponse | undefined + + try { + offRampTransferDetails = yield* call(fetchOffRampTransferDetails, { + sessionId: externalTransactionId, + baseCurrencyCode: currencyCode, + baseCurrencyAmount: Number(currencyAmount), + depositWalletAddress: walletAddress, + }) + } catch (error) { + logger.error(error, { + tags: { file: 'handleOffRampReturnLinkSaga', function: 'handleOffRampReturnLink' }, + extra: { url: url.toString() }, + }) + throw new Error('Failed to fetch offramp transfer details') + } + + if ( + !offRampTransferDetails.tokenAddress || + !offRampTransferDetails.baseCurrencyCode || + !offRampTransferDetails.depositWalletAddress + ) { + throw new Error('Missing offRampTransferDetails in fiat offramp deep link') + } + + const { tokenAddress, baseCurrencyCode, baseCurrencyAmount, depositWalletAddress, logos, provider, chainId } = + offRampTransferDetails + + const analyticsProperties = { + cryptoCurrency: baseCurrencyCode, + currencyAmount: baseCurrencyAmount, + serviceProvider: provider, + chainId, + externalTransactionId, + } + + sendAnalyticsEvent(FiatOffRampEventName.FiatOffRampWidgetCompleted, analyticsProperties) + + const currencyTradeableAsset: TradeableAsset = { + address: tokenAddress, + chainId: Number(chainId) as UniverseChainId, + type: AssetType.Currency, + } + + const fiatOffRampMetaData: FiatOffRampMetaData = { + name: provider, + logoUrl: logos?.lightLogo ?? '', + onSubmitCallback: (amountUSD?: number) => { + sendAnalyticsEvent(FiatOffRampEventName.FiatOffRampFundsSent, { ...analyticsProperties, amountUSD }) + }, + moonpayCurrencyCode: baseCurrencyCode, + meldCurrencyCode: baseCurrencyCode, + } + + const txnId = createTransactionId() + + const initialSendState = { + txId: txnId, + [CurrencyField.INPUT]: currencyTradeableAsset, + [CurrencyField.OUTPUT]: null, + exactCurrencyField: CurrencyField.INPUT, + exactAmountToken: baseCurrencyAmount.toString(), + focusOnCurrencyField: null, + recipient: depositWalletAddress, + isFiatInput: false, + showRecipientSelector: false, + fiatOffRampMetaData, + sendScreen: TransactionScreen.Review, + } + + yield* put(forceFetchFiatOnRampTransactions()) + yield* call(navigate, MobileScreens.Home) + yield* put(openModal({ name: ModalName.Send, initialState: initialSendState })) + yield* call(dismissInAppBrowser) +} diff --git a/apps/mobile/src/features/deepLinking/handleOnRampReturnLinkSaga.test.ts b/apps/mobile/src/features/deepLinking/handleOnRampReturnLinkSaga.test.ts new file mode 100644 index 00000000..c1435916 --- /dev/null +++ b/apps/mobile/src/features/deepLinking/handleOnRampReturnLinkSaga.test.ts @@ -0,0 +1,20 @@ +import { call, put } from '@redux-saga/core/effects' +import { expectSaga } from 'redux-saga-test-plan' +import { navigate } from 'src/app/navigation/rootNavigation' +import { handleOnRampReturnLink } from 'src/features/deepLinking/handleOnRampReturnLinkSaga' +import { HomeScreenTabIndex } from 'src/screens/HomeScreen/HomeScreenTabIndex' +import { dismissInAppBrowser } from 'src/utils/linking' +import { forceFetchFiatOnRampTransactions } from 'uniswap/src/features/transactions/slice' +import { MobileScreens } from 'uniswap/src/types/screens/mobile' + +describe(handleOnRampReturnLink, () => { + it('Navigates to the home screen activity tab when coming back from on-ramp widget', () => { + return expectSaga(handleOnRampReturnLink) + .provide([ + [put(forceFetchFiatOnRampTransactions), undefined], + [call(navigate, MobileScreens.Home, { tab: HomeScreenTabIndex.Activity }), undefined], + [call(dismissInAppBrowser), undefined], + ]) + .silentRun() + }) +}) diff --git a/apps/mobile/src/features/deepLinking/handleOnRampReturnLinkSaga.ts b/apps/mobile/src/features/deepLinking/handleOnRampReturnLinkSaga.ts new file mode 100644 index 00000000..8edff2d8 --- /dev/null +++ b/apps/mobile/src/features/deepLinking/handleOnRampReturnLinkSaga.ts @@ -0,0 +1,20 @@ +import { FeatureFlags, getFeatureFlag } from '@universe/gating' +import { navigate } from 'src/app/navigation/rootNavigation' +import { HomeScreenTabIndex } from 'src/screens/HomeScreen/HomeScreenTabIndex' +import { dismissInAppBrowser } from 'src/utils/linking' +import { call, put } from 'typed-redux-saga' +import { forceFetchFiatOnRampTransactions } from 'uniswap/src/features/transactions/slice' +import { MobileScreens } from 'uniswap/src/types/screens/mobile' + +export function* handleOnRampReturnLink() { + yield* put(forceFetchFiatOnRampTransactions()) + + const isBottomTabsEnabled = getFeatureFlag(FeatureFlags.BottomTabs) + + if (!isBottomTabsEnabled) { + yield* call(navigate, MobileScreens.Home, { tab: HomeScreenTabIndex.Activity }) + } else { + yield* call(navigate, MobileScreens.Activity) + } + yield* call(dismissInAppBrowser) +} diff --git a/apps/mobile/src/features/deepLinking/handleSwapLinkSaga.test.ts b/apps/mobile/src/features/deepLinking/handleSwapLinkSaga.test.ts new file mode 100644 index 00000000..36525047 --- /dev/null +++ b/apps/mobile/src/features/deepLinking/handleSwapLinkSaga.test.ts @@ -0,0 +1,200 @@ +import { URL } from 'react-native-url-polyfill' +import { expectSaga } from 'redux-saga-test-plan' +import { navigate } from 'src/app/navigation/rootNavigation' +import { handleSwapLink } from 'src/features/deepLinking/handleSwapLinkSaga' +import { parseSwapLinkMobileFormatOrThrow } from 'src/features/deepLinking/parseSwapLink' +import { DAI, UNI, USDC_UNICHAIN_SEPOLIA } from 'uniswap/src/constants/tokens' +import { AssetType } from 'uniswap/src/entities/assets' +import { UniverseChainId } from 'uniswap/src/features/chains/types' +import { Platform } from 'uniswap/src/features/platforms/types/Platform' +import { ModalName } from 'uniswap/src/features/telemetry/constants' +import { signerMnemonicAccount } from 'wallet/src/test/fixtures' + +jest.mock('src/app/navigation/rootNavigation', () => ({ + navigate: jest.fn(), +})) + +jest.mock('uniswap/src/features/settings/saga', () => ({ + *getEnabledChainIdsSaga( + _platform?: Platform, + ): Generator { + yield + return { + isTestnetModeEnabled: false, + chains: [], + defaultChainId: 1, + } + }, +})) + +const account = signerMnemonicAccount() + +function formSwapUrl({ + userAddress, + chain, + inputAddress, + outputAddress, + currencyField, + amount, +}: { + userAddress?: Address + chain?: UniverseChainId | number + inputAddress?: string + outputAddress?: string + currencyField?: string + amount?: string +}): URL { + return new URL( + `https://uniswap.org/app?screen=swap +&userAddress=${userAddress} +&inputCurrencyId=${chain}-${inputAddress} +&outputCurrencyId=${chain}-${outputAddress} +¤cyField=${currencyField} +&amount=${amount}`.trim(), + ) +} + +const swapUrl = formSwapUrl({ + userAddress: account.address, + chain: UniverseChainId.Mainnet, + inputAddress: DAI.address, + outputAddress: UNI[UniverseChainId.Mainnet].address, + currencyField: 'input', + amount: '100', +}) + +const testnetSwapUrl = formSwapUrl({ + userAddress: account.address, + chain: UniverseChainId.Sepolia, + inputAddress: USDC_UNICHAIN_SEPOLIA.address, + outputAddress: UNI[UniverseChainId.Sepolia].address, + currencyField: 'input', + amount: '100', +}) + +const invalidOutputCurrencySwapUrl = formSwapUrl({ + userAddress: account.address, + chain: UniverseChainId.Mainnet, + inputAddress: DAI.address, + outputAddress: undefined, + currencyField: 'input', + amount: '100', +}) + +const invalidInputTokenSwapURl = formSwapUrl({ + userAddress: account.address, + chain: UniverseChainId.Mainnet, + inputAddress: '0x00', + outputAddress: UNI[UniverseChainId.Mainnet].address, + currencyField: 'input', + amount: '100', +}) + +const invalidChainSwapUrl = formSwapUrl({ + userAddress: account.address, + chain: 23, + inputAddress: DAI.address, + outputAddress: UNI[UniverseChainId.Mainnet].address, + currencyField: 'input', + amount: '100', +}) + +const invalidAmountSwapUrl = formSwapUrl({ + userAddress: account.address, + chain: UniverseChainId.Mainnet, + inputAddress: DAI.address, + outputAddress: UNI[UniverseChainId.Mainnet].address, + currencyField: 'input', + amount: 'not a number', +}) + +const invalidCurrencyFieldSwapUrl = formSwapUrl({ + userAddress: account.address, + chain: UniverseChainId.Mainnet, + inputAddress: DAI.address, + outputAddress: UNI[UniverseChainId.Mainnet].address, + currencyField: 'token1', + amount: '100', +}) + +describe(handleSwapLink, () => { + const mockNavigate = navigate as jest.MockedFunction + + beforeEach(() => { + mockNavigate.mockClear() + }) + + describe('valid inputs', () => { + it('Navigates to the swap screen with all params if all inputs are valid; testnet mode aligned', async () => { + await expectSaga(handleSwapLink, swapUrl, parseSwapLinkMobileFormatOrThrow).silentRun() + expect(mockNavigate).toHaveBeenCalledWith( + ModalName.Swap, + expect.objectContaining({ + input: { + address: DAI.address, + chainId: UniverseChainId.Mainnet, + type: AssetType.Currency, + }, + output: { + address: UNI[UniverseChainId.Mainnet].address, + chainId: UniverseChainId.Mainnet, + type: AssetType.Currency, + }, + exactCurrencyField: 'input', + exactAmountToken: '100', + }), + ) + }) + it('Navigates to the swap screen with all params if all inputs are valid; testnet mode not aligned', async () => { + await expectSaga(handleSwapLink, testnetSwapUrl, parseSwapLinkMobileFormatOrThrow).silentRun() + expect(mockNavigate).toHaveBeenCalledWith( + ModalName.Swap, + expect.objectContaining({ + input: { + address: USDC_UNICHAIN_SEPOLIA.address, + chainId: UniverseChainId.Sepolia, + type: AssetType.Currency, + }, + output: { + address: UNI[UniverseChainId.Sepolia].address, + chainId: UniverseChainId.Sepolia, + type: AssetType.Currency, + }, + exactCurrencyField: 'input', + exactAmountToken: '100', + }), + ) + }) + }) + + describe('invalid inputs', () => { + beforeAll(() => { + jest.spyOn(console, 'error').mockImplementation(() => undefined) + }) + + it('Navigates to an empty swap screen if outputCurrency is invalid', async () => { + await expectSaga(handleSwapLink, invalidOutputCurrencySwapUrl, parseSwapLinkMobileFormatOrThrow).silentRun() + expect(mockNavigate).toHaveBeenCalledWith(ModalName.Swap) + }) + + it('Navigates to an empty swap screen if inputToken is invalid', async () => { + await expectSaga(handleSwapLink, invalidInputTokenSwapURl, parseSwapLinkMobileFormatOrThrow).silentRun() + expect(mockNavigate).toHaveBeenCalledWith(ModalName.Swap) + }) + + it('Navigates to an empty swap screen if the chain is not supported', async () => { + await expectSaga(handleSwapLink, invalidChainSwapUrl, parseSwapLinkMobileFormatOrThrow).silentRun() + expect(mockNavigate).toHaveBeenCalledWith(ModalName.Swap) + }) + + it('Navigates to an empty swap screen if the swap amount is invalid', async () => { + await expectSaga(handleSwapLink, invalidAmountSwapUrl, parseSwapLinkMobileFormatOrThrow).silentRun() + expect(mockNavigate).toHaveBeenCalledWith(ModalName.Swap) + }) + + it('Navigates to an empty swap screen if currency field is invalid', async () => { + await expectSaga(handleSwapLink, invalidCurrencyFieldSwapUrl, parseSwapLinkMobileFormatOrThrow).silentRun() + expect(mockNavigate).toHaveBeenCalledWith(ModalName.Swap) + }) + }) +}) diff --git a/apps/mobile/src/features/deepLinking/handleSwapLinkSaga.ts b/apps/mobile/src/features/deepLinking/handleSwapLinkSaga.ts new file mode 100644 index 00000000..ac7b50fd --- /dev/null +++ b/apps/mobile/src/features/deepLinking/handleSwapLinkSaga.ts @@ -0,0 +1,42 @@ +import { navigate } from 'src/app/navigation/rootNavigation' +import { createSwapTransactionState, ParseSwapLinkFunction } from 'src/features/deepLinking/parseSwapLink' +import { isTestnetChain } from 'uniswap/src/features/chains/utils' +import { Platform } from 'uniswap/src/features/platforms/types/Platform' +import { getEnabledChainIdsSaga } from 'uniswap/src/features/settings/saga' +import { ModalName } from 'uniswap/src/features/telemetry/constants' +import { logger } from 'utilities/src/logger/logger' + +/** + * Opens swap modal with the provided swap link parameters; prompts testnet switch modal if necessary. + * + * @param url - The URL to parse + * @param parseSwapLink - The function to parse the swap link + */ +export function* handleSwapLink(url: URL, parseSwapLink: ParseSwapLinkFunction) { + try { + const params = parseSwapLink(url) + + logger.info('handleSwapLinkSaga', 'handleSwapLink', 'Navigating to swap modal with params', params) + + const { inputAsset, outputAsset } = params + const swapFormState = createSwapTransactionState(params) + + // Check if testnet mode alignment is needed + const isTestnetChains = + (inputAsset?.chainId && isTestnetChain(inputAsset.chainId)) || + (outputAsset?.chainId && isTestnetChain(outputAsset.chainId)) + const { isTestnetModeEnabled } = yield* getEnabledChainIdsSaga(Platform.EVM) + + // prefill modal irrespective of testnet mode alignment + navigate(ModalName.Swap, swapFormState) + + // if testnet mode isn't aligned with assets, prompt testnet switch modal (closes prefilled swap modal if rejected) + if (isTestnetModeEnabled !== isTestnetChains) { + navigate(ModalName.TestnetSwitchModal, { switchToMode: isTestnetChains ? 'testnet' : 'production' }) + return + } + } catch (error) { + logger.error(error, { tags: { file: 'handleSwapLinkSaga', function: 'handleSwapLink' } }) + navigate(ModalName.Swap) + } +} diff --git a/apps/mobile/src/features/deepLinking/handleTopTokensDeepLink.test.ts b/apps/mobile/src/features/deepLinking/handleTopTokensDeepLink.test.ts new file mode 100644 index 00000000..33212242 --- /dev/null +++ b/apps/mobile/src/features/deepLinking/handleTopTokensDeepLink.test.ts @@ -0,0 +1,523 @@ +import { NavigationContainerRef } from '@react-navigation/native' +import { expectSaga } from 'redux-saga-test-plan' +import { exploreNavigationRef } from 'src/app/navigation/navigationRef' +import { navigate } from 'src/app/navigation/rootNavigation' +import { ExploreStackParamList } from 'src/app/navigation/types' +import { handleTopTokensDeepLink } from 'src/features/deepLinking/handleTopTokensDeepLink' +import { dismissAllModalsBeforeNavigation } from 'src/features/deepLinking/utils' +import { UniverseChainId } from 'uniswap/src/features/chains/types' +import { ModalName } from 'uniswap/src/features/telemetry/constants' +import { MobileScreens } from 'uniswap/src/types/screens/mobile' +import { logger } from 'utilities/src/logger/logger' + +// Mock the navigation ref +jest.mock('src/app/navigation/navigationRef', () => ({ + exploreNavigationRef: { + current: null, + }, +})) + +// Mock the logger +jest.mock('utilities/src/logger/logger', () => ({ + logger: { + error: jest.fn(), + }, +})) + +// Mock the dismissAllModalsBeforeNavigation function +jest.mock('src/features/deepLinking/utils', () => ({ + dismissAllModalsBeforeNavigation: jest.fn(), +})) + +describe('handleTopTokensDeepLink', () => { + const unichainExploreUrl = 'https://app.uniswap.org/explore/tokens/unichain' + const unichainChainId = UniverseChainId.Unichain + + const mockedExploreNavigationRef = exploreNavigationRef as jest.Mocked + const mockedLogger = logger as jest.Mocked + + beforeEach(() => { + jest.clearAllMocks() + // Reset navigation ref state + mockedExploreNavigationRef.current = null + }) + + it('should navigate to explore modal with deep link parameters', () => { + return expectSaga(handleTopTokensDeepLink, { chainId: unichainChainId, url: unichainExploreUrl }) + .call(dismissAllModalsBeforeNavigation) + .call(navigate, ModalName.Explore, { + screen: MobileScreens.Explore, + params: { + showFavorites: false, + orderByMetric: undefined, + chainId: unichainChainId, + }, + }) + .run() + }) + + it('should handle valid metric parameter in URL', () => { + const urlWithMetric = 'https://app.uniswap.org/explore/tokens/unichain?metric=volume' + return expectSaga(handleTopTokensDeepLink, { chainId: unichainChainId, url: urlWithMetric }) + .call(dismissAllModalsBeforeNavigation) + .call(navigate, ModalName.Explore, { + screen: MobileScreens.Explore, + params: { + showFavorites: false, + orderByMetric: 'VOLUME', + chainId: unichainChainId, + }, + }) + .run() + }) + + it('should navigate to explore modal without chainId', () => { + const urlWithoutChainId = 'https://app.uniswap.org/explore/tokens' + return expectSaga(handleTopTokensDeepLink, { chainId: undefined, url: urlWithoutChainId }) + .call(dismissAllModalsBeforeNavigation) + .call(navigate, ModalName.Explore, { + screen: MobileScreens.Explore, + params: { + showFavorites: false, + orderByMetric: undefined, + chainId: undefined, + }, + }) + .run() + }) + + it('should handle metric parameter in URL without chainId', () => { + const urlWithMetricNoChainId = 'https://app.uniswap.org/explore/tokens?metric=market_cap' + return expectSaga(handleTopTokensDeepLink, { chainId: undefined, url: urlWithMetricNoChainId }) + .call(dismissAllModalsBeforeNavigation) + .call(navigate, ModalName.Explore, { + screen: MobileScreens.Explore, + params: { + showFavorites: false, + orderByMetric: 'MARKET_CAP', + chainId: undefined, + }, + }) + .run() + }) + + it('should use exploreNavigationRef when it is focused', async () => { + // Setup the navigation ref to be focused + const mockNavigate = jest.fn() + const mockIsFocused = jest.fn().mockReturnValue(true) + mockedExploreNavigationRef.current = { + isFocused: mockIsFocused, + navigate: mockNavigate, + } as unknown as NavigationContainerRef + + const urlWithMetric = 'https://app.uniswap.org/explore/tokens/unichain?metric=volume' + + await expectSaga(handleTopTokensDeepLink, { chainId: unichainChainId, url: urlWithMetric }) + .not.call(dismissAllModalsBeforeNavigation) + .not.call(navigate, ModalName.Explore, expect.anything()) + .run() + + // Verify that the navigation ref's navigate method was called with correct parameters + expect(mockNavigate).toHaveBeenCalledWith(MobileScreens.Explore, { + showFavorites: false, + orderByMetric: 'VOLUME', + chainId: unichainChainId, + }) + }) + + describe('error handling', () => { + it('should continue execution when navigation throws error and log it', async () => { + // Mock navigate to throw an error + const navigateError = new Error('Navigation failed') + + await expectSaga(handleTopTokensDeepLink, { chainId: unichainChainId, url: unichainExploreUrl }) + .provide({ + call: (effect, next) => { + if (effect.fn === navigate) { + throw navigateError + } + return next() + }, + }) + .call(dismissAllModalsBeforeNavigation) + .call(mockedLogger.error, navigateError, { + tags: { file: 'handleDeepLinkSaga', function: 'handleTopTokensDeepLink' }, + extra: { chainId: unichainChainId, url: unichainExploreUrl }, + }) + .run({ silenceTimeout: true }) + }) + + it('should continue execution when exploreNavigationRef.navigate throws error and log it', async () => { + // Setup the navigation ref to be focused and throw an error + const navError = new Error('Navigation ref failed') + const mockNavigate = jest.fn().mockImplementation(() => { + throw navError + }) + const mockIsFocused = jest.fn().mockReturnValue(true) + mockedExploreNavigationRef.current = { + isFocused: mockIsFocused, + navigate: mockNavigate, + } as unknown as NavigationContainerRef + + await expectSaga(handleTopTokensDeepLink, { chainId: unichainChainId, url: unichainExploreUrl }) + .not.call(dismissAllModalsBeforeNavigation) + .call(mockedLogger.error, navError, { + tags: { file: 'handleDeepLinkSaga', function: 'handleTopTokensDeepLink' }, + extra: { chainId: unichainChainId, url: unichainExploreUrl }, + }) + .run({ silenceTimeout: true }) + }) + }) + + describe('metric validation', () => { + it('should handle invalid metric values and set orderByMetric to undefined', () => { + const urlWithInvalidMetric = 'https://app.uniswap.org/explore/tokens/unichain?metric=invalid_metric' + return expectSaga(handleTopTokensDeepLink, { chainId: unichainChainId, url: urlWithInvalidMetric }) + .call(dismissAllModalsBeforeNavigation) + .call(navigate, ModalName.Explore, { + screen: MobileScreens.Explore, + params: { + showFavorites: false, + orderByMetric: undefined, + chainId: unichainChainId, + }, + }) + .run() + }) + + it('should handle empty metric parameter and set orderByMetric to undefined', () => { + const urlWithEmptyMetric = 'https://app.uniswap.org/explore/tokens/unichain?metric=' + return expectSaga(handleTopTokensDeepLink, { chainId: unichainChainId, url: urlWithEmptyMetric }) + .call(dismissAllModalsBeforeNavigation) + .call(navigate, ModalName.Explore, { + screen: MobileScreens.Explore, + params: { + showFavorites: false, + orderByMetric: undefined, + chainId: unichainChainId, + }, + }) + .run() + }) + + it('should handle case-insensitive valid metrics', () => { + const testCases = [ + { + url: 'https://app.uniswap.org/explore/tokens/unichain?metric=total_value_locked', + expected: 'TOTAL_VALUE_LOCKED', + }, + { url: 'https://app.uniswap.org/explore/tokens/unichain?metric=market_cap', expected: 'MARKET_CAP' }, + { + url: 'https://app.uniswap.org/explore/tokens/unichain?metric=price_percent_change_1_day_asc', + expected: 'PRICE_PERCENT_CHANGE_1_DAY_ASC', + }, + { + url: 'https://app.uniswap.org/explore/tokens/unichain?metric=price_percent_change_1_day_desc', + expected: 'PRICE_PERCENT_CHANGE_1_DAY_DESC', + }, + ] + + return Promise.all( + testCases.map(({ url, expected }) => + expectSaga(handleTopTokensDeepLink, { chainId: unichainChainId, url }) + .call(dismissAllModalsBeforeNavigation) + .call(navigate, ModalName.Explore, { + screen: MobileScreens.Explore, + params: { + showFavorites: false, + orderByMetric: expected, + chainId: unichainChainId, + }, + }) + .run(), + ), + ) + }) + + it('should reject TRENDING metric (excluded CustomRankingType)', () => { + const urlWithTrendingMetric = 'https://app.uniswap.org/explore/tokens/unichain?metric=trending' + return expectSaga(handleTopTokensDeepLink, { chainId: unichainChainId, url: urlWithTrendingMetric }) + .call(dismissAllModalsBeforeNavigation) + .call(navigate, ModalName.Explore, { + screen: MobileScreens.Explore, + params: { + showFavorites: false, + orderByMetric: undefined, + chainId: unichainChainId, + }, + }) + .run() + }) + + it('should handle mixed case metrics correctly', () => { + const urlWithMixedCaseMetric = 'https://app.uniswap.org/explore/tokens/unichain?metric=VoLuMe' + return expectSaga(handleTopTokensDeepLink, { chainId: unichainChainId, url: urlWithMixedCaseMetric }) + .call(dismissAllModalsBeforeNavigation) + .call(navigate, ModalName.Explore, { + screen: MobileScreens.Explore, + params: { + showFavorites: false, + orderByMetric: 'VOLUME', + chainId: unichainChainId, + }, + }) + .run() + }) + + it('should handle numeric metric values as invalid', () => { + const urlWithNumericMetric = 'https://app.uniswap.org/explore/tokens/unichain?metric=123' + return expectSaga(handleTopTokensDeepLink, { chainId: unichainChainId, url: urlWithNumericMetric }) + .call(dismissAllModalsBeforeNavigation) + .call(navigate, ModalName.Explore, { + screen: MobileScreens.Explore, + params: { + showFavorites: false, + orderByMetric: undefined, + chainId: unichainChainId, + }, + }) + .run() + }) + + it('should handle special characters in metric as invalid', () => { + const urlWithSpecialCharsMetric = 'https://app.uniswap.org/explore/tokens/unichain?metric=volume@#$' + return expectSaga(handleTopTokensDeepLink, { chainId: unichainChainId, url: urlWithSpecialCharsMetric }) + .call(dismissAllModalsBeforeNavigation) + .call(navigate, ModalName.Explore, { + screen: MobileScreens.Explore, + params: { + showFavorites: false, + orderByMetric: undefined, + chainId: unichainChainId, + }, + }) + .run() + }) + + it('should handle various invalid metric formats', () => { + const testCases = [ + { url: 'https://app.uniswap.org/explore/tokens/unichain?metric=true', desc: 'boolean-like' }, + { url: 'https://app.uniswap.org/explore/tokens/unichain?metric=volume,market_cap', desc: 'array-like' }, + { + url: "https://app.uniswap.org/explore/tokens/unichain?metric=volume'; DROP TABLE--", + desc: 'SQL injection-like', + }, + { url: `https://app.uniswap.org/explore/tokens/unichain?metric=${'a'.repeat(100)}`, desc: 'very long' }, + ] + + return Promise.all( + testCases.map(({ url }) => + expectSaga(handleTopTokensDeepLink, { chainId: unichainChainId, url }) + .call(dismissAllModalsBeforeNavigation) + .call(navigate, ModalName.Explore, { + screen: MobileScreens.Explore, + params: { + showFavorites: false, + orderByMetric: undefined, + chainId: unichainChainId, + }, + }) + .run(), + ), + ) + }) + + it('should handle URL encoded metric values correctly', () => { + const urlWithEncodedMetric = 'https://app.uniswap.org/explore/tokens/unichain?metric=MARKET%5FCAP' + return expectSaga(handleTopTokensDeepLink, { chainId: unichainChainId, url: urlWithEncodedMetric }) + .call(dismissAllModalsBeforeNavigation) + .call(navigate, ModalName.Explore, { + screen: MobileScreens.Explore, + params: { + showFavorites: false, + orderByMetric: 'MARKET_CAP', + chainId: unichainChainId, + }, + }) + .run() + }) + }) + + describe('getValidRankingType function edge cases', () => { + it('should return undefined for falsy metrics (null, empty, whitespace)', () => { + const testCases = [ + 'https://app.uniswap.org/explore/tokens/unichain?other=value', // null metric + 'https://app.uniswap.org/explore/tokens/unichain?metric=', // empty + 'https://app.uniswap.org/explore/tokens/unichain?metric=%20%20%20', // whitespace + ] + + return Promise.all( + testCases.map((url) => + expectSaga(handleTopTokensDeepLink, { chainId: unichainChainId, url }) + .call(dismissAllModalsBeforeNavigation) + .call(navigate, ModalName.Explore, { + screen: MobileScreens.Explore, + params: { + showFavorites: false, + orderByMetric: undefined, + chainId: unichainChainId, + }, + }) + .run(), + ), + ) + }) + + it('should convert valid lowercase metric to uppercase', () => { + const urlWithLowercaseMetric = 'https://app.uniswap.org/explore/tokens/unichain?metric=volume' + return expectSaga(handleTopTokensDeepLink, { chainId: unichainChainId, url: urlWithLowercaseMetric }) + .call(dismissAllModalsBeforeNavigation) + .call(navigate, ModalName.Explore, { + screen: MobileScreens.Explore, + params: { + showFavorites: false, + orderByMetric: 'VOLUME', + chainId: unichainChainId, + }, + }) + .run() + }) + + it('should handle mixed case metric conversion', () => { + const urlWithMixedCaseMetric = 'https://app.uniswap.org/explore/tokens/unichain?metric=mArKeT_cAp' + return expectSaga(handleTopTokensDeepLink, { chainId: unichainChainId, url: urlWithMixedCaseMetric }) + .call(dismissAllModalsBeforeNavigation) + .call(navigate, ModalName.Explore, { + screen: MobileScreens.Explore, + params: { + showFavorites: false, + orderByMetric: 'MARKET_CAP', + chainId: unichainChainId, + }, + }) + .run() + }) + + it('should return undefined for unsupported metric values', () => { + const urlWithUnsupportedMetric = 'https://app.uniswap.org/explore/tokens/unichain?metric=unsupported_metric' + return expectSaga(handleTopTokensDeepLink, { chainId: unichainChainId, url: urlWithUnsupportedMetric }) + .call(dismissAllModalsBeforeNavigation) + .call(navigate, ModalName.Explore, { + screen: MobileScreens.Explore, + params: { + showFavorites: false, + orderByMetric: undefined, + chainId: unichainChainId, + }, + }) + .run() + }) + + it('should reject TRENDING metric in any case format', () => { + const testCases = [ + 'https://app.uniswap.org/explore/tokens/unichain?metric=trending', + 'https://app.uniswap.org/explore/tokens/unichain?metric=TrEnDiNg', + ] + + return Promise.all( + testCases.map((url) => + expectSaga(handleTopTokensDeepLink, { chainId: unichainChainId, url }) + .call(dismissAllModalsBeforeNavigation) + .call(navigate, ModalName.Explore, { + screen: MobileScreens.Explore, + params: { + showFavorites: false, + orderByMetric: undefined, + chainId: unichainChainId, + }, + }) + .run(), + ), + ) + }) + + it('should handle valid RankingType values - all uppercase variants', () => { + const validMetrics = ['VOLUME', 'MARKET_CAP', 'TOTAL_VALUE_LOCKED'] + + return Promise.all( + validMetrics.map((metric) => { + const urlWithMetric = `https://app.uniswap.org/explore/tokens/unichain?metric=${metric}` + return expectSaga(handleTopTokensDeepLink, { chainId: unichainChainId, url: urlWithMetric }) + .call(dismissAllModalsBeforeNavigation) + .call(navigate, ModalName.Explore, { + screen: MobileScreens.Explore, + params: { + showFavorites: false, + orderByMetric: metric, + chainId: unichainChainId, + }, + }) + .run() + }), + ) + }) + + it('should handle valid CustomRankingType values except TRENDING', () => { + const validCustomMetrics = ['PRICE_PERCENT_CHANGE_1_DAY_ASC', 'PRICE_PERCENT_CHANGE_1_DAY_DESC'] + + return Promise.all( + validCustomMetrics.map((metric) => { + const urlWithMetric = `https://app.uniswap.org/explore/tokens/unichain?metric=${metric}` + return expectSaga(handleTopTokensDeepLink, { chainId: unichainChainId, url: urlWithMetric }) + .call(dismissAllModalsBeforeNavigation) + .call(navigate, ModalName.Explore, { + screen: MobileScreens.Explore, + params: { + showFavorites: false, + orderByMetric: metric, + chainId: unichainChainId, + }, + }) + .run() + }), + ) + }) + }) + + describe('URL edge cases', () => { + it('should handle URL without search params', () => { + const basicUrl = 'https://app.uniswap.org/explore/tokens/unichain' + return expectSaga(handleTopTokensDeepLink, { chainId: unichainChainId, url: basicUrl }) + .call(dismissAllModalsBeforeNavigation) + .call(navigate, ModalName.Explore, { + screen: MobileScreens.Explore, + params: { + showFavorites: false, + orderByMetric: undefined, + chainId: unichainChainId, + }, + }) + .run() + }) + + it('should handle URL with multiple query parameters', () => { + const urlWithMultipleParams = 'https://app.uniswap.org/explore/tokens/unichain?metric=volume&other=value&foo=bar' + return expectSaga(handleTopTokensDeepLink, { chainId: unichainChainId, url: urlWithMultipleParams }) + .call(dismissAllModalsBeforeNavigation) + .call(navigate, ModalName.Explore, { + screen: MobileScreens.Explore, + params: { + showFavorites: false, + orderByMetric: 'VOLUME', + chainId: unichainChainId, + }, + }) + .run() + }) + + it('should handle URL with fragment identifier', () => { + const urlWithFragment = 'https://app.uniswap.org/explore/tokens/unichain?metric=market_cap#section' + return expectSaga(handleTopTokensDeepLink, { chainId: unichainChainId, url: urlWithFragment }) + .call(dismissAllModalsBeforeNavigation) + .call(navigate, ModalName.Explore, { + screen: MobileScreens.Explore, + params: { + showFavorites: false, + orderByMetric: 'MARKET_CAP', + chainId: unichainChainId, + }, + }) + .run() + }) + }) +}) diff --git a/apps/mobile/src/features/deepLinking/handleTopTokensDeepLink.ts b/apps/mobile/src/features/deepLinking/handleTopTokensDeepLink.ts new file mode 100644 index 00000000..09f6cdaa --- /dev/null +++ b/apps/mobile/src/features/deepLinking/handleTopTokensDeepLink.ts @@ -0,0 +1,52 @@ +import { exploreNavigationRef } from 'src/app/navigation/navigationRef' +import { navigate } from 'src/app/navigation/rootNavigation' +import { ExploreModalState } from 'src/app/navigation/types' +import { dismissAllModalsBeforeNavigation } from 'src/features/deepLinking/utils' +import { call } from 'typed-redux-saga' +import { UniverseChainId } from 'uniswap/src/features/chains/types' +import { ModalName } from 'uniswap/src/features/telemetry/constants' +import { MobileScreens } from 'uniswap/src/types/screens/mobile' +import { logger } from 'utilities/src/logger/logger' +import { ExploreOrderBy, isSupportedExploreOrderBy } from 'wallet/src/features/wallet/types' + +const getValidRankingType = (metric: string | null): ExploreOrderBy | undefined => { + if (!metric) { + return undefined + } + const upperMetric = metric.toUpperCase() + return isSupportedExploreOrderBy(upperMetric) ? upperMetric : undefined +} + +export function* handleTopTokensDeepLink({ chainId, url }: { chainId?: UniverseChainId; url: string }): Generator { + try { + const urlObj = new URL(url) + const metric = urlObj.searchParams.get('metric') + + // Validate the metric if provided + const validMetric = getValidRankingType(metric) + + // Navigate to the Explore modal with deep link parameters + const navParams: ExploreModalState = { + screen: MobileScreens.Explore, + params: { + showFavorites: false, + orderByMetric: validMetric, + chainId, + }, + } + + const ref = exploreNavigationRef.current + if (ref && ref.isFocused()) { + ref.navigate(MobileScreens.Explore, navParams.params) + } else { + // Dismiss any open modals before navigating + yield* call(dismissAllModalsBeforeNavigation) + yield* call(navigate, ModalName.Explore, navParams) + } + } catch (error) { + yield* call(logger.error, error, { + tags: { file: 'handleDeepLinkSaga', function: 'handleTopTokensDeepLink' }, + extra: { chainId, url }, + }) + } +} diff --git a/apps/mobile/src/features/deepLinking/handleTransactionLinkSaga.test.ts b/apps/mobile/src/features/deepLinking/handleTransactionLinkSaga.test.ts new file mode 100644 index 00000000..55e55b35 --- /dev/null +++ b/apps/mobile/src/features/deepLinking/handleTransactionLinkSaga.test.ts @@ -0,0 +1,18 @@ +import { call, put } from '@redux-saga/core/effects' +import { expectSaga } from 'redux-saga-test-plan' +import { navigate } from 'src/app/navigation/rootNavigation' +import { handleTransactionLink } from 'src/features/deepLinking/handleTransactionLinkSaga' +import { closeAllModals } from 'src/features/modals/modalSlice' +import { HomeScreenTabIndex } from 'src/screens/HomeScreen/HomeScreenTabIndex' +import { MobileScreens } from 'uniswap/src/types/screens/mobile' + +describe(handleTransactionLink, () => { + it('Navigates to the home screen when opening a transaction notification', () => { + return expectSaga(handleTransactionLink) + .provide([ + [put(closeAllModals()), undefined], + [call(navigate, MobileScreens.Home, { tab: HomeScreenTabIndex.Activity }), undefined], + ]) + .silentRun() + }) +}) diff --git a/apps/mobile/src/features/deepLinking/handleTransactionLinkSaga.ts b/apps/mobile/src/features/deepLinking/handleTransactionLinkSaga.ts new file mode 100644 index 00000000..c6e5d16b --- /dev/null +++ b/apps/mobile/src/features/deepLinking/handleTransactionLinkSaga.ts @@ -0,0 +1,17 @@ +import { FeatureFlags, getFeatureFlag } from '@universe/gating' +import { navigate } from 'src/app/navigation/rootNavigation' +import { closeAllModals } from 'src/features/modals/modalSlice' +import { HomeScreenTabIndex } from 'src/screens/HomeScreen/HomeScreenTabIndex' +import { call, put } from 'typed-redux-saga' +import { MobileScreens } from 'uniswap/src/types/screens/mobile' + +export function* handleTransactionLink() { + const isBottomTabsEnabled = getFeatureFlag(FeatureFlags.BottomTabs) + + if (!isBottomTabsEnabled) { + yield* call(navigate, MobileScreens.Home, { tab: HomeScreenTabIndex.Activity }) + } else { + yield* call(navigate, MobileScreens.Activity) + } + yield* put(closeAllModals()) +} diff --git a/apps/mobile/src/features/deepLinking/handleUniswapAppDeepLink.test.ts b/apps/mobile/src/features/deepLinking/handleUniswapAppDeepLink.test.ts new file mode 100644 index 00000000..d204b234 --- /dev/null +++ b/apps/mobile/src/features/deepLinking/handleUniswapAppDeepLink.test.ts @@ -0,0 +1,702 @@ +import { expectSaga } from 'redux-saga-test-plan' +import { call, put } from 'redux-saga/effects' +import { navigate } from 'src/app/navigation/rootNavigation' +import { handleTopTokensDeepLink } from 'src/features/deepLinking/handleTopTokensDeepLink' +import { handleUniswapAppDeepLink } from 'src/features/deepLinking/handleUniswapAppDeepLink' +import { LinkSource } from 'src/features/deepLinking/types' +import { openModal } from 'src/features/modals/modalSlice' +import { UniverseChainId } from 'uniswap/src/features/chains/types' +import { fromUniswapWebAppLink } from 'uniswap/src/features/chains/utils' +import { BACKEND_NATIVE_CHAIN_ADDRESS_STRING } from 'uniswap/src/features/search/utils' +import { MobileEventName, ModalName } from 'uniswap/src/features/telemetry/constants' +import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { MobileScreens } from 'uniswap/src/types/screens/mobile' +import { ShareableEntity } from 'uniswap/src/types/sharing' +import { WidgetType } from 'uniswap/src/types/widgets' +import { buildCurrencyId, buildNativeCurrencyId } from 'uniswap/src/utils/currencyId' +import { setAccountAsActive } from 'wallet/src/features/wallet/slice' +import { signerMnemonicAccount } from 'wallet/src/test/fixtures' + +const account = signerMnemonicAccount() +const SAMPLE_CONTRACT_ADDRESS = '0x1234567890abcdef1234567890abcdef12345678' +const SAMPLE_CONTRACT_ADDRESS_2 = '0xabcdef1234567890abcdef1234567890abcdef12' +const SAMPLE_TOKEN_ID = '123' +const SAMPLE_CURRENCY_ID = '1-0x1234567890abcdef1234567890abcdef12345678' +const SAMPLE_NATIVE_CURRENCY_ID = '1-ETH' + +const stateWithAccounts = { + wallet: { + accounts: { + [account.address]: account, + [SAMPLE_CONTRACT_ADDRESS_2]: account, + }, + activeAccountAddress: account.address, + }, +} + +describe('handleUniswapAppDeepLink', () => { + describe('Token deep links', () => { + it('should handle token share with contract address', () => { + const path = `tokens/ethereum/${SAMPLE_CONTRACT_ADDRESS}` + const url = `https://app.uniswap.org/${path}` + + return expectSaga(handleUniswapAppDeepLink, { + path, + url, + linkSource: LinkSource.Share, + }) + .provide([ + [call(fromUniswapWebAppLink, 'ethereum'), UniverseChainId.Mainnet], + [call(buildCurrencyId, UniverseChainId.Mainnet, SAMPLE_CONTRACT_ADDRESS), SAMPLE_CURRENCY_ID], + [ + call(navigate, MobileScreens.TokenDetails, { + currencyId: SAMPLE_CURRENCY_ID, + }), + undefined, + ], + [ + call(sendAnalyticsEvent, MobileEventName.ShareLinkOpened, { + entity: ShareableEntity.Token, + url, + }), + undefined, + ], + ]) + .run() + }) + + it('should handle token share with native currency', () => { + const path = `tokens/ethereum/${BACKEND_NATIVE_CHAIN_ADDRESS_STRING}` + const url = `https://app.uniswap.org/${path}` + + return expectSaga(handleUniswapAppDeepLink, { + path, + url, + linkSource: LinkSource.Share, + }) + .provide([ + [call(fromUniswapWebAppLink, 'ethereum'), UniverseChainId.Mainnet], + [call(buildNativeCurrencyId, UniverseChainId.Mainnet), SAMPLE_NATIVE_CURRENCY_ID], + [ + call(navigate, MobileScreens.TokenDetails, { + currencyId: SAMPLE_NATIVE_CURRENCY_ID, + }), + undefined, + ], + [ + call(sendAnalyticsEvent, MobileEventName.ShareLinkOpened, { + entity: ShareableEntity.Token, + url, + }), + undefined, + ], + ]) + .run() + }) + + it('should handle widget token link', () => { + const path = `tokens/ethereum/${SAMPLE_CONTRACT_ADDRESS}` + const url = `https://app.uniswap.org/${path}` + + return expectSaga(handleUniswapAppDeepLink, { + path, + url, + linkSource: LinkSource.Widget, + }) + .provide([ + [call(fromUniswapWebAppLink, 'ethereum'), UniverseChainId.Mainnet], + [call(buildCurrencyId, UniverseChainId.Mainnet, SAMPLE_CONTRACT_ADDRESS), SAMPLE_CURRENCY_ID], + [ + call(navigate, MobileScreens.TokenDetails, { + currencyId: SAMPLE_CURRENCY_ID, + }), + undefined, + ], + [ + call(sendAnalyticsEvent, MobileEventName.WidgetClicked, { + widget_type: WidgetType.TokenPrice, + url, + }), + undefined, + ], + ]) + .run() + }) + + it('should throw error for token link with invalid network', () => { + const path = `tokens/invalid/${SAMPLE_CONTRACT_ADDRESS}` + const url = `https://app.uniswap.org/${path}` + + return expect( + expectSaga(handleUniswapAppDeepLink, { + path, + url, + linkSource: LinkSource.Share, + }).run(), + ).rejects.toThrow('Network "invalid" can not be mapped') + }) + }) + + describe('Top Tokens deep links', () => { + it('should handle explore top tokens with chain', () => { + const path = 'explore/tokens/unichain' + const url = `https://app.uniswap.org/${path}?metric=volume` + + return expectSaga(handleUniswapAppDeepLink, { + path, + url, + linkSource: LinkSource.Share, + }) + .provide([ + [call(fromUniswapWebAppLink, 'unichain'), UniverseChainId.Unichain], + [ + call(handleTopTokensDeepLink, { + chainId: UniverseChainId.Unichain, + url, + }), + undefined, + ], + ]) + .run() + }) + + it('should handle top tokens with chain', () => { + const path = 'tokens/ethereum' + const url = `https://app.uniswap.org/${path}?metric=volume` + + return expectSaga(handleUniswapAppDeepLink, { + path, + url, + linkSource: LinkSource.Share, + }) + .provide([ + [call(fromUniswapWebAppLink, 'ethereum'), UniverseChainId.Mainnet], + [ + call(handleTopTokensDeepLink, { + chainId: UniverseChainId.Mainnet, + url, + }), + undefined, + ], + ]) + .run() + }) + + it('should handle explore top tokens without chain', () => { + const path = 'explore/tokens' + const url = `https://app.uniswap.org/${path}?metric=volume` + + return expectSaga(handleUniswapAppDeepLink, { + path, + url, + linkSource: LinkSource.Share, + }) + .provide([ + [ + call(handleTopTokensDeepLink, { + chainId: undefined, + url, + }), + undefined, + ], + ]) + .run() + }) + + it('should handle top tokens without chain', () => { + const path = 'tokens' + const url = `https://app.uniswap.org/${path}` + + return expectSaga(handleUniswapAppDeepLink, { + path, + url, + linkSource: LinkSource.Share, + }) + .provide([ + [ + call(handleTopTokensDeepLink, { + chainId: undefined, + url, + }), + undefined, + ], + ]) + .run() + }) + }) + + describe('Address deep links', () => { + it('should handle external address share', () => { + const externalAddress = '0x1234567890abcdef1234567890abcdef12345679' + const path = `portfolio/${externalAddress}` + const url = `https://app.uniswap.org/${path}` + + return expectSaga(handleUniswapAppDeepLink, { + path, + url, + linkSource: LinkSource.Share, + }) + .withState(stateWithAccounts) + .provide([ + [ + call(navigate, MobileScreens.ExternalProfile, { + address: externalAddress, + }), + undefined, + ], + [ + call(sendAnalyticsEvent, MobileEventName.ShareLinkOpened, { + entity: ShareableEntity.Wallet, + url, + }), + undefined, + ], + ]) + .run() + }) + + it('should handle internal address share by switching to that account', () => { + const path = `portfolio/${SAMPLE_CONTRACT_ADDRESS_2}` + const url = `https://app.uniswap.org/${path}` + + return expectSaga(handleUniswapAppDeepLink, { + path, + url, + linkSource: LinkSource.Share, + }) + .withState(stateWithAccounts) + .provide([ + [ + call(sendAnalyticsEvent, MobileEventName.ShareLinkOpened, { + entity: ShareableEntity.Wallet, + url, + }), + undefined, + ], + ]) + .put(setAccountAsActive(SAMPLE_CONTRACT_ADDRESS_2)) + .run() + }) + + it('should not handle active account address', () => { + const path = `portfolio/${account.address}` + const url = `https://app.uniswap.org/${path}` + + return expectSaga(handleUniswapAppDeepLink, { + path, + url, + linkSource: LinkSource.Share, + }) + .withState(stateWithAccounts) + .provide([ + [ + call(sendAnalyticsEvent, MobileEventName.ShareLinkOpened, { + entity: ShareableEntity.Wallet, + url, + }), + undefined, + ], + ]) + .not.call.fn(navigate) + .not.put.actionType(setAccountAsActive.type) + .run() + }) + + it('should handle portfolio share', () => { + const externalAddress = '0x1234567890abcdef1234567890abcdef12345679' + const path = `portfolio/${externalAddress}` + const url = `https://app.uniswap.org/${path}` + + return expectSaga(handleUniswapAppDeepLink, { + path, + url, + linkSource: LinkSource.Share, + }) + .withState(stateWithAccounts) + .provide([ + [ + call(navigate, MobileScreens.ExternalProfile, { + address: externalAddress, + }), + undefined, + ], + [ + call(sendAnalyticsEvent, MobileEventName.ShareLinkOpened, { + entity: ShareableEntity.Wallet, + url, + }), + undefined, + ], + ]) + .run() + }) + }) + + describe('Edge cases and invalid paths', () => { + it('should not handle unrecognized paths', () => { + const path = 'unknown/path' + const url = `https://app.uniswap.org/${path}` + + return expectSaga(handleUniswapAppDeepLink, { + path, + url, + linkSource: LinkSource.Share, + }) + .not.call.fn(navigate) + .not.call.fn(handleTopTokensDeepLink) + .run() + }) + + it('should not handle address with invalid format', () => { + const path = 'portfolio/invalid-address' + const url = `https://app.uniswap.org/${path}` + + return expectSaga(handleUniswapAppDeepLink, { + path, + url, + linkSource: LinkSource.Share, + }) + .not.call.fn(navigate) + .run() + }) + }) + + describe('Buy deep links', () => { + it('should handle buy link with value and currencyCode', () => { + const path = 'buy' + const url = 'https://app.uniswap.org/buy?value=3¤cyCode=ETH' + + return expectSaga(handleUniswapAppDeepLink, { + path, + url, + linkSource: LinkSource.Share, + }) + .provide([ + [ + put( + openModal({ + name: ModalName.FiatOnRampAggregator, + initialState: { + prefilledAmount: '3', + currencyCode: 'ETH', + prefilledIsTokenInputMode: false, + providers: undefined, + }, + }), + ), + undefined, + ], + ]) + .run() + }) + + it('should handle buy link with only value', () => { + const path = 'buy' + const url = 'https://app.uniswap.org/buy?value=100' + + return expectSaga(handleUniswapAppDeepLink, { + path, + url, + linkSource: LinkSource.Share, + }) + .provide([ + [ + put( + openModal({ + name: ModalName.FiatOnRampAggregator, + initialState: { + prefilledAmount: '100', + currencyCode: undefined, + prefilledIsTokenInputMode: false, + providers: undefined, + }, + }), + ), + undefined, + ], + ]) + .run() + }) + + it('should handle buy link with only currencyCode', () => { + const path = 'buy' + const url = 'https://app.uniswap.org/buy?currencyCode=USDC' + + return expectSaga(handleUniswapAppDeepLink, { + path, + url, + linkSource: LinkSource.Share, + }) + .provide([ + [ + put( + openModal({ + name: ModalName.FiatOnRampAggregator, + initialState: { + prefilledAmount: undefined, + currencyCode: 'USDC', + prefilledIsTokenInputMode: false, + providers: undefined, + }, + }), + ), + undefined, + ], + ]) + .run() + }) + + it('should handle buy link with no parameters', () => { + const path = 'buy' + const url = 'https://app.uniswap.org/buy' + + return expectSaga(handleUniswapAppDeepLink, { + path, + url, + linkSource: LinkSource.Share, + }) + .provide([ + [ + put( + openModal({ + name: ModalName.FiatOnRampAggregator, + initialState: { + prefilledAmount: undefined, + currencyCode: undefined, + prefilledIsTokenInputMode: false, + providers: undefined, + }, + }), + ), + undefined, + ], + ]) + .run() + }) + + it('should handle buy link with isTokenInputMode=true', () => { + const path = 'buy' + const url = 'https://app.uniswap.org/buy?value=50¤cyCode=BTC&isTokenInputMode=true' + + return expectSaga(handleUniswapAppDeepLink, { + path, + url, + linkSource: LinkSource.Share, + }) + .provide([ + [ + put( + openModal({ + name: ModalName.FiatOnRampAggregator, + initialState: { + prefilledAmount: '50', + currencyCode: 'BTC', + prefilledIsTokenInputMode: true, + providers: undefined, + }, + }), + ), + undefined, + ], + ]) + .run() + }) + + it('should handle buy link with isTokenInputMode=false', () => { + const path = 'buy' + const url = 'https://app.uniswap.org/buy?value=25¤cyCode=USDT&isTokenInputMode=false' + + return expectSaga(handleUniswapAppDeepLink, { + path, + url, + linkSource: LinkSource.Share, + }) + .provide([ + [ + put( + openModal({ + name: ModalName.FiatOnRampAggregator, + initialState: { + prefilledAmount: '25', + currencyCode: 'USDT', + prefilledIsTokenInputMode: false, + providers: undefined, + }, + }), + ), + undefined, + ], + ]) + .run() + }) + + it('should handle buy link with only isTokenInputMode parameter', () => { + const path = 'buy' + const url = 'https://app.uniswap.org/buy?isTokenInputMode=true' + + return expectSaga(handleUniswapAppDeepLink, { + path, + url, + linkSource: LinkSource.Share, + }) + .provide([ + [ + put( + openModal({ + name: ModalName.FiatOnRampAggregator, + initialState: { + prefilledAmount: undefined, + currencyCode: undefined, + prefilledIsTokenInputMode: true, + providers: undefined, + }, + }), + ), + undefined, + ], + ]) + .run() + }) + + it('should handle buy link with single provider', () => { + const path = 'buy' + const url = 'https://app.uniswap.org/buy?providers=moonpay' + + return expectSaga(handleUniswapAppDeepLink, { + path, + url, + linkSource: LinkSource.Share, + }) + .provide([ + [ + put( + openModal({ + name: ModalName.FiatOnRampAggregator, + initialState: { + prefilledAmount: undefined, + currencyCode: undefined, + prefilledIsTokenInputMode: false, + providers: ['MOONPAY'], + }, + }), + ), + undefined, + ], + ]) + .run() + }) + + it('should handle buy link with multiple providers', () => { + const path = 'buy' + const url = 'https://app.uniswap.org/buy?providers=moonpay,coinbasepay,stripe' + + return expectSaga(handleUniswapAppDeepLink, { + path, + url, + linkSource: LinkSource.Share, + }) + .provide([ + [ + put( + openModal({ + name: ModalName.FiatOnRampAggregator, + initialState: { + prefilledAmount: undefined, + currencyCode: undefined, + prefilledIsTokenInputMode: false, + providers: ['MOONPAY', 'COINBASEPAY', 'STRIPE'], + }, + }), + ), + undefined, + ], + ]) + .run() + }) + + it('should handle buy link with providers in mixed case (converted to uppercase)', () => { + const path = 'buy' + const url = 'https://app.uniswap.org/buy?providers=MoonPay,coinbasepay,STRIPE' + + return expectSaga(handleUniswapAppDeepLink, { + path, + url, + linkSource: LinkSource.Share, + }) + .provide([ + [ + put( + openModal({ + name: ModalName.FiatOnRampAggregator, + initialState: { + prefilledAmount: undefined, + currencyCode: undefined, + prefilledIsTokenInputMode: false, + providers: ['MOONPAY', 'COINBASEPAY', 'STRIPE'], + }, + }), + ), + undefined, + ], + ]) + .run() + }) + + it('should handle buy link with providers and other parameters', () => { + const path = 'buy' + const url = + 'https://app.uniswap.org/buy?value=100¤cyCode=ETH&isTokenInputMode=true&providers=moonpay,coinbasepay' + + return expectSaga(handleUniswapAppDeepLink, { + path, + url, + linkSource: LinkSource.Share, + }) + .provide([ + [ + put( + openModal({ + name: ModalName.FiatOnRampAggregator, + initialState: { + prefilledAmount: '100', + currencyCode: 'ETH', + prefilledIsTokenInputMode: true, + providers: ['MOONPAY', 'COINBASEPAY'], + }, + }), + ), + undefined, + ], + ]) + .run() + }) + + it('should handle buy link with empty providers parameter', () => { + const path = 'buy' + const url = 'https://app.uniswap.org/buy?providers=' + + return expectSaga(handleUniswapAppDeepLink, { + path, + url, + linkSource: LinkSource.Share, + }) + .provide([ + [ + put( + openModal({ + name: ModalName.FiatOnRampAggregator, + initialState: { + prefilledAmount: undefined, + currencyCode: undefined, + prefilledIsTokenInputMode: false, + providers: [], + }, + }), + ), + undefined, + ], + ]) + .run() + }) + }) +}) diff --git a/apps/mobile/src/features/deepLinking/handleUniswapAppDeepLink.ts b/apps/mobile/src/features/deepLinking/handleUniswapAppDeepLink.ts new file mode 100644 index 00000000..e0c4f721 --- /dev/null +++ b/apps/mobile/src/features/deepLinking/handleUniswapAppDeepLink.ts @@ -0,0 +1,171 @@ +import { fiatOnRampNavigationRef } from 'src/app/navigation/navigationRef' +import { navigate } from 'src/app/navigation/rootNavigation' +import { handleSwapLink } from 'src/features/deepLinking/handleSwapLinkSaga' +import { handleTopTokensDeepLink } from 'src/features/deepLinking/handleTopTokensDeepLink' +import { parseSwapLinkWebFormatOrThrow } from 'src/features/deepLinking/parseSwapLink' +import { LinkSource } from 'src/features/deepLinking/types' +import { dismissAllModalsBeforeNavigation } from 'src/features/deepLinking/utils' +import { openModal } from 'src/features/modals/modalSlice' +import { call, put, select } from 'typed-redux-saga' +import { fromUniswapWebAppLink } from 'uniswap/src/features/chains/utils' +import { BACKEND_NATIVE_CHAIN_ADDRESS_STRING } from 'uniswap/src/features/search/utils' +import { MobileEventName, ModalName } from 'uniswap/src/features/telemetry/constants' +import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { MobileScreens } from 'uniswap/src/types/screens/mobile' +import { ShareableEntity } from 'uniswap/src/types/sharing' +import { WidgetType } from 'uniswap/src/types/widgets' +import { buildCurrencyId, buildNativeCurrencyId } from 'uniswap/src/utils/currencyId' +import { selectAccounts, selectActiveAccountAddress } from 'wallet/src/features/wallet/selectors' +import { setAccountAsActive } from 'wallet/src/features/wallet/slice' + +const TOKEN_SHARE_LINK_HASH_REGEX = RegExp( + `^(#/)?(?:explore/)?tokens/([\\w\\d]*)/(0x[a-fA-F0-9]{40}|${BACKEND_NATIVE_CHAIN_ADDRESS_STRING})$`, +) +const TOP_TOKENS_LINK_CHAIN_REGEX = /^(?:explore\/)?tokens\/([\w\d]+)/ +const TOP_TOKENS_LINK_REGEX = /^(?:explore\/)?tokens/ +const ADDRESS_SHARE_LINK_HASH_REGEX = /^(#\/)?portfolio\/(0x[a-fA-F0-9]{40})$/ +const SWAP_LINK_HASH_REGEX = /^\/?swap(?:\?)?/ +const BUY_LINK_HASH_REGEX = /^\/?buy(?:\?)?/ + +export function* handleUniswapAppDeepLink({ + path, + url, + linkSource, +}: { + path: string + url: string + linkSource: LinkSource +}): Generator { + // Handle Buy links (ex. https://app.uniswap.org/buy?value=3¤cyCode=ETH) + if (BUY_LINK_HASH_REGEX.test(path)) { + const urlObj = new URL(url) + yield* call(handleBuyLink, urlObj) + return + } + + // Handle Swap links (ex. https://app.uniswap.org/swap?inputCurrency=ETH&outputCurrency=0x...) + if (SWAP_LINK_HASH_REGEX.test(path)) { + const urlObj = new URL(url) + yield* call(handleSwapLink, urlObj, parseSwapLinkWebFormatOrThrow) + return + } + + // Handle Token share (ex. https://app.uniswap.org/tokens/ethereum/0x... or https://app.uniswap.org/explore/tokens/arbitrum/0x...) + if (TOKEN_SHARE_LINK_HASH_REGEX.test(path)) { + yield* call(handleTokenShare, { path, url, linkSource }) + return + } + + // Handle Top Tokens page with or without explore and chain path: + // ex. https://app.uniswap.org/tokens/unichain?metric=volume or https://app.uniswap.org/explore/tokens/base?metric=market_cap + // or https://app.uniswap.org/tokens?metric=volume or https://app.uniswap.org/explore/tokens?metric=market_cap + if (TOP_TOKENS_LINK_CHAIN_REGEX.test(path) || TOP_TOKENS_LINK_REGEX.test(path)) { + const [, network] = path.match(TOP_TOKENS_LINK_CHAIN_REGEX) || [] + const chainId = network ? fromUniswapWebAppLink(network) : undefined + + yield* call(handleTopTokensDeepLink, { chainId, url }) + return + } + + // Handle Address share (ex. https://app.uniswap.org/address/0x...) + if (ADDRESS_SHARE_LINK_HASH_REGEX.test(path)) { + yield* call(handleAddressShare, { path, url }) + return + } +} + +function* handleTokenShare({ + path, + url, + linkSource, +}: { + path: string + url: string + linkSource: LinkSource +}): Generator { + const [, , network, contractAddress] = path.match(TOKEN_SHARE_LINK_HASH_REGEX) || [] + const chainId = network && fromUniswapWebAppLink(network) + + if (!chainId || !contractAddress) { + return + } + + yield* call(dismissAllModalsBeforeNavigation) + + const currencyId = + contractAddress === BACKEND_NATIVE_CHAIN_ADDRESS_STRING + ? buildNativeCurrencyId(chainId) + : buildCurrencyId(chainId, contractAddress) + yield* call(navigate, MobileScreens.TokenDetails, { + currencyId, + }) + if (linkSource === LinkSource.Share) { + yield* call(sendAnalyticsEvent, MobileEventName.ShareLinkOpened, { + entity: ShareableEntity.Token, + url, + }) + } else { + yield* call(sendAnalyticsEvent, MobileEventName.WidgetClicked, { + widget_type: WidgetType.TokenPrice, + url, + }) + } +} + +function* handleAddressShare({ path, url }: { path: string; url: string }): Generator { + const [, , accountAddress] = path.match(ADDRESS_SHARE_LINK_HASH_REGEX) || [] + if (!accountAddress) { + return + } + const accounts = yield* select(selectAccounts) + const activeAccountAddress = yield* select(selectActiveAccountAddress) + if (accountAddress === activeAccountAddress) { + return + } + + const isInternal = Boolean(accounts[accountAddress]) + + yield* call(dismissAllModalsBeforeNavigation) + + if (isInternal) { + yield* put(setAccountAsActive(accountAddress)) + } else { + yield* call(navigate, MobileScreens.ExternalProfile, { + address: accountAddress, + }) + } + yield* call(sendAnalyticsEvent, MobileEventName.ShareLinkOpened, { + entity: ShareableEntity.Wallet, + url, + }) + return +} + +function* handleBuyLink(urlObj: URL): Generator { + const searchParams = urlObj.searchParams + const value = searchParams.get('value') + const currencyCode = searchParams.get('currencyCode') + const isTokenInputMode = searchParams.get('isTokenInputMode') + const providers = searchParams + .get('providers') + ?.split(',') + .map((provider) => provider.trim()) + .filter(Boolean) + .map((provider) => provider.toUpperCase()) + + const ref = fiatOnRampNavigationRef.current + if (!ref || !ref.isFocused()) { + yield* call(dismissAllModalsBeforeNavigation) + } + yield* put( + openModal({ + name: ModalName.FiatOnRampAggregator, + initialState: { + prefilledAmount: value || undefined, + currencyCode: currencyCode || undefined, + prefilledIsTokenInputMode: isTokenInputMode === 'true', + providers, + }, + }), + ) +} diff --git a/apps/mobile/src/features/deepLinking/parseSwapLink.test.ts b/apps/mobile/src/features/deepLinking/parseSwapLink.test.ts new file mode 100644 index 00000000..7272cf95 --- /dev/null +++ b/apps/mobile/src/features/deepLinking/parseSwapLink.test.ts @@ -0,0 +1,345 @@ +import { + createSwapTransactionState, + parseSwapLinkMobileFormatOrThrow, + parseSwapLinkWebFormatOrThrow, +} from 'src/features/deepLinking/parseSwapLink' +import { AssetType } from 'uniswap/src/entities/assets' +import { UniverseChainId } from 'uniswap/src/features/chains/types' +import { CurrencyField } from 'uniswap/src/types/currency' + +describe('parseSwapLink', () => { + describe('Mobile format', () => { + it('should parse valid mobile format link', () => { + // Using USDC address on mainnet + const url = new URL( + 'https://uniswap.org/mobile-redirect?screen=swap&inputCurrencyId=1-0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee&outputCurrencyId=1-0xdAC17F958D2ee523a2206206994597C13D831ec7¤cyField=input&amount=100', + ) + + const result = parseSwapLinkMobileFormatOrThrow(url) + + expect(result.inputAsset?.chainId).toBe(UniverseChainId.Mainnet) + expect(result.exactCurrencyField).toBe(CurrencyField.INPUT) + expect(result.exactAmountToken).toBe('100') + }) + + it('should handle missing inputCurrencyId', () => { + const url = new URL( + 'https://uniswap.org/mobile-redirect?screen=swap&outputCurrencyId=1-0xdAC17F958D2ee523a2206206994597C13D831ec7¤cyField=input&amount=100', + ) + + expect(() => parseSwapLinkMobileFormatOrThrow(url)).toThrow('Not mobile format - missing currencyId parameters') + }) + }) + + describe('Universal format', () => { + it('should parse valid web format link', () => { + const url = new URL( + 'https://app.uniswap.org/swap?inputCurrency=ETH&outputCurrency=0xdAC17F958D2ee523a2206206994597C13D831ec7&chain=ethereum&value=1.5&field=INPUT', + ) + + const result = parseSwapLinkWebFormatOrThrow(url) + + expect(result.inputAsset?.chainId).toBe(UniverseChainId.Mainnet) + expect(result.exactCurrencyField).toBe(CurrencyField.INPUT) + expect(result.exactAmountToken).toBe('1.5') + }) + + it('should handle ETH as native currency', () => { + const url = new URL( + 'https://app.uniswap.org/swap?inputCurrency=ETH&outputCurrency=0xdAC17F958D2ee523a2206206994597C13D831ec7&chain=ethereum', + ) + + const result = parseSwapLinkWebFormatOrThrow(url) + + // Should use native address for ETH + expect(result.inputAsset?.address).toBe('0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee') + }) + + it('should handle cross-chain swaps', () => { + const url = new URL( + 'https://app.uniswap.org/swap?inputCurrency=ETH&chain=ethereum&outputCurrency=0x53E0bca35eC356BD5ddDFebbD1Fc0fD03FaBad39&outputChain=polygon', + ) + + const result = parseSwapLinkWebFormatOrThrow(url) + + expect(result.inputAsset?.chainId).toBe(UniverseChainId.Mainnet) + expect(result.outputAsset?.chainId).toBe(UniverseChainId.Polygon) + }) + + it('should handle missing currencies', () => { + const url = new URL('https://app.uniswap.org/swap?chain=ethereum') + + expect(() => parseSwapLinkWebFormatOrThrow(url)).toThrow('Not web format - missing currency parameters') + }) + + it('should handle only input currency provided', () => { + const url = new URL('https://app.uniswap.org/swap?inputCurrency=ETH&chain=ethereum') + + const result = parseSwapLinkWebFormatOrThrow(url) + + expect(result.inputAsset).toBeDefined() + expect(result.inputAsset?.address).toBe('0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee') + expect(result.inputAsset?.chainId).toBe(UniverseChainId.Mainnet) + expect(result.outputAsset).toBeNull() + }) + + it('should handle only output currency provided', () => { + const url = new URL( + 'https://app.uniswap.org/swap?outputCurrency=0xdAC17F958D2ee523a2206206994597C13D831ec7&chain=ethereum', + ) + + const result = parseSwapLinkWebFormatOrThrow(url) + + expect(result.outputAsset).toBeDefined() + expect(result.outputAsset?.address).toBe('0xdAC17F958D2ee523a2206206994597C13D831ec7') + expect(result.outputAsset?.chainId).toBe(UniverseChainId.Mainnet) + expect(result.inputAsset).toBeNull() + }) + + it('should handle invalid field values', () => { + const url = new URL( + 'https://app.uniswap.org/swap?inputCurrency=ETH&outputCurrency=0xdAC17F958D2ee523a2206206994597C13D831ec7&field=INVALID', + ) + + expect(() => parseSwapLinkWebFormatOrThrow(url)).toThrow('Invalid field. Must be either `INPUT` or `OUTPUT`') + }) + }) + + describe('createSwapTransactionState', () => { + it('should create valid transaction state with both assets', () => { + const params = { + inputAsset: { + address: '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', + chainId: UniverseChainId.Mainnet, + type: AssetType.Currency as AssetType.Currency, + }, + outputAsset: { + address: '0xdAC17F958D2ee523a2206206994597C13D831ec7', + chainId: UniverseChainId.Mainnet, + type: AssetType.Currency as AssetType.Currency, + }, + exactCurrencyField: CurrencyField.INPUT, + exactAmountToken: '1.5', + } + + const state = createSwapTransactionState(params) + + expect(state[CurrencyField.INPUT]).toBe(params.inputAsset) + expect(state[CurrencyField.OUTPUT]).toBe(params.outputAsset) + expect(state.exactCurrencyField).toBe(CurrencyField.INPUT) + expect(state.exactAmountToken).toBe('1.5') + }) + + it('should create valid transaction state with only input asset', () => { + const params = { + inputAsset: { + address: '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', + chainId: UniverseChainId.Mainnet, + type: AssetType.Currency as AssetType.Currency, + }, + outputAsset: null, + exactCurrencyField: CurrencyField.INPUT, + exactAmountToken: '1.5', + } + + const state = createSwapTransactionState(params) + + expect(state[CurrencyField.INPUT]).toBe(params.inputAsset) + expect(state[CurrencyField.OUTPUT]).toBeNull() + expect(state.exactCurrencyField).toBe(CurrencyField.INPUT) + expect(state.exactAmountToken).toBe('1.5') + }) + + it('should create valid transaction state with only output asset', () => { + const params = { + inputAsset: null, + outputAsset: { + address: '0xdAC17F958D2ee523a2206206994597C13D831ec7', + chainId: UniverseChainId.Mainnet, + type: AssetType.Currency as AssetType.Currency, + }, + exactCurrencyField: CurrencyField.OUTPUT, + exactAmountToken: '100', + } + + const state = createSwapTransactionState(params) + + expect(state[CurrencyField.INPUT]).toBeNull() + expect(state[CurrencyField.OUTPUT]).toBe(params.outputAsset) + expect(state.exactCurrencyField).toBe(CurrencyField.OUTPUT) + expect(state.exactAmountToken).toBe('100') + }) + }) + + describe('Mobile format with testnets', () => { + it('should parse valid Sepolia testnet link', () => { + // Using Sepolia testnet chain ID (11155111) and valid USDC address + const url = new URL( + `https://uniswap.org/mobile-redirect?screen=swap&inputCurrencyId=${UniverseChainId.Sepolia}-0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee&outputCurrencyId=${UniverseChainId.Sepolia}-0x1c7d4b196cb0c7b01d743fbc6116a902379c7238¤cyField=input&amount=1.5`, + ) + + const result = parseSwapLinkMobileFormatOrThrow(url) + + expect(result.inputAsset?.chainId).toBe(UniverseChainId.Sepolia) + expect(result.outputAsset?.chainId).toBe(UniverseChainId.Sepolia) + expect(result.exactCurrencyField).toBe(CurrencyField.INPUT) + expect(result.exactAmountToken).toBe('1.5') + }) + + it('should parse valid UnichainSepolia link', () => { + const url = new URL( + `https://uniswap.org/mobile-redirect?screen=swap&inputCurrencyId=${UniverseChainId.UnichainSepolia}-0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee&outputCurrencyId=${UniverseChainId.UnichainSepolia}-0x31d0220469e10c4E71834a79b1f276d740d3768F¤cyField=input&amount=0.5`, + ) + + const result = parseSwapLinkMobileFormatOrThrow(url) + + expect(result.inputAsset?.chainId).toBe(UniverseChainId.UnichainSepolia) + expect(result.outputAsset?.chainId).toBe(UniverseChainId.UnichainSepolia) + expect(result.exactCurrencyField).toBe(CurrencyField.INPUT) + expect(result.exactAmountToken).toBe('0.5') + }) + + it('should reject mixed testnet and mainnet chains', () => { + // Try to swap from Sepolia (testnet) to Mainnet + const url = new URL( + `https://uniswap.org/mobile-redirect?screen=swap&inputCurrencyId=${UniverseChainId.Sepolia}-0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee&outputCurrencyId=${UniverseChainId.Mainnet}-0xdAC17F958D2ee523a2206206994597C13D831ec7¤cyField=input&amount=1`, + ) + + const testFn = (): void => { + parseSwapLinkMobileFormatOrThrow(url) + } + expect(testFn).toThrow('Cannot swap between testnet and mainnet') + }) + + it('should reject mixed mainnet and testnet chains', () => { + // Try to swap from Mainnet to UnichainSepolia (testnet) + const url = new URL( + `https://uniswap.org/mobile-redirect?screen=swap&inputCurrencyId=${UniverseChainId.Mainnet}-0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee&outputCurrencyId=${UniverseChainId.UnichainSepolia}-0x31d0220469e10c4E71834a79b1f276d740d3768F¤cyField=input&amount=1`, + ) + + const testFn = (): void => { + parseSwapLinkMobileFormatOrThrow(url) + } + expect(testFn).toThrow('Cannot swap between testnet and mainnet') + }) + }) + + describe('Web format with testnets', () => { + it('should parse valid Sepolia testnet link', () => { + const url = new URL( + 'https://app.uniswap.org/swap?inputCurrency=ETH&outputCurrency=0x1c7d4b196cb0c7b01d743fbc6116a902379c7238&chain=ethereum_sepolia&value=2.5&field=INPUT', + ) + + const result = parseSwapLinkWebFormatOrThrow(url) + + expect(result.inputAsset?.chainId).toBe(UniverseChainId.Sepolia) + expect(result.outputAsset?.chainId).toBe(UniverseChainId.Sepolia) + expect(result.exactCurrencyField).toBe(CurrencyField.INPUT) + expect(result.exactAmountToken).toBe('2.5') + }) + + it('should handle cross-chain testnet swaps', () => { + const url = new URL( + 'https://app.uniswap.org/swap?inputCurrency=ETH&chain=ethereum_sepolia&outputCurrency=0x31d0220469e10c4E71834a79b1f276d740d3768F&outputChain=unichain_sepolia&value=1.0', + ) + + const result = parseSwapLinkWebFormatOrThrow(url) + + expect(result.inputAsset?.chainId).toBe(UniverseChainId.Sepolia) + expect(result.outputAsset?.chainId).toBe(UniverseChainId.UnichainSepolia) + expect(result.exactAmountToken).toBe('1.0') + }) + + it('should reject mixed testnet and mainnet in web format', () => { + // Try to swap from Sepolia to Ethereum mainnet + const url = new URL( + 'https://app.uniswap.org/swap?inputCurrency=ETH&chain=ethereum_sepolia&outputCurrency=0xdAC17F958D2ee523a2206206994597C13D831ec7&outputChain=ethereum', + ) + + const testFn = (): void => { + parseSwapLinkWebFormatOrThrow(url) + } + expect(testFn).toThrow('Cannot swap between testnet and mainnet') + }) + + it('should reject mixed mainnet and testnet in web format', () => { + // Try to swap from Ethereum mainnet to Sepolia + const url = new URL( + 'https://app.uniswap.org/swap?inputCurrency=ETH&chain=ethereum&outputCurrency=0x1c7d4b196cb0c7b01d743fbc6116a902379c7238&outputChain=ethereum_sepolia', + ) + + const testFn = (): void => { + parseSwapLinkWebFormatOrThrow(url) + } + expect(testFn).toThrow('Cannot swap between testnet and mainnet') + }) + + it('should handle only input currency on testnet', () => { + const url = new URL('https://app.uniswap.org/swap?inputCurrency=ETH&chain=ethereum_sepolia') + + const result = parseSwapLinkWebFormatOrThrow(url) + + expect(result.inputAsset).toBeDefined() + expect(result.inputAsset?.chainId).toBe(UniverseChainId.Sepolia) + expect(result.inputAsset?.address).toBe('0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee') + expect(result.outputAsset).toBeNull() + }) + + it('should handle only output currency on testnet', () => { + const url = new URL( + 'https://app.uniswap.org/swap?outputCurrency=0x1c7d4b196cb0c7b01d743fbc6116a902379c7238&chain=ethereum_sepolia', + ) + + const result = parseSwapLinkWebFormatOrThrow(url) + + expect(result.outputAsset).toBeDefined() + expect(result.outputAsset?.chainId).toBe(UniverseChainId.Sepolia) + expect(result.outputAsset?.address).toBe('0x1c7d4b196cb0c7b01d743fbc6116a902379c7238') + expect(result.inputAsset).toBeNull() + }) + }) + + describe('Cross-testnet compatibility', () => { + it('should allow swaps between different testnet chains', () => { + // Sepolia to UnichainSepolia should work (both testnets) + const url = new URL( + `https://uniswap.org/mobile-redirect?screen=swap&inputCurrencyId=${UniverseChainId.Sepolia}-0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee&outputCurrencyId=${UniverseChainId.UnichainSepolia}-0x31d0220469e10c4E71834a79b1f276d740d3768F¤cyField=input&amount=1`, + ) + + const result = parseSwapLinkMobileFormatOrThrow(url) + + expect(result.inputAsset?.chainId).toBe(UniverseChainId.Sepolia) + expect(result.outputAsset?.chainId).toBe(UniverseChainId.UnichainSepolia) + }) + + it('should allow swaps between UnichainSepolia and Sepolia', () => { + const url = new URL( + `https://uniswap.org/mobile-redirect?screen=swap&inputCurrencyId=${UniverseChainId.UnichainSepolia}-0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee&outputCurrencyId=${UniverseChainId.Sepolia}-0x1c7d4b196cb0c7b01d743fbc6116a902379c7238¤cyField=output&amount=50`, + ) + + const result = parseSwapLinkMobileFormatOrThrow(url) + + expect(result.inputAsset?.chainId).toBe(UniverseChainId.UnichainSepolia) + expect(result.outputAsset?.chainId).toBe(UniverseChainId.Sepolia) + expect(result.exactCurrencyField).toBe(CurrencyField.OUTPUT) + }) + }) + + describe('Error handling', () => { + it('should handle parsing errors gracefully for mobile format', () => { + // URL with malformed parameters + const url = new URL( + 'https://uniswap.org/mobile-redirect?screen=swap&inputCurrencyId=invalid&outputCurrencyId=also-invalid', + ) + + expect(() => parseSwapLinkMobileFormatOrThrow(url)).toThrow() + }) + + it('should handle parsing errors gracefully for web format', () => { + // URL with no currency parameters + const url = new URL('https://example.com/swap?random=param') + + expect(() => parseSwapLinkWebFormatOrThrow(url)).toThrow('Not web format - missing currency parameters') + }) + }) +}) diff --git a/apps/mobile/src/features/deepLinking/parseSwapLink.ts b/apps/mobile/src/features/deepLinking/parseSwapLink.ts new file mode 100644 index 00000000..98aced9c --- /dev/null +++ b/apps/mobile/src/features/deepLinking/parseSwapLink.ts @@ -0,0 +1,273 @@ +import { getNativeAddress } from 'uniswap/src/constants/addresses' +import { AssetType, CurrencyAsset } from 'uniswap/src/entities/assets' +import { ALL_CHAIN_IDS } from 'uniswap/src/features/chains/chainInfo' +import { UniverseChainId } from 'uniswap/src/features/chains/types' +import { fromUniswapWebAppLink, isTestnetChain } from 'uniswap/src/features/chains/utils' +import { TransactionState } from 'uniswap/src/features/transactions/types/transactionState' +import { CurrencyField } from 'uniswap/src/types/currency' +import { areAddressesEqual, getValidAddress } from 'uniswap/src/utils/addresses' +import { currencyIdToAddress, currencyIdToChain } from 'uniswap/src/utils/currencyId' + +/** + * Supported swap link formats: + * + * 1. Mobile format (legacy): + * - inputCurrencyId: chainId-tokenAddress (e.g., "1-0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee") + * - outputCurrencyId: chainId-tokenAddress + * - currencyField: "input" | "output" + * - amount: string number + * + * 2. Universal format: + * - inputCurrency: tokenAddress | "ETH" | "NATIVE" + * - outputCurrency: tokenAddress | "ETH" | "NATIVE" + * - chain: chain name (e.g., "ethereum", "polygon", "arbitrum") + * - outputChain: chain name (for cross-chain swaps) + * - value: string number + * - field: "INPUT" | "OUTPUT" + */ + +interface ParsedSwapLinkParams { + inputAsset: CurrencyAsset | null + outputAsset: CurrencyAsset | null + exactCurrencyField: CurrencyField + exactAmountToken: string +} + +export type ParseSwapLinkFunction = (url: URL) => ParsedSwapLinkParams + +/** + * Creates a TransactionState from parsed swap link parameters + */ +export function createSwapTransactionState(params: ParsedSwapLinkParams): TransactionState { + return { + [CurrencyField.INPUT]: params.inputAsset, + [CurrencyField.OUTPUT]: params.outputAsset, + exactCurrencyField: params.exactCurrencyField, + exactAmountToken: params.exactAmountToken, + } +} + +/** + * Validates that swap chains are compatible (both testnet or both mainnet) + */ +function validateSwapChainCompatibility(inputChainId: UniverseChainId, outputChainId: UniverseChainId): void { + if (isTestnetChain(inputChainId) !== isTestnetChain(outputChainId)) { + throw new Error('Cannot swap between testnet and mainnet') + } +} + +export function parseSwapLinkMobileFormatOrThrow(url: URL): ParsedSwapLinkParams { + const inputCurrencyId = url.searchParams.get('inputCurrencyId') + const outputCurrencyId = url.searchParams.get('outputCurrencyId') + const currencyField = url.searchParams.get('currencyField') + const exactAmountToken = url.searchParams.get('amount') ?? '0' + + // Check if this looks like mobile format + if (!inputCurrencyId || !outputCurrencyId) { + throw new Error('Not mobile format - missing currencyId parameters') + } + + const inputChain = currencyIdToChain(inputCurrencyId) + const inputAddress = currencyIdToAddress(inputCurrencyId) + const outputChain = currencyIdToChain(outputCurrencyId) + const outputAddress = currencyIdToAddress(outputCurrencyId) + + if (!inputChain || !inputAddress) { + throw new Error('Invalid inputCurrencyId. Must be of format `-`') + } + + if (!outputChain || !outputAddress) { + throw new Error('Invalid outputCurrencyId. Must be of format `-`') + } + + // Validate addresses + if (!getValidAddress({ address: inputAddress, chainId: inputChain, withEVMChecksum: true })) { + throw new Error('Invalid tokenAddress provided within inputCurrencyId') + } + + if (!getValidAddress({ address: outputAddress, chainId: outputChain, withEVMChecksum: true })) { + throw new Error('Invalid tokenAddress provided within outputCurrencyId') + } + + // Validate chain IDs + if (!ALL_CHAIN_IDS.includes(inputChain)) { + throw new Error('Invalid inputCurrencyId. Chain ID is not supported') + } + + if (!ALL_CHAIN_IDS.includes(outputChain)) { + throw new Error('Invalid outputCurrencyId. Chain ID is not supported') + } + + // Validate amount + if (!exactAmountToken || isNaN(Number(exactAmountToken)) || Number(exactAmountToken) < 0) { + throw new Error('Invalid swap amount') + } + + // Validate currency field + if (!currencyField || (currencyField.toLowerCase() !== 'input' && currencyField.toLowerCase() !== 'output')) { + throw new Error('Invalid currencyField. Must be either `input` or `output`') + } + + // Validate chain compatibility + validateSwapChainCompatibility(inputChain, outputChain) + + const exactCurrencyField = currencyField.toLowerCase() === 'output' ? CurrencyField.OUTPUT : CurrencyField.INPUT + + const inputAsset: CurrencyAsset = { + address: inputAddress, + chainId: inputChain, + type: AssetType.Currency, + } + + const outputAsset: CurrencyAsset = { + address: outputAddress, + chainId: outputChain, + type: AssetType.Currency, + } + + return { + inputAsset, + outputAsset, + exactCurrencyField, + exactAmountToken, + } +} + +export function parseSwapLinkWebFormatOrThrow(url: URL): ParsedSwapLinkParams { + const inputCurrency = url.searchParams.get('inputCurrency') || url.searchParams.get('inputcurrency') + const outputCurrency = url.searchParams.get('outputCurrency') || url.searchParams.get('outputcurrency') + const chain = url.searchParams.get('chain') + const outputChain = url.searchParams.get('outputChain') + const value = url.searchParams.get('value') + const field = url.searchParams.get('field') + + // Check if this looks like web format + if (!inputCurrency && !outputCurrency) { + throw new Error('Not web format - missing currency parameters') + } + + const chainData = parseWebChainData(chain, outputChain) + const addressData = parseWebCurrencyData({ inputCurrency, outputCurrency, chainData }) + const amountData = parseWebAmountData(value, field) + + // Create input asset only if input currency is provided + const inputAsset: CurrencyAsset | null = addressData.inputAddress + ? { + address: addressData.inputAddress, + chainId: chainData.finalInputChainId, + type: AssetType.Currency, + } + : null + + // Create output asset only if output currency is provided + const outputAsset: CurrencyAsset | null = addressData.outputAddress + ? { + address: addressData.outputAddress, + chainId: chainData.finalOutputChainId, + type: AssetType.Currency, + } + : null + + return { + inputAsset, + outputAsset, + exactCurrencyField: amountData.exactCurrencyField, + exactAmountToken: amountData.exactAmountToken, + } +} + +function parseWebChainData( + chain: string | null, + outputChain: string | null, +): { finalInputChainId: UniverseChainId; finalOutputChainId: UniverseChainId } { + const inputChainId = chain ? fromUniswapWebAppLink(chain) : null + const outputChainId = outputChain ? fromUniswapWebAppLink(outputChain) : null + + if (chain && !inputChainId) { + throw new Error(`Invalid chain: ${chain}`) + } + + if (outputChain && !outputChainId) { + throw new Error(`Invalid outputChain: ${outputChain}`) + } + + const finalInputChainId = inputChainId ?? UniverseChainId.Mainnet + const finalOutputChainId = outputChainId ?? finalInputChainId + + // Validate chain compatibility + validateSwapChainCompatibility(finalInputChainId, finalOutputChainId) + + return { finalInputChainId, finalOutputChainId } +} + +interface WebCurrencyDataParams { + inputCurrency: string | null + outputCurrency: string | null + chainData: { finalInputChainId: UniverseChainId; finalOutputChainId: UniverseChainId } +} + +function parseWebCurrencyData({ inputCurrency, outputCurrency, chainData }: WebCurrencyDataParams): { + inputAddress: string | null + outputAddress: string | null +} { + const inputAddress = parseCurrencyAddress(inputCurrency, chainData.finalInputChainId) + const outputAddress = parseCurrencyAddress(outputCurrency, chainData.finalOutputChainId) + + return { inputAddress, outputAddress } +} + +function parseWebAmountData( + value: string | null, + field: string | null, +): { exactAmountToken: string; exactCurrencyField: CurrencyField } { + const exactAmountToken = value || '0' + let exactCurrencyField = CurrencyField.INPUT + + if (field) { + const fieldUpper = field.toUpperCase() + if (fieldUpper === 'OUTPUT') { + exactCurrencyField = CurrencyField.OUTPUT + } else if (fieldUpper !== 'INPUT') { + throw new Error('Invalid field. Must be either `INPUT` or `OUTPUT`') + } + } + + // Validate amount if provided + if (value) { + try { + const numValue = parseFloat(exactAmountToken) + if (isNaN(numValue) || numValue < 0) { + throw new Error('Invalid amount value') + } + } catch { + throw new Error('Invalid swap amount') + } + } + + return { exactAmountToken, exactCurrencyField } +} + +function parseCurrencyAddress(currency: string | null, chainId: UniverseChainId): string | null { + if (!currency) { + return null + } + + // Handle native currency representations + if ( + currency === 'ETH' || + currency === 'NATIVE' || + areAddressesEqual({ + addressInput1: { address: currency, chainId }, + addressInput2: { address: '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', chainId }, + }) + ) { + return getNativeAddress(chainId) + } + + // Validate address format + if (!getValidAddress({ address: currency, chainId, withEVMChecksum: true })) { + throw new Error(`Invalid currency address: ${currency}`) + } + + return currency as string +} diff --git a/apps/mobile/src/features/deepLinking/types.ts b/apps/mobile/src/features/deepLinking/types.ts new file mode 100644 index 00000000..ad41543d --- /dev/null +++ b/apps/mobile/src/features/deepLinking/types.ts @@ -0,0 +1,4 @@ +export enum LinkSource { + Widget = 'Widget', + Share = 'Share', +} diff --git a/apps/mobile/src/features/deepLinking/utils.test.ts b/apps/mobile/src/features/deepLinking/utils.test.ts new file mode 100644 index 00000000..c8bad675 --- /dev/null +++ b/apps/mobile/src/features/deepLinking/utils.test.ts @@ -0,0 +1,152 @@ +import { CommonActions } from '@react-navigation/core' +import { call, put } from '@redux-saga/core/effects' +import { expectSaga } from 'redux-saga-test-plan' +import { navigationRef } from 'src/app/navigation/navigationRef' +import { dispatchNavigationAction } from 'src/app/navigation/rootNavigation' +import { dismissAllModalsBeforeNavigation } from 'src/features/deepLinking/utils' +import { closeAllModals } from 'src/features/modals/modalSlice' +import { MobileScreens } from 'uniswap/src/types/screens/mobile' + +// Mock the navigation ref +jest.mock('src/app/navigation/navigationRef', () => ({ + navigationRef: { + isReady: jest.fn(), + dispatch: jest.fn(), + getState: jest.fn(), + canGoBack: jest.fn(), + }, +})) + +const mockNavigationRef = navigationRef as jest.Mocked + +describe('dismissAllModalsBeforeNavigation', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('should close all Redux-managed modals and dismiss React Navigation modals when navigationRef is ready', () => { + mockNavigationRef.isReady.mockReturnValue(true) + mockNavigationRef.getState.mockReturnValue({ + key: 'root', + index: 2, + routeNames: [MobileScreens.Home, 'ModalName.Swap', 'ModalName.Explore'], + type: 'stack', + stale: false, + routes: [ + { name: MobileScreens.Home, key: 'home', params: undefined }, + { name: 'ModalName.Swap', key: 'swap', params: undefined }, + { name: 'ModalName.Explore', key: 'explore', params: undefined }, + ], + }) + mockNavigationRef.canGoBack.mockReturnValue(true) + + const expectedGoBackAction = CommonActions.goBack() + + return expectSaga(dismissAllModalsBeforeNavigation) + .provide([ + [put(closeAllModals()), undefined], + [call(dispatchNavigationAction, expectedGoBackAction), undefined], + ]) + .put(closeAllModals()) + .call(dispatchNavigationAction, expectedGoBackAction) + .call(dispatchNavigationAction, expectedGoBackAction) + .silentRun() + }) + + it('should close all Redux-managed modals but skip navigation actions when navigationRef is not ready', () => { + mockNavigationRef.isReady.mockReturnValue(false) + + return expectSaga(dismissAllModalsBeforeNavigation) + .provide([[put(closeAllModals()), undefined]]) + .put(closeAllModals()) + .not.call.fn(dispatchNavigationAction) + .silentRun() + }) + + it('should not dispatch navigation actions when already on home screen', () => { + mockNavigationRef.isReady.mockReturnValue(true) + mockNavigationRef.getState.mockReturnValue({ + key: 'root', + index: 0, + routeNames: [MobileScreens.Home], + type: 'stack', + stale: false, + routes: [{ name: MobileScreens.Home, key: 'home', params: undefined }], + }) + + return expectSaga(dismissAllModalsBeforeNavigation) + .provide([[put(closeAllModals()), undefined]]) + .put(closeAllModals()) + .not.call.fn(dispatchNavigationAction) + .silentRun() + }) + + it('should handle navigation state with empty routes array', () => { + mockNavigationRef.isReady.mockReturnValue(true) + mockNavigationRef.getState.mockReturnValue({ + key: 'root', + index: 0, + routeNames: [], + type: 'stack', + stale: false, + routes: [], + }) + + return expectSaga(dismissAllModalsBeforeNavigation) + .provide([[put(closeAllModals()), undefined]]) + .put(closeAllModals()) + .not.call.fn(dispatchNavigationAction) + .silentRun() + }) + + it('should handle case when home screen is not found in navigation stack', () => { + mockNavigationRef.isReady.mockReturnValue(true) + mockNavigationRef.getState.mockReturnValue({ + key: 'root', + index: 1, + routeNames: ['ModalName.Swap', 'ModalName.Explore'], + type: 'stack', + stale: false, + routes: [ + { name: 'ModalName.Swap', key: 'swap', params: undefined }, + { name: 'ModalName.Explore', key: 'explore', params: undefined }, + ], + }) + + return expectSaga(dismissAllModalsBeforeNavigation) + .provide([[put(closeAllModals()), undefined]]) + .put(closeAllModals()) + .not.call.fn(dispatchNavigationAction) + .silentRun() + }) + + it('should stop going back when canGoBack returns false', () => { + mockNavigationRef.isReady.mockReturnValue(true) + mockNavigationRef.getState.mockReturnValue({ + key: 'root', + index: 2, + routeNames: [MobileScreens.Home, 'ModalName.Swap', 'ModalName.Explore'], + type: 'stack', + stale: false, + routes: [ + { name: MobileScreens.Home, key: 'home', params: undefined }, + { name: 'ModalName.Swap', key: 'swap', params: undefined }, + { name: 'ModalName.Explore', key: 'explore', params: undefined }, + ], + }) + mockNavigationRef.canGoBack + .mockReturnValueOnce(true) // First goBack succeeds + .mockReturnValueOnce(false) // Second goBack fails + + const expectedGoBackAction = CommonActions.goBack() + + return expectSaga(dismissAllModalsBeforeNavigation) + .provide([ + [put(closeAllModals()), undefined], + [call(dispatchNavigationAction, expectedGoBackAction), undefined], + ]) + .put(closeAllModals()) + .call(dispatchNavigationAction, expectedGoBackAction) + .silentRun() + }) +}) diff --git a/apps/mobile/src/features/deepLinking/utils.ts b/apps/mobile/src/features/deepLinking/utils.ts new file mode 100644 index 00000000..9c062d1d --- /dev/null +++ b/apps/mobile/src/features/deepLinking/utils.ts @@ -0,0 +1,52 @@ +import { CommonActions } from '@react-navigation/core' +import { navigationRef } from 'src/app/navigation/navigationRef' +import { dispatchNavigationAction } from 'src/app/navigation/rootNavigation' +import { closeAllModals } from 'src/features/modals/modalSlice' +import { call, put } from 'typed-redux-saga' +import { MobileScreens } from 'uniswap/src/types/screens/mobile' + +/** + * Helper function to dismiss all open modals before navigating to a deep link target. + * This ensures the target screen is visible and not hidden behind any modals. + */ +export function* dismissAllModalsBeforeNavigation(): Generator { + // Close all Redux-managed modals + yield* put(closeAllModals()) + + // Dismiss React Navigation modals by going back to the first non-modal screen + if (navigationRef.isReady()) { + yield* call(dismissReactNavigationModalsWithoutAnimation) + } +} + +/** + * Helper function to dismiss React Navigation modals without resetting the stack. + * This preserves the current screen state and prevents re-animation of the home screen. + */ +function* dismissReactNavigationModalsWithoutAnimation(): Generator { + const navigationState = navigationRef.getState() + + // Early return if navigationState is not available (e.g., in tests, or if modal has no state) + // oxlint-disable-next-line typescript/no-unnecessary-condition + if (!navigationState || !navigationState.routes) { + return + } + + // Find the index of the first non-modal screen (usually Home) + const homeScreenIndex = navigationState.routes.findIndex((route) => route.name === MobileScreens.Home) + + // If we're already on the home screen or no modals are open, no action needed + if (homeScreenIndex === -1 || navigationState.index === homeScreenIndex) { + return + } + + // Calculate how many screens we need to go back to reach the home screen + const modalsToClose = navigationState.index - homeScreenIndex + + // Go back multiple times to dismiss modals without resetting the stack + for (let i = 0; i < modalsToClose; i++) { + if (navigationRef.canGoBack()) { + yield* call(dispatchNavigationAction, CommonActions.goBack()) + } + } +} diff --git a/apps/mobile/src/features/explore/utils.ts b/apps/mobile/src/features/explore/utils.ts new file mode 100644 index 00000000..a1b34af6 --- /dev/null +++ b/apps/mobile/src/features/explore/utils.ts @@ -0,0 +1,55 @@ +import { CustomRankingType, RankingType } from '@universe/api' +import { AppTFunction } from 'ui/src/i18n/types' +import { ExploreOrderBy, TokenMetadataDisplayType } from 'wallet/src/features/wallet/types' + +export function getTokenMetadataDisplayType(orderBy: ExploreOrderBy): TokenMetadataDisplayType { + switch (orderBy) { + case RankingType.MarketCap: + return TokenMetadataDisplayType.MarketCap + case RankingType.Volume: + return TokenMetadataDisplayType.Volume + case RankingType.TotalValueLocked: + return TokenMetadataDisplayType.TVL + case CustomRankingType.PricePercentChange1DayDesc: + case CustomRankingType.PricePercentChange1DayAsc: + return TokenMetadataDisplayType.Symbol + default: + throw new Error('Unexpected order by value ' + orderBy) + } +} + +// Label shown in the popover context menu. +export function getTokensOrderByMenuLabel(orderBy: ExploreOrderBy, t: AppTFunction): string { + switch (orderBy) { + case RankingType.MarketCap: + return t('explore.tokens.sort.option.marketCap') + case RankingType.Volume: + return t('explore.tokens.sort.option.volume') + case RankingType.TotalValueLocked: + return t('explore.tokens.sort.option.totalValueLocked') + case CustomRankingType.PricePercentChange1DayDesc: + return t('explore.tokens.sort.option.priceIncrease') + case CustomRankingType.PricePercentChange1DayAsc: + return t('explore.tokens.sort.option.priceDecrease') + default: + throw new Error('Unexpected order by value ' + orderBy) + } +} + +// Label shown when option is selected in dropdown. +export function getTokensOrderBySelectedLabel(orderBy: ExploreOrderBy, t: AppTFunction): string { + switch (orderBy) { + case RankingType.MarketCap: + return t('explore.tokens.sort.label.marketCap') + case RankingType.Volume: + return t('explore.tokens.sort.label.volume') + case RankingType.TotalValueLocked: + return t('explore.tokens.sort.label.totalValueLocked') + case CustomRankingType.PricePercentChange1DayDesc: + return t('explore.tokens.sort.label.priceIncrease') + case CustomRankingType.PricePercentChange1DayAsc: + return t('explore.tokens.sort.label.priceDecrease') + default: + throw new Error('Unexpected order by value in option text ' + orderBy) + } +} diff --git a/apps/mobile/src/features/externalProfile/ProfileContextMenu.tsx b/apps/mobile/src/features/externalProfile/ProfileContextMenu.tsx new file mode 100644 index 00000000..2756fcbe --- /dev/null +++ b/apps/mobile/src/features/externalProfile/ProfileContextMenu.tsx @@ -0,0 +1,128 @@ +import { SharedEventName } from '@uniswap/analytics-events' +import React, { useCallback, useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { NativeSyntheticEvent, Share } from 'react-native' +import ContextMenu, { ContextMenuOnPressNativeEvent } from 'react-native-context-menu-view' +import { useDispatch } from 'react-redux' +import { TouchableArea } from 'ui/src' +import { Ellipsis } from 'ui/src/components/icons' +import { uniswapUrls } from 'uniswap/src/constants/urls' +import { useUnitagsAddressQuery } from 'uniswap/src/data/apiClients/unitagsApi/useUnitagsAddressQuery' +import { getChainInfo } from 'uniswap/src/features/chains/chainInfo' +import { useEnabledChains } from 'uniswap/src/features/chains/hooks/useEnabledChains' +import { pushNotification } from 'uniswap/src/features/notifications/slice/slice' +import { AppNotificationType, CopyNotificationType } from 'uniswap/src/features/notifications/slice/types' +import { ElementName, WalletEventName } from 'uniswap/src/features/telemetry/constants' +import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { MobileScreens } from 'uniswap/src/types/screens/mobile' +import { ShareableEntity } from 'uniswap/src/types/sharing' +import { ExplorerDataType, getExplorerLink, getPortfolioUrl, openUri } from 'uniswap/src/utils/linking' +import { setClipboard } from 'utilities/src/clipboard/clipboard' +import { logger } from 'utilities/src/logger/logger' +import { noop } from 'utilities/src/react/noop' + +type MenuAction = { + title: string + action: () => void + systemIcon: string +} + +export function ProfileContextMenu({ address }: { address: Address }): JSX.Element { + const { t } = useTranslation() + const dispatch = useDispatch() + const { data: unitag } = useUnitagsAddressQuery({ + params: address ? { address } : undefined, + }) + const { defaultChainId } = useEnabledChains() + + const onPressCopyAddress = useCallback(async () => { + if (!address) { + return + } + + await setClipboard(address) + dispatch(pushNotification({ type: AppNotificationType.Copied, copyType: CopyNotificationType.Address })) + sendAnalyticsEvent(SharedEventName.ELEMENT_CLICKED, { + element: ElementName.CopyAddress, + screen: MobileScreens.ExternalProfile, + }) + }, [address, dispatch]) + + const openExplorerLink = useCallback(async () => { + await openUri({ + uri: getExplorerLink({ chainId: defaultChainId, data: address, type: ExplorerDataType.ADDRESS }), + }) + }, [address, defaultChainId]) + + const onReportProfile = useCallback(async () => { + const params = new URLSearchParams() + params.append('tf_11041337007757', address) // Wallet Address + params.append('tf_7005922218125', 'report_unitag') // Report Type Dropdown + const prefilledRequestUrl = uniswapUrls.helpRequestUrl + '?' + params.toString() + openUri({ uri: prefilledRequestUrl }).catch((e) => + logger.error(e, { tags: { file: 'ProfileContextMenu', function: 'reportProfileLink' } }), + ) + }, [address]) + + const onPressShare = useCallback(async () => { + if (!address) { + return + } + try { + const url = getPortfolioUrl(address) + await Share.share({ + message: url, + }) + sendAnalyticsEvent(WalletEventName.ShareButtonClicked, { + entity: ShareableEntity.Wallet, + url, + }) + } catch (error) { + logger.error(error, { tags: { file: 'ProfileContextMenu', function: 'onPressShare' } }) + } + }, [address]) + + const menuActions = useMemo(() => { + const options: MenuAction[] = [ + { + title: t('account.wallet.action.viewExplorer', { + blockExplorerName: getChainInfo(defaultChainId).explorer.name, + }), + action: openExplorerLink, + systemIcon: 'link', + }, + { + title: t('account.wallet.action.copy'), + action: onPressCopyAddress, + systemIcon: 'square.on.square', + }, + { + title: t('common.button.share'), + action: onPressShare, + systemIcon: 'square.and.arrow.up', + }, + ] + if (unitag) { + options.push({ + title: t('account.wallet.action.report'), + action: onReportProfile, + systemIcon: 'flag', + }) + } + return options + }, [onPressCopyAddress, onPressShare, onReportProfile, openExplorerLink, t, unitag, defaultChainId]) + + return ( + ): Promise => { + await menuActions[e.nativeEvent.index]?.action() + }} + > + + + + + ) +} diff --git a/apps/mobile/src/features/externalProfile/ProfileHeader.tsx b/apps/mobile/src/features/externalProfile/ProfileHeader.tsx new file mode 100644 index 00000000..258de68f --- /dev/null +++ b/apps/mobile/src/features/externalProfile/ProfileHeader.tsx @@ -0,0 +1,273 @@ +import React, { memo, useCallback, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { StatusBar, StyleSheet } from 'react-native' +import { FadeIn } from 'react-native-reanimated' +import { useDispatch, useSelector } from 'react-redux' +import { BackButton } from 'src/components/buttons/BackButton' +import { Favorite } from 'src/components/icons/Favorite' +import { LongText } from 'src/components/text/LongText' +import { ProfileContextMenu } from 'src/features/externalProfile/ProfileContextMenu' +import { openModal } from 'src/features/modals/modalSlice' +import { + Flex, + getUniconColors, + Image, + LinearGradient, + ScrollView, + Text, + TouchableArea, + useExtractedColors, + useIsDarkMode, + useSporeColors, +} from 'ui/src' +import { ENS_LOGO } from 'ui/src/assets' +import { SendAction, XTwitter } from 'ui/src/components/icons' +import { AnimatedFlex } from 'ui/src/components/layout/AnimatedFlex' +import { DEP_accentColors, iconSizes, imageSizes, spacing, validColor } from 'ui/src/theme' +import { AddressDisplay } from 'uniswap/src/components/accounts/AddressDisplay' +import { DisplayNameType } from 'uniswap/src/features/accounts/types' +import { useAvatar } from 'uniswap/src/features/address/avatar' +import { useENSDescription, useENSName, useENSTwitterUsername } from 'uniswap/src/features/ens/api' +import { selectWatchedAddressSet } from 'uniswap/src/features/favorites/selectors' +import { useToggleWatchedWalletCallback } from 'uniswap/src/features/favorites/useToggleWatchedWalletCallback' +import { useTestnetModeBannerHeight } from 'uniswap/src/features/settings/hooks' +import { ModalName } from 'uniswap/src/features/telemetry/constants' +import { TestID } from 'uniswap/src/test/fixtures/testIDs' +import { CurrencyField } from 'uniswap/src/types/currency' +import { openUri } from 'uniswap/src/utils/linking' +import { RecipientSelectSpeedBumps } from 'wallet/src/components/RecipientSearch/RecipientSelectSpeedBumps' +import { HeaderRadial, solidHeaderProps } from 'wallet/src/features/unitags/HeaderRadial' +import { useDisplayName } from 'wallet/src/features/wallet/hooks' + +const HEADER_GRADIENT_HEIGHT = 144 +const HEADER_ICON_SIZE = 72 +// prevents buttons from touching banner +const TESTNET_BANNER_MULTIPLIER = 1.1 + +interface ProfileHeaderProps { + address: Address +} + +export const ProfileHeader = memo(function ProfileHeader({ address }: ProfileHeaderProps): JSX.Element { + const colors = useSporeColors() + const dispatch = useDispatch() + const isDarkMode = useIsDarkMode() + const isFavorited = useSelector(selectWatchedAddressSet).has(address) + const [checkSpeedBumps, setCheckSpeedBumps] = useState(false) + + const displayName = useDisplayName(address, { includeUnitagSuffix: true }) + + // Note that if a user has a Unitag AND ENS, this prioritizes the Unitag's metadata over the ENS metadata + const nameToFetchENSMetadata = + (displayName?.type === DisplayNameType.ENS || displayName?.type === DisplayNameType.Unitag) && displayName.name + ? displayName.name + : undefined + + // ENS avatar and avatar colors + const { avatar, loading: avatarLoading } = useAvatar(address) + const { data: primaryENSName } = useENSName(address) + const { data: twitter } = useENSTwitterUsername(nameToFetchENSMetadata) + const { data: bio } = useENSDescription(nameToFetchENSMetadata) + const showENSName = primaryENSName && primaryENSName !== displayName?.name + + const { colors: avatarColors } = useExtractedColors(avatar) + + const hasAvatar = !!avatar && !avatarLoading + + // Unicon colors + const { color } = getUniconColors(address, false) + + // Wait for avatar, then render avatar extracted colors or unicon colors if no avatar + const fixedGradientColors: [string, string] = useMemo(() => { + if (avatarLoading || (hasAvatar && !avatarColors)) { + return [colors.surface1.val, colors.surface1.val] + } + if (hasAvatar && avatarColors && avatarColors.base) { + return [avatarColors.base, avatarColors.base] + } + return [color, color] + }, [avatarColors, hasAvatar, avatarLoading, colors.surface1, color]) + + const onPressFavorite = useToggleWatchedWalletCallback(address) + + const initialSendState = useMemo(() => { + return { + recipient: address, + exactAmountToken: '', + exactAmountFiat: '', + exactCurrencyField: CurrencyField.INPUT, + [CurrencyField.INPUT]: null, + [CurrencyField.OUTPUT]: null, + } + }, [address]) + + const openSendModal = useCallback(() => { + dispatch( + openModal({ + name: ModalName.Send, + ...{ initialState: initialSendState }, + }), + ) + }, [dispatch, initialSendState]) + + const onPressSend = useCallback(async () => { + setCheckSpeedBumps(true) + }, []) + + const onPressTwitter = useCallback(async () => { + if (twitter) { + await openUri({ uri: `https://twitter.com/${twitter}` }) + } + }, [twitter]) + + const { t } = useTranslation() + + const testnetBannerHeight = useTestnetModeBannerHeight() * TESTNET_BANNER_MULTIPLIER + + return ( + + + {/* fixed gradient at 0.2 opacity overlaid on surface1 */} + + + + + + {hasAvatar && avatarColors?.primary ? ( + + ) : ( + + )} + + + {/* header row */} + + + + + + + + {/* button content */} + + + + + {bio ? : null} + + {(twitter || showENSName) && ( + + + {twitter ? ( + + + + + {twitter} + + + + ) : null} + {showENSName ? ( + + + + {primaryENSName} + + + ) : null} + + + )} + + + + + + + + + + + {t('common.button.send')} + + + + + + + + + ) +}) + +const styles = StyleSheet.create({ + buttonShadow: { + elevation: 2, + shadowOffset: { + height: 2, + width: 0, + }, + shadowOpacity: 0.04, + shadowRadius: 4, + }, +}) diff --git a/apps/mobile/src/features/fiatOnRamp/ExchangeTransferModal.tsx b/apps/mobile/src/features/fiatOnRamp/ExchangeTransferModal.tsx new file mode 100644 index 00000000..8376c2c4 --- /dev/null +++ b/apps/mobile/src/features/fiatOnRamp/ExchangeTransferModal.tsx @@ -0,0 +1,25 @@ +import { AppStackScreenProp } from 'src/app/navigation/types' +import { useReactNavigationModal } from 'src/components/modals/useReactNavigationModal' +import { ExchangeTransferConnecting } from 'src/screens/ExchangeTransferConnecting' +import { Modal } from 'uniswap/src/components/modals/Modal' +import { ModalName } from 'uniswap/src/features/telemetry/constants' + +export function ExchangeTransferModal({ + route, +}: AppStackScreenProp): JSX.Element | null { + const { onClose } = useReactNavigationModal() + const serviceProvider = route.params.initialState.serviceProvider + + return ( + + + + ) +} diff --git a/apps/mobile/src/features/fiatOnRamp/ExchangeTransferServiceProviderSelector.tsx b/apps/mobile/src/features/fiatOnRamp/ExchangeTransferServiceProviderSelector.tsx new file mode 100644 index 00000000..86448bf3 --- /dev/null +++ b/apps/mobile/src/features/fiatOnRamp/ExchangeTransferServiceProviderSelector.tsx @@ -0,0 +1,101 @@ +import React, { useCallback } from 'react' +import { FlatList, ListRenderItemInfo } from 'react-native' +import { FadeIn, FadeOut } from 'react-native-reanimated' +import { navigate } from 'src/app/navigation/rootNavigation' +import { Flex, Text, TouchableArea, UniversalImage, useIsDarkMode } from 'ui/src' +import { AnimatedFlex } from 'ui/src/components/layout/AnimatedFlex' +import { UniversalImageResizeMode } from 'ui/src/components/UniversalImage/types' +import { iconSizes } from 'ui/src/theme' +import { FORServiceProvider } from 'uniswap/src/features/fiatOnRamp/types' +import { getOptionalServiceProviderLogo } from 'uniswap/src/features/fiatOnRamp/utils' +import { ModalName } from 'uniswap/src/features/telemetry/constants' + +function key(item: FORServiceProvider): string { + return item.serviceProvider +} + +const CEX_ICON_SIZE = iconSizes.icon36 +const CEX_ICON_BORDER_RADIUS = 8 + +function CEXItemWrapper({ + serviceProvider, + onSelectServiceProvider, +}: { + serviceProvider: FORServiceProvider + onSelectServiceProvider: (serviceProvider: FORServiceProvider) => void +}): JSX.Element | null { + const onPress = (): void => onSelectServiceProvider(serviceProvider) + + const isDarkMode = useIsDarkMode() + const logoUrl = getOptionalServiceProviderLogo(serviceProvider.logos, isDarkMode) ?? '' + + return ( + + + + + + {serviceProvider.name} + + + + + ) +} + +export function ServiceProviderSelector({ + onClose, + serviceProviders, +}: { + onClose: () => void + serviceProviders: FORServiceProvider[] +}): JSX.Element { + const onSelectServiceProvider = useCallback( + (serviceProvider: FORServiceProvider) => { + onClose() + navigate(ModalName.ExchangeTransferModal, { + initialState: { serviceProvider }, + }) + }, + [onClose], + ) + + const renderItem = useCallback( + ({ item: serviceProvider }: ListRenderItemInfo) => ( + + ), + [onSelectServiceProvider], + ) + + return ( + + + + + + ) +} + +const renderItemSeparator = (): JSX.Element => diff --git a/apps/mobile/src/features/fiatOnRamp/FiatOnRampAggregatorModal.tsx b/apps/mobile/src/features/fiatOnRamp/FiatOnRampAggregatorModal.tsx new file mode 100644 index 00000000..bbc6b95b --- /dev/null +++ b/apps/mobile/src/features/fiatOnRamp/FiatOnRampAggregatorModal.tsx @@ -0,0 +1,12 @@ +import React from 'react' +import { FiatOnRampStackNavigator } from 'src/app/navigation/navigation' +import { FullScreenNavModal } from 'src/components/modals/FullScreenNavModal' +import { ModalName } from 'uniswap/src/features/telemetry/constants' + +export function FiatOnRampAggregatorModal(): JSX.Element { + return ( + + + + ) +} diff --git a/apps/mobile/src/features/fiatOnRamp/FiatOnRampAmountSection.tsx b/apps/mobile/src/features/fiatOnRamp/FiatOnRampAmountSection.tsx new file mode 100644 index 00000000..08d55774 --- /dev/null +++ b/apps/mobile/src/features/fiatOnRamp/FiatOnRampAmountSection.tsx @@ -0,0 +1,395 @@ +import { useFocusEffect } from '@react-navigation/core' +import React, { forwardRef, RefObject, useCallback, useEffect, useImperativeHandle, useRef } from 'react' +import { useTranslation } from 'react-i18next' +import { NativeSyntheticEvent, TextInput as RNTextInput, TextInputSelectionChangeEventData } from 'react-native' +import { TouchableOpacity } from 'react-native-gesture-handler' +import { useFiatOnRampContext } from 'src/features/fiatOnRamp/FiatOnRampContext' +import { + ColorTokens, + Flex, + Text, + TouchableArea, + useIsShortMobileDevice, + useShakeAnimation, + useSporeColors, +} from 'ui/src' +import { ArrowDownArrowUp } from 'ui/src/components/icons' +import { AnimatedFlex } from 'ui/src/components/layout/AnimatedFlex' +import { useDeviceDimensions } from 'ui/src/hooks/useDeviceDimensions' +import { useDynamicFontSizing } from 'ui/src/hooks/useDynamicFontSizing' +import { fonts, spacing } from 'ui/src/theme' +import { AmountInput } from 'uniswap/src/components/AmountInput/AmountInput' +import { TextInput } from 'uniswap/src/components/input/TextInput' +import { Pill } from 'uniswap/src/components/pill/Pill' +import { PortfolioBalance } from 'uniswap/src/features/dataApi/types' +import { useFormatExactCurrencyAmount } from 'uniswap/src/features/fiatOnRamp/hooks' +import { FiatCurrencyInfo, FiatOnRampCurrency } from 'uniswap/src/features/fiatOnRamp/types' +import { useMaxAmountSpend } from 'uniswap/src/features/gas/hooks/useMaxAmountSpend' +import { useLocalizationContext } from 'uniswap/src/features/language/LocalizationContext' +import { getCurrencyAmount, ValueType } from 'uniswap/src/features/tokens/getCurrencyAmount' +import { TransactionType } from 'uniswap/src/features/transactions/types/transactionDetails' +import { TestID } from 'uniswap/src/test/fixtures/testIDs' +import { usePrevious } from 'utilities/src/react/hooks' +import { DEFAULT_DELAY, useDebounce } from 'utilities/src/time/timing' + +const MAX_INPUT_FONT_SIZE = 52 +const MIN_INPUT_FONT_SIZE = 32 +const MIN_SCREEN_HEIGHT = 667 // iPhone SE 3rd Gen + +// if font changes from `fontFamily.sansSerif.regular` or `MAX_INPUT_FONT_SIZE` +// changes from 46 then width value must be adjusted +const MAX_CHAR_PIXEL_WIDTH = 46 + +const PREDEFINED_ONRAMP_AMOUNTS = [100, 300, 1000] +const PREDEFINED_OFFRAMP_PERCENTAGES = [25, 50, 75, 100] + +type OnChangeAmount = (amount: string, newIsTokenInputMode?: boolean) => void + +function OnRampError({ errorText, color }: { errorText?: string; color: ColorTokens }): JSX.Element { + return ( + + {errorText} + + ) +} + +interface FiatOnRampAmountSectionProps { + disabled?: boolean + value: string + errorText: string | undefined + currency: FiatOnRampCurrency + onEnterAmount: OnChangeAmount + onChoosePredefinedValue: OnChangeAmount + onToggleIsTokenInputMode: () => void + quoteAmount: number + sourceAmount: number + quoteCurrencyAmountReady: boolean + selectTokenLoading: boolean + onTokenSelectorPress: () => void + predefinedAmountsSupported: boolean + appFiatCurrencySupported: boolean + notAvailableInThisRegion?: boolean + fiatCurrencyInfo: FiatCurrencyInfo + onSelectionChange?: (start: number, end: number) => void + portfolioBalance?: PortfolioBalance | null +} + +export type FiatOnRampAmountSectionRef = { + textInputRef: RefObject + triggerShakeAnimation: () => void +} + +export const FiatOnRampAmountSection = forwardRef( + // oxlint-disable-next-line complexity -- biome-parity: oxlint is stricter here + function FiatOnRampAmountSectionInner( + { + disabled, + value, + onSelectionChange: selectionChange, + errorText, + onEnterAmount, + onChoosePredefinedValue, + onToggleIsTokenInputMode, + predefinedAmountsSupported, + appFiatCurrencySupported, + notAvailableInThisRegion, + fiatCurrencyInfo, + quoteAmount, + sourceAmount, + currency, + selectTokenLoading, + portfolioBalance, + }, + forwardedRef, + ): JSX.Element { + const { t } = useTranslation() + const isShortMobileDevice = useIsShortMobileDevice() + const { + onLayout: onInputLayout, + fontSize, + onSetFontSize, + } = useDynamicFontSizing({ + maxCharWidthAtMaxFontSize: MAX_CHAR_PIXEL_WIDTH, + maxFontSize: MAX_INPUT_FONT_SIZE, + minFontSize: MIN_INPUT_FONT_SIZE, + }) + const prevErrorText = usePrevious(errorText) + const { fullHeight } = useDeviceDimensions() + + const { isTokenInputMode, isOffRamp } = useFiatOnRampContext() + + const inputRef = useRef(null) + + useImperativeHandle(forwardedRef, () => ({ + textInputRef: inputRef, + triggerShakeAnimation, + })) + + // This is needed to ensure that the text resizes when modified from outside the component (e.g. custom numpad) + useEffect(() => { + if (value) { + onSetFontSize(value) + // Always set font size if focused to format placeholder size, we need to pass in a non-empty string to avoid formatting crash + } else { + onSetFontSize('0') + } + }, [onSetFontSize, value]) + + const onSelectionChange = useCallback( + ({ + nativeEvent: { + selection: { start, end }, + }, + }: NativeSyntheticEvent) => selectionChange?.(start, end), + [selectionChange], + ) + + const { shakeStyle: inputAnimatedStyle, triggerShakeAnimation } = useShakeAnimation() + + useEffect(() => { + async function shake(): Promise { + triggerShakeAnimation() + } + if (errorText && prevErrorText !== errorText) { + shake().catch(() => undefined) + } + }, [errorText, prevErrorText, triggerShakeAnimation]) + + // Design has asked to make it around 100ms and DEFAULT_DELAY is 200ms + const debouncedErrorText = useDebounce(errorText, DEFAULT_DELAY / 2) + + // we want to always focus amount input + const isTextInputRefActuallyFocused = inputRef.current?.isFocused() + useFocusEffect( + useCallback(() => { + if (!isTextInputRefActuallyFocused) { + inputRef.current?.focus() + } + }, [isTextInputRefActuallyFocused]), + ) + + const derivedFiatAmount = isOffRamp ? quoteAmount : sourceAmount + const derivedTokenAmount = useFormatExactCurrencyAmount( + isOffRamp ? sourceAmount.toString() : quoteAmount.toString(), + currency.currencyInfo?.currency, + ) + + const derivedAmount = isTokenInputMode ? derivedFiatAmount.toString() : derivedTokenAmount + const formattedDerivedAmount = isTokenInputMode + ? `${fiatCurrencyInfo.symbol}${derivedAmount}` + : `${derivedAmount}${currency.currencyInfo?.currency.symbol}` + + // Workaround to avoid incorrect input width calculations by react-native + // Decimal numbers were manually calculated for Basel Grotesk fonts and will + // require an adjustment when the font is changed + const calculatedInputWidth = [...value].reduce( + (acc, numStr) => { + switch (numStr) { + case '1': + return acc + fontSize * 0.393 + case '2': + case '6': + case '8': + return acc + fontSize * 0.596 + case '3': + return acc + fontSize * 0.595 + case '4': + case '0': + return acc + fontSize * 0.62 + case '5': + case '7': + return acc + fontSize * 0.602 + case '9': + return acc + fontSize * 0.607 + case '.': + case ',': + return acc + fontSize * 0.25 + default: + return acc + fontSize * 0.62 + } + }, + // ensures a proper width for a "0" placeholder or adds 3 points for the input caret + value.length === 0 ? fontSize * 0.62 : 3, + ) + + return ( + + + {notAvailableInThisRegion ? ( + + ) : debouncedErrorText ? ( + + ) : !appFiatCurrencySupported ? ( + + ) : null} + + + + + {isTokenInputMode ? ' ' + currency.currencyInfo?.currency.symbol : fiatCurrencyInfo.symbol} + + + + + {!value && predefinedAmountsSupported ? ( + + {(isOffRamp ? PREDEFINED_OFFRAMP_PERCENTAGES : PREDEFINED_ONRAMP_AMOUNTS).map((amount) => ( + + ))} + + ) : ( + + + + {formattedDerivedAmount} + + + + + )} + + ) + }, +) + +// Predefined amount is only supported for certain currencies +function PredefinedAmount({ + amount, + onPress, + currentAmount, + fiatCurrencyInfo, + disabled, + isOffRamp, + isMaxAmount, + currency, + portfolioBalance, +}: { + amount: number + currentAmount: string + onPress: (amount: string) => void + fiatCurrencyInfo: FiatCurrencyInfo + disabled?: boolean + isOffRamp?: boolean + isMaxAmount?: boolean + currency?: FiatOnRampCurrency + portfolioBalance?: PortfolioBalance | null +}): JSX.Element { + const colors = useSporeColors() + const { addFiatSymbolToNumber } = useLocalizationContext() + const { t } = useTranslation() + const currencyBalance = + currency?.currencyInfo?.currency && portfolioBalance?.quantity && isOffRamp + ? getCurrencyAmount({ + value: portfolioBalance.quantity.toString(), + valueType: ValueType.Exact, + currency: currency.currencyInfo.currency, + }) + : undefined + + const maxSpendableAmount = useMaxAmountSpend({ + currencyAmount: currencyBalance, + txType: TransactionType.Send, + }) + + const handlePress = useCallback(async (): Promise => { + if (!isOffRamp) { + onPress(amount.toString()) + } else if (isMaxAmount && maxSpendableAmount && currency?.currencyInfo) { + onPress(maxSpendableAmount.toExact()) + } else { + const percentOfBalance = (parseFloat(currencyBalance?.toExact() ?? '0') * (amount / 100)).toString() + onPress(percentOfBalance) + } + }, [isMaxAmount, maxSpendableAmount, currency?.currencyInfo, onPress, currencyBalance, amount, isOffRamp]) + + const formattedAmount = isOffRamp + ? isMaxAmount + ? t('common.max') + : `${amount}%` + : addFiatSymbolToNumber({ + value: amount, + currencyCode: fiatCurrencyInfo.code, + currencySymbol: fiatCurrencyInfo.symbol, + }) + + const highlighted = currentAmount === amount.toString() + + return ( + + + + ) +} diff --git a/apps/mobile/src/features/fiatOnRamp/FiatOnRampContext.tsx b/apps/mobile/src/features/fiatOnRamp/FiatOnRampContext.tsx new file mode 100644 index 00000000..8b9ebe9c --- /dev/null +++ b/apps/mobile/src/features/fiatOnRamp/FiatOnRampContext.tsx @@ -0,0 +1,172 @@ +/** + * This context is used to persist Fiat On Ramp related data between Fiat On Ramp screens. + */ +import React, { createContext, useContext, useEffect, useMemo, useState } from 'react' +import { SectionListData } from 'react-native' +import { getCountry } from 'react-native-localize' +import { useSelector } from 'react-redux' +import { selectModalState } from 'src/features/modals/selectModalState' +import { getNativeAddress } from 'uniswap/src/constants/addresses' +import { UniverseChainId } from 'uniswap/src/features/chains/types' +import { FiatCurrencyInfo, FiatOnRampCurrency, FORFilters, FORQuote } from 'uniswap/src/features/fiatOnRamp/types' +import { ModalName } from 'uniswap/src/features/telemetry/constants' +import { useCurrencyInfo } from 'uniswap/src/features/tokens/useCurrencyInfo' +import { buildCurrencyId } from 'uniswap/src/utils/currencyId' +import { v4 as uuidv4 } from 'uuid' + +interface FiatOnRampContextType { + quotesSections?: SectionListData[] | undefined + setQuotesSections: (quotesSections: SectionListData[] | undefined) => void + selectedQuote?: FORQuote + setSelectedQuote: (quote: FORQuote | undefined) => void + countryCode: string + setCountryCode: (countryCode: string) => void + countryState: string | undefined + setCountryState: (countryCode: string | undefined) => void + baseCurrencyInfo?: FiatCurrencyInfo + setBaseCurrencyInfo: (baseCurrency: FiatCurrencyInfo | undefined) => void + quoteCurrency: FiatOnRampCurrency + defaultCurrency: FiatOnRampCurrency + setQuoteCurrency: (quoteCurrency: FiatOnRampCurrency) => void + fiatAmount: number | undefined + tokenAmount: number | undefined + setFiatAmount: (fiatAmount: number | undefined) => void + setTokenAmount: (tokenAmount: number | undefined) => void + isOffRamp: boolean + setIsOffRamp: (isOffRamp: boolean) => void + isTokenInputMode: boolean + setIsTokenInputMode: React.Dispatch> + externalTransactionIdSuffix: string + providers: string[] + currencyCode?: string + setPaymentMethod: (paymentMethod: FORFilters | undefined) => void + paymentMethod?: FORFilters +} + +const initialState: FiatOnRampContextType = { + setQuotesSections: () => undefined, + setSelectedQuote: () => undefined, + setCountryCode: () => undefined, + setCountryState: () => undefined, + setBaseCurrencyInfo: () => undefined, + setQuoteCurrency: () => undefined, + setFiatAmount: () => undefined, + setTokenAmount: () => undefined, + fiatAmount: undefined, + tokenAmount: undefined, + countryCode: '', + countryState: undefined, + quoteCurrency: { currencyInfo: undefined }, + defaultCurrency: { currencyInfo: undefined }, + isOffRamp: false, + setIsOffRamp: () => undefined, + isTokenInputMode: false, + setIsTokenInputMode: () => undefined, + externalTransactionIdSuffix: '', + providers: [], + currencyCode: undefined, + setPaymentMethod: () => undefined, + paymentMethod: undefined, +} + +const FiatOnRampContext = createContext(initialState) + +export function useFiatOnRampContext(): FiatOnRampContextType { + return useContext(FiatOnRampContext) +} + +export function FiatOnRampProvider({ children }: { children: React.ReactNode }): JSX.Element { + const { initialState: initialModalState } = useSelector(selectModalState(ModalName.FiatOnRampAggregator)) + const { prefilledCurrency, prefilledAmount, prefilledIsTokenInputMode, currencyCode } = initialModalState ?? {} + + const [quotesSections, setQuotesSections] = useState() + const [selectedQuote, setSelectedQuote] = useState() + const [countryCode, setCountryCode] = useState(getCountry()) + const [countryState, setCountryState] = useState() + const [baseCurrencyInfo, setBaseCurrencyInfo] = useState() + const [isTokenInputMode, setIsTokenInputMode] = useState(false) + const [fiatAmount, setFiatAmount] = useState() + const [tokenAmount, setTokenAmount] = useState() + const [externalTransactionIdSuffix] = useState(() => { + // Generate a UUID and extract the last 4 groups as the suffix + return uuidv4().split('-').slice(1).join('-') + }) + + // We hardcode ETH as the default starting currency if not specified by modal state's prefilledCurrency + const ethCurrencyInfo = useCurrencyInfo( + buildCurrencyId(UniverseChainId.Mainnet, getNativeAddress(UniverseChainId.Mainnet)), + ) + const defaultCurrency = useMemo( + () => ({ + currencyInfo: ethCurrencyInfo, + meldCurrencyCode: 'ETH', + }), + [ethCurrencyInfo], + ) + const [quoteCurrency, setQuoteCurrency] = useState(prefilledCurrency ?? defaultCurrency) + const [isOffRamp, setIsOffRamp] = useState(initialModalState?.isOfframp ?? false) + const [providers, setProviders] = useState([]) + const [paymentMethod, setPaymentMethod] = useState() + + useEffect(() => { + if (initialModalState?.providers) { + setProviders(initialModalState.providers) + } + }, [initialModalState?.providers]) + + useEffect(() => { + if (prefilledCurrency || quoteCurrency.currencyInfo) { + return + } + // Addresses a race condition where the quoteCurrency could be set before ethCurrencyInfo is loaded + if (ethCurrencyInfo) { + setQuoteCurrency(defaultCurrency) + } + }, [ethCurrencyInfo, defaultCurrency, prefilledCurrency, quoteCurrency]) + + useEffect(() => { + if (prefilledAmount) { + if (prefilledIsTokenInputMode) { + setTokenAmount(parseFloat(prefilledAmount)) + } else { + setFiatAmount(parseFloat(prefilledAmount)) + } + setIsTokenInputMode(prefilledIsTokenInputMode ?? false) + } + }, [prefilledAmount, prefilledIsTokenInputMode]) + + return ( + + {children} + + ) +} diff --git a/apps/mobile/src/features/fiatOnRamp/FiatOnRampCountryListModal.tsx b/apps/mobile/src/features/fiatOnRamp/FiatOnRampCountryListModal.tsx new file mode 100644 index 00000000..c884d559 --- /dev/null +++ b/apps/mobile/src/features/fiatOnRamp/FiatOnRampCountryListModal.tsx @@ -0,0 +1,160 @@ +import React, { useCallback, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { ListRenderItemInfo } from 'react-native' +import { FadeIn, FadeOut } from 'react-native-reanimated' +import { SvgUri } from 'react-native-svg' +import { Loader } from 'src/components/loading/loaders' +import { useFiatOnRampContext } from 'src/features/fiatOnRamp/FiatOnRampContext' +import { Flex, Text, TouchableArea, useSporeColors } from 'ui/src' +import { AnimatedBottomSheetFlashList } from 'ui/src/components/AnimatedFlashList/AnimatedFlashList' +import { Check } from 'ui/src/components/icons' +import { AnimatedFlex } from 'ui/src/components/layout/AnimatedFlex' +import { useDeviceDimensions } from 'ui/src/hooks/useDeviceDimensions' +import { fonts, spacing } from 'ui/src/theme' +import { Modal } from 'uniswap/src/components/modals/Modal' +import { FOR_MODAL_SNAP_POINTS } from 'uniswap/src/features/fiatOnRamp/constants' +import { useFiatOnRampAggregatorCountryListQuery } from 'uniswap/src/features/fiatOnRamp/hooks/useFiatOnRampQueries' +import { FORCountry, RampDirection } from 'uniswap/src/features/fiatOnRamp/types' +import { getCountryFlagSvgUrl } from 'uniswap/src/features/fiatOnRamp/utils' +import { SearchTextInput } from 'uniswap/src/features/search/SearchTextInput' +import { ModalName } from 'uniswap/src/features/telemetry/constants' +import { useAppInsets } from 'uniswap/src/hooks/useAppInsets' +import { bubbleToTop } from 'utilities/src/primitives/array' +import { useDebounce } from 'utilities/src/time/timing' + +const ICON_SIZE = 32 // design prefers a custom value here + +interface CountrySelectorProps { + onSelectCountry: (country: FORCountry) => void + countryCode: string +} + +function key(item: FORCountry): string { + return item.countryCode +} + +function CountrySelectorContent({ onSelectCountry, countryCode }: CountrySelectorProps): JSX.Element { + const { t } = useTranslation() + const insets = useAppInsets() + const { isOffRamp } = useFiatOnRampContext() + + const { data, isLoading } = useFiatOnRampAggregatorCountryListQuery({ + rampDirection: isOffRamp ? RampDirection.OFF_RAMP : RampDirection.ON_RAMP, + }) + + const [searchText, setSearchText] = useState('') + + const debouncedSearchText = useDebounce(searchText) + + const filteredData: FORCountry[] = useMemo(() => { + if (!data) { + return [] + } + return bubbleToTop(data.supportedCountries, (c) => c.countryCode === countryCode).filter( + (item) => !debouncedSearchText || item.displayName.toLowerCase().startsWith(debouncedSearchText.toLowerCase()), + ) + }, [countryCode, data, debouncedSearchText]) + + const renderItem = useCallback( + ({ item }: ListRenderItemInfo): JSX.Element => { + const countryFlagUrl = getCountryFlagSvgUrl(item.countryCode) + + return ( + onSelectCountry(item)}> + + + + + {item.displayName} + {item.countryCode === countryCode && ( + + + + )} + + + ) + }, + [countryCode, onSelectCountry], + ) + + return ( + + + {t('fiatOnRamp.region.title')} + + + + + {isLoading ? ( + + ) : ( + } + bounces={true} + contentContainerStyle={{ paddingBottom: insets.bottom + spacing.spacing12 }} + data={filteredData} + keyExtractor={key} + keyboardDismissMode="on-drag" + keyboardShouldPersistTaps="always" + renderItem={renderItem} + showsVerticalScrollIndicator={false} + windowSize={5} + /> + )} + + + + ) +} + +const CountryListPlaceholder = React.memo(function CountryListPlaceholder({ + itemsCount, +}: { + itemsCount: number +}): JSX.Element { + const { fullWidth } = useDeviceDimensions() + return ( + + {new Array(itemsCount).fill(null).map((_, i) => ( + + + + + ))} + + ) +}) + +export function FiatOnRampCountryListModal({ + onClose, + onSelectCountry, + countryCode, +}: { + onClose: () => void +} & CountrySelectorProps): JSX.Element { + const colors = useSporeColors() + + return ( + + + + ) +} diff --git a/apps/mobile/src/features/fiatOnRamp/FiatOnRampTokenSelector.tsx b/apps/mobile/src/features/fiatOnRamp/FiatOnRampTokenSelector.tsx new file mode 100644 index 00000000..cd701465 --- /dev/null +++ b/apps/mobile/src/features/fiatOnRamp/FiatOnRampTokenSelector.tsx @@ -0,0 +1,74 @@ +import React from 'react' +import { useTranslation } from 'react-i18next' +import { FadeIn, FadeOut } from 'react-native-reanimated' +import { TokenFiatOnRampList } from 'src/components/TokenSelector/TokenFiatOnRampList' +import { Flex, Text, useSporeColors } from 'ui/src' +import { AnimatedFlex } from 'ui/src/components/layout/AnimatedFlex' +import { Modal } from 'uniswap/src/components/modals/Modal' +import { PortfolioBalance } from 'uniswap/src/features/dataApi/types' +import { FOR_MODAL_SNAP_POINTS } from 'uniswap/src/features/fiatOnRamp/constants' +import { FiatOnRampCurrency } from 'uniswap/src/features/fiatOnRamp/types' +import { ElementName, ModalName, SectionName } from 'uniswap/src/features/telemetry/constants' +import Trace from 'uniswap/src/features/telemetry/Trace' + +interface Props { + onSelectCurrency: (currency: FiatOnRampCurrency) => void + onRetry: () => void + onClose: () => void + error: boolean + loading: boolean + list: FiatOnRampCurrency[] | undefined + balancesById: Record | undefined + selectedCurrency?: FiatOnRampCurrency + isOffRamp: boolean +} + +export function FiatOnRampTokenSelectorModal({ + error, + list, + loading, + onClose, + onRetry, + onSelectCurrency, + balancesById, + selectedCurrency, + isOffRamp, +}: { onClose: () => void } & Props): JSX.Element { + const { t } = useTranslation() + const colors = useSporeColors() + + return ( + + + + + {t('fiatOnRamp.button.chooseToken')} + + + + + + + + ) +} diff --git a/apps/mobile/src/features/fiatOnRamp/OffRampPopover.tsx b/apps/mobile/src/features/fiatOnRamp/OffRampPopover.tsx new file mode 100644 index 00000000..e41e1811 --- /dev/null +++ b/apps/mobile/src/features/fiatOnRamp/OffRampPopover.tsx @@ -0,0 +1,72 @@ +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useDispatch, useSelector } from 'react-redux' +import { Popover, Text } from 'ui/src' +import { isAndroid } from 'utilities/src/platform' +import { selectHasViewedOffRampTooltip } from 'wallet/src/features/behaviorHistory/selectors' +import { setHasViewedOffRampTooltip } from 'wallet/src/features/behaviorHistory/slice' + +const POPOVER_OFFSET_X = 31 +const POPOVER_OFFSET_Y = isAndroid ? 42 : 18 +const POPOVER_WIDTH = 200 +const POPOVER_DELAY_MS = 1500 + +export function OffRampPopover({ triggerContent }: { triggerContent: JSX.Element }): JSX.Element { + const { t } = useTranslation() + const dispatch = useDispatch() + const [delayedOpen, setDelayedOpen] = useState(false) + const hasViewedOffRampTooltip = useSelector(selectHasViewedOffRampTooltip) + + useEffect(() => { + setTimeout(() => { + setDelayedOpen(true) + }, POPOVER_DELAY_MS) + }, []) + + function onOpenChange(open: boolean): void { + if (!open) { + dispatch(setHasViewedOffRampTooltip(true)) + } + } + + return ( + + {triggerContent} + + + {t('fiatOffRamp.welcome.tooltip')} + + + ) +} diff --git a/apps/mobile/src/features/firebase/firebaseDataSaga.ts b/apps/mobile/src/features/firebase/firebaseDataSaga.ts new file mode 100644 index 00000000..4f1644d6 --- /dev/null +++ b/apps/mobile/src/features/firebase/firebaseDataSaga.ts @@ -0,0 +1,283 @@ +import firebase from '@react-native-firebase/app' +import auth from '@react-native-firebase/auth' +import firestore from '@react-native-firebase/firestore' +import { getFirebaseUidOrError, getFirestoreMetadataRef, getFirestoreUidRef } from 'src/features/firebase/utils' +import { getOneSignalUserIdOrError } from 'src/features/notifications/Onesignal' +import { all, call, put, select, takeEvery, takeLatest } from 'typed-redux-saga' +import { AccountType } from 'uniswap/src/features/accounts/types' +import { Language } from 'uniswap/src/features/language/constants' +import { getLocale } from 'uniswap/src/features/language/navigatorLocale' +import { selectCurrentLanguage } from 'uniswap/src/features/settings/selectors' +import { setCurrentLanguage } from 'uniswap/src/features/settings/slice' +import { logger } from 'utilities/src/logger/logger' +import { getKeys } from 'utilities/src/primitives/objects' +import { + EditAccountAction, + editAccountActions, + TogglePushNotificationParams, +} from 'wallet/src/features/wallet/accounts/editAccountSaga' +import { Account } from 'wallet/src/features/wallet/accounts/types' +import { makeSelectAccountNotificationSetting, selectAccounts } from 'wallet/src/features/wallet/selectors' +import { addAccounts, editAccount } from 'wallet/src/features/wallet/slice' + +interface AccountMetadata { + name?: string + type?: AccountType + avatar?: string + testnetsEnabled?: boolean + locale?: string +} + +function* initFirebase() { + try { + const firebaseAuth = auth() + yield* call([firebaseAuth, 'signInAnonymously']) + logger.debug('initFirebaseSaga', 'initFirebase', 'Firebase initialization successful') + } catch (error) { + logger.error(error, { + tags: { file: 'firebaseDataSaga', function: 'initFirebase' }, + }) + } +} + +export function* firebaseDataWatcher() { + yield* call(initFirebase) + yield* call(syncNotificationsWithFirebase) + + // Can't merge with `editAccountSaga` because it can't handle simultaneous actions + yield* takeEvery(editAccountActions.trigger, editAccountDataInFirebase) + yield* takeLatest(setCurrentLanguage, syncLanguageWithFirebase) + yield* takeEvery(addAccounts, syncAccountWithFirebase) +} + +function* syncNotificationsWithFirebase() { + try { + const accounts = yield* select(selectAccounts) + const addresses = Object.keys(accounts) + + for (const address of addresses) { + const selectAccountNotificationSetting = yield* call(makeSelectAccountNotificationSetting) + const notificationsEnabled = yield* select(selectAccountNotificationSetting, address) + + if (notificationsEnabled) { + yield* call(mapFirebaseUidToAddresses, [address]) + } else { + yield* call(deleteFirebaseMetadata, address) + yield* call(disassociateFirebaseUidFromAddresses, [address]) + } + } + } catch (error) { + // Permission denied error is expected for syncing when notifications are disabled + if (!(error instanceof Error && error.message.includes('permission-denied'))) { + logger.error(error, { + tags: { file: 'firebaseDataSaga', function: 'syncNotificationsWithFirebase' }, + }) + } + } +} + +function* syncLanguageWithFirebase(actionData: ReturnType) { + const accounts = yield* select(selectAccounts) + const addresses = Object.keys(accounts) + + yield* call(updateFirebaseLanguage, addresses, actionData.payload) +} + +function* syncAccountWithFirebase(actionData: ReturnType) { + const currentLanguage = yield* select(selectCurrentLanguage) + const addedAccountsAddresses = actionData.payload.map((account) => account.address) + yield* call(updateFirebaseLanguage, addedAccountsAddresses, currentLanguage) +} + +function* updateFirebaseLanguage(addresses: Address[], language: Language) { + const locale = getLocale(language) + + for (const address of addresses) { + yield* put( + editAccountActions.trigger({ + type: EditAccountAction.UpdateLanguage, + address, + locale, + }), + ) + } +} + +function* editAccountDataInFirebase(actionData: ReturnType) { + const { payload } = actionData + + switch (payload.type) { + case EditAccountAction.Remove: { + const accountsToRemove = payload.accounts + yield* all( + accountsToRemove.map((account: { address: Address; pushNotificationsEnabled?: boolean }) => + call(removeAccountFromFirebase, account.address, account.pushNotificationsEnabled), + ), + ) + return + } + case EditAccountAction.Rename: + yield* call(renameAccountInFirebase, payload.address, payload.newName) + break + case EditAccountAction.TogglePushNotification: + yield* call(toggleFirebaseNotificationSettings, payload) + break + case EditAccountAction.ToggleTestnetSettings: + yield* call(maybeUpdateFirebaseMetadata, payload.address, { testnetsEnabled: payload.enabled }) + break + case EditAccountAction.UpdateLanguage: + yield* call(maybeUpdateFirebaseMetadata, payload.address, { locale: payload.locale }) + break + default: + break + } +} + +function* addAccountToFirebase(account: Account) { + const { name, type, address } = account + const testnetsEnabled = false + const currentLanguage = yield* select(selectCurrentLanguage) + const currentLocale = getLocale(currentLanguage) + + try { + yield* call(mapFirebaseUidToAddresses, [address]) + yield* call(updateFirebaseMetadata, address, { + type, + name, + testnetsEnabled, + locale: currentLocale, + }) + } catch (error) { + logger.error(error, { tags: { file: 'firebaseDataSaga', function: 'addAccountToFirebase' } }) + } +} + +function* removeAccountFromFirebase(address: Address, notificationsEnabled: boolean | undefined) { + try { + if (!notificationsEnabled) { + return + } + yield* call(deleteFirebaseMetadata, address) + yield* call(disassociateFirebaseUidFromAddresses, [address]) + } catch (error) { + logger.error(error, { + tags: { file: 'firebaseDataSaga', function: 'removeAccountFromFirebase' }, + }) + } +} + +function* renameAccountInFirebase(address: Address | undefined, newName: string) { + if (!address) { + throw new Error('Address is required for renameAccountInFirebase') + } + + try { + yield* call(maybeUpdateFirebaseMetadata, address, { name: newName }) + } catch (error) { + logger.error(error, { tags: { file: 'firebaseDataSaga', function: 'renameAccountInFirebase' } }) + } +} + +function* toggleFirebaseNotificationSettings({ address, enabled }: TogglePushNotificationParams) { + if (!address) { + throw new Error('Address is required for toggleFirebaseNotificationSettings') + } + + try { + const accounts = yield* select(selectAccounts) + const account = accounts[address] + if (!account) { + throw new Error(`Account not found for address ${address}`) + } + + if (enabled) { + yield* call(addAccountToFirebase, account) + } else { + yield* call(removeAccountFromFirebase, address, true) + } + + yield* put( + editAccount({ + address, + updatedAccount: { + ...account, + pushNotificationsEnabled: enabled, + }, + }), + ) + } catch (error) { + logger.error(error, { + tags: { file: 'firebaseDataSaga', function: 'toggleFirebaseNotificationSettings' }, + }) + } +} + +async function mapFirebaseUidToAddresses(addresses: Address[]): Promise { + const firebaseApp = firebase.app() + const uid = getFirebaseUidOrError(firebaseApp) + const batch = firestore(firebaseApp).batch() + addresses.forEach((address: string) => { + const uidRef = getFirestoreUidRef(firebaseApp, address) + batch.set(uidRef, { [uid]: true }, { merge: true }) + }) + + await batch.commit() +} + +async function disassociateFirebaseUidFromAddresses(addresses: Address[]): Promise { + const firebaseApp = firebase.app() + const uid = getFirebaseUidOrError(firebaseApp) + const batch = firestore(firebaseApp).batch() + addresses.forEach((address: string) => { + const uidRef = getFirestoreUidRef(firebaseApp, address) + batch.update(uidRef, { [uid]: firebase.firestore.FieldValue.delete() }) + }) + + await batch.commit() +} + +function* maybeUpdateFirebaseMetadata(address: Address | undefined, metadata: AccountMetadata) { + if (!address) { + return + } + + const selectAccountNotificationSetting = yield* call(makeSelectAccountNotificationSetting) + const notificationsEnabled = yield* select(selectAccountNotificationSetting, address) + + if (!notificationsEnabled) { + return + } + + // syncs firebase mapping with the local state to avoid errors during metadata changes + yield* call(syncNotificationsWithFirebase) + + yield* call(updateFirebaseMetadata, address, metadata) +} + +async function updateFirebaseMetadata(address: Address, metadata: AccountMetadata): Promise { + try { + const firebaseApp = firebase.app() + const pushId = await getOneSignalUserIdOrError() + const metadataRef = getFirestoreMetadataRef({ firebaseApp, address, pushId }) + + // Firestore does not support updating properties with an `undefined` value so must strip them out + const metadataWithDefinedPropsOnly = getKeys(metadata).reduce((obj: Record, prop) => { + const value = metadata[prop] + if (value !== undefined) { + obj[prop] = value + } + return obj + }, {}) + + await metadataRef.set(metadataWithDefinedPropsOnly, { merge: true }) + } catch (error) { + logger.error(error, { tags: { file: 'firebaseDataSaga', function: 'updateFirebaseMetadata' } }) + } +} + +async function deleteFirebaseMetadata(address: Address): Promise { + const firebaseApp = firebase.app() + const pushId = await getOneSignalUserIdOrError() + const metadataRef = getFirestoreMetadataRef({ firebaseApp, address, pushId }) + await metadataRef.delete() +} diff --git a/apps/mobile/src/features/firebase/utils.ts b/apps/mobile/src/features/firebase/utils.ts new file mode 100644 index 00000000..8a743f0f --- /dev/null +++ b/apps/mobile/src/features/firebase/utils.ts @@ -0,0 +1,53 @@ +import '@react-native-firebase/auth' +import type { ReactNativeFirebase } from '@react-native-firebase/app' +import firestore, { FirebaseFirestoreTypes } from '@react-native-firebase/firestore' +import { isBetaEnv, isDevEnv } from 'utilities/src/environment/env' + +const ADDRESS_DATA_COLLECTION = 'address_data' +const DEV_ADDRESS_DATA_COLLECTION = 'dev_address_data' +const BETA_ADDRESS_DATA_COLLECTION = 'beta_address_data' + +export const getFirebaseUidOrError = (firebaseApp: ReactNativeFirebase.FirebaseApp): string => { + const uid = firebaseApp.auth().currentUser?.uid + if (!uid) { + throw new Error('User must be signed in to Firebase before accessing Firestore') + } + return uid +} + +export const getFirestoreUidRef = ( + firebaseApp: ReactNativeFirebase.FirebaseApp, + address: Address, +): FirebaseFirestoreTypes.DocumentReference => + firestore(firebaseApp) + .collection(getAddressDataCollectionFromBundleId()) + .doc('address_uid_mapping') + .collection(address.toLowerCase()) + .doc('firebase') + +export const getFirestoreMetadataRef = ({ + firebaseApp, + address, + pushId, +}: { + firebaseApp: ReactNativeFirebase.FirebaseApp + address: Address + pushId: string +}): FirebaseFirestoreTypes.DocumentReference => + firestore(firebaseApp) + .collection(getAddressDataCollectionFromBundleId()) + .doc('metadata') + .collection(address.toLowerCase()) + .doc('onesignal_uids') + .collection(pushId) + .doc('data') + +function getAddressDataCollectionFromBundleId(): string { + if (isDevEnv()) { + return DEV_ADDRESS_DATA_COLLECTION + } + if (isBetaEnv()) { + return BETA_ADDRESS_DATA_COLLECTION + } + return ADDRESS_DATA_COLLECTION +} diff --git a/apps/mobile/src/features/import/GenericImportForm.test.tsx b/apps/mobile/src/features/import/GenericImportForm.test.tsx new file mode 100644 index 00000000..cc168232 --- /dev/null +++ b/apps/mobile/src/features/import/GenericImportForm.test.tsx @@ -0,0 +1,54 @@ +import React from 'react' +import { GenericImportForm } from 'src/features/import/GenericImportForm' +import { render, screen } from 'src/test/test-utils' +import { noOpFunction } from 'utilities/src/test/utils' +import { TamaguiProvider } from 'wallet/src/providers/tamagui-provider' + +describe(GenericImportForm, () => { + it('renders a placeholder when there is no value', async () => { + const tree = render( + + + , + ) + + expect(await screen.findByText('seed phrase')).toBeDefined() + expect(tree.toJSON()).toMatchSnapshot() + }) + + it('renders a value', async () => { + render( + + + , + ) + + expect(await screen.queryByText('seed phrase')).toBeNull() + expect(await screen.findByDisplayValue('hello')).toBeDefined() + }) + + it('renders an error message', async () => { + render( + + + , + ) + + expect(await screen.findByText('there is an error')).toBeDefined() + }) +}) diff --git a/apps/mobile/src/features/import/GenericImportForm.tsx b/apps/mobile/src/features/import/GenericImportForm.tsx new file mode 100644 index 00000000..e2d9d65c --- /dev/null +++ b/apps/mobile/src/features/import/GenericImportForm.tsx @@ -0,0 +1,195 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react' +// oxlint-disable-next-line no-restricted-imports -- Keyboard addListener is allowed for this use case +import { Keyboard, TextInput as NativeTextInput } from 'react-native' +import InputWithSuffix from 'src/features/import/InputWithSuffix' +import { ColorTokens, Flex, Text, useMedia } from 'ui/src' +import { fonts } from 'ui/src/theme' +import PasteButton from 'uniswap/src/components/buttons/PasteButton' +import { SectionName } from 'uniswap/src/features/telemetry/constants' +import Trace from 'uniswap/src/features/telemetry/Trace' + +interface Props { + value: string | undefined + errorMessage: string | undefined + onChange: (text: string | undefined) => void + placeholderLabel: string + onSubmit?: () => void + inputSuffix?: string //text to auto to end of input string + liveCheck?: boolean + autoCorrect?: boolean + inputAlignment?: 'center' | 'flex-start' + onBlur?: () => void + onFocus?: () => void + beforePasteButtonPress?: () => void + afterPasteButtonPress?: () => void + blurOnSubmit?: boolean + textAlign?: 'left' | 'right' | 'center' + shouldUseMinHeight?: boolean +} + +export function GenericImportForm({ + value, + onChange, + errorMessage, + placeholderLabel, + onSubmit, + inputSuffix, + liveCheck, + autoCorrect, + onBlur, + onFocus, + beforePasteButtonPress, + afterPasteButtonPress, + blurOnSubmit, + textAlign, + inputAlignment = 'center', + shouldUseMinHeight = true, +}: Props): JSX.Element { + const [focused, setFocused] = useState(false) + const textInputRef = useRef(null) + const isKeyboardVisibleRef = useRef(false) + const media = useMedia() + + const INPUT_FONT_SIZE = media.short ? fonts.subheading2.fontSize : fonts.subheading2.fontSize + const INPUT_MAX_FONT_SIZE_MULTIPLIER = fonts.subheading2.maxFontSizeMultiplier + + const handleBlur = (): void => { + setFocused(false) + onBlur?.() + } + + const handleFocus = (): void => { + setFocused(true) + onFocus?.() + // Need this to allow for focus on click on container. + textInputRef.current?.focus() + } + + const handleSubmit = (): void => { + onSubmit && onSubmit() + } + + useEffect(() => { + const keyboardListeners = [ + Keyboard.addListener('keyboardDidShow', (): void => { + isKeyboardVisibleRef.current = true + }), + Keyboard.addListener('keyboardDidHide', (): void => { + if (!isKeyboardVisibleRef.current) { + return + } + isKeyboardVisibleRef.current = false + textInputRef.current?.blur() + }), + ] + + return () => { + keyboardListeners.forEach((listener) => listener.remove()) + } + }, []) + + const INPUT_MIN_HEIGHT = 120 + const INPUT_MIN_HEIGHT_SHORT = 90 + const LINE_HEIGHT = INPUT_FONT_SIZE * 1.2 + + const showError = errorMessage && value && (liveCheck || !focused) + + const borderColor: ColorTokens = useMemo(() => { + if (value && (liveCheck || !focused)) { + return errorMessage ? '$statusCritical' : '$statusSuccess' + } + return '$surface3' + }, [value, liveCheck, focused, errorMessage]) + + return ( + + { + // Disable touch events when keyboard is visible (it prevents dismissing the keyboard + // when this component is pressed while the keyboard is visible) + return focused + }} + onTouchEnd={handleFocus} + > + + {/* TODO: [MOB-225] make Box press re-focus TextInput. Fine for now since TexInput has autoFocus */} + + {!value && placeholderLabel && ( + + + {placeholderLabel} + + + )} + {!value && !shouldUseMinHeight && ( + + + + )} + {!value && shouldUseMinHeight && ( + + + + + + )} + + + {showError && ( + + + {errorMessage} + + + )} + + + + ) +} diff --git a/apps/mobile/src/features/import/InputWIthSuffixProps.ts b/apps/mobile/src/features/import/InputWIthSuffixProps.ts new file mode 100644 index 00000000..bf24dd3d --- /dev/null +++ b/apps/mobile/src/features/import/InputWIthSuffixProps.ts @@ -0,0 +1,22 @@ +import { TextInput as NativeTextInput } from 'react-native' +import { ColorTokens } from 'ui/src' + +export interface InputWithSuffixProps { + alwaysShowInputSuffix?: boolean + autoCorrect: boolean + blurOnSubmit: boolean + inputAlignment: 'center' | 'flex-start' + value?: string + inputFontSize: number + inputMaxFontSizeMultiplier: number + inputSuffix?: string + inputSuffixColor?: ColorTokens + multiline?: boolean + textAlign?: 'left' | 'right' | 'center' + lineHeight?: number + textInputRef: React.RefObject + onBlur?: () => void + onFocus?: () => void + onChangeText?: (text: string) => void + onSubmitEditing?: () => void +} diff --git a/apps/mobile/src/features/import/InputWithSuffix.android.tsx b/apps/mobile/src/features/import/InputWithSuffix.android.tsx new file mode 100644 index 00000000..fc761dbd --- /dev/null +++ b/apps/mobile/src/features/import/InputWithSuffix.android.tsx @@ -0,0 +1,109 @@ +import { useCallback, useRef, useState } from 'react' +import { LayoutChangeEvent } from 'react-native' +import { InputWithSuffixProps } from 'src/features/import/InputWIthSuffixProps' +import { Flex } from 'ui/src' +import { TextInput } from 'uniswap/src/components/input/TextInput' +import { TestID } from 'uniswap/src/test/fixtures/testIDs' + +const EPS = 1 + +export default function InputWithSuffix({ + alwaysShowInputSuffix = false, + value, + inputSuffix, + inputSuffixColor, + inputAlignment: inputAlignmentProp, + inputFontSize, + inputMaxFontSizeMultiplier, + multiline = true, + textAlign, + textInputRef, + lineHeight, + ...inputProps +}: InputWithSuffixProps): JSX.Element { + const textInputWidth = useRef(0) + const [shouldWrapLine, setShouldWrapLine] = useState(false) + + const measureMaxWidth = useCallback((e: LayoutChangeEvent) => { + textInputWidth.current = e.nativeEvent.layout.width + }, []) + + const measureInputWidth = useCallback( + (e: LayoutChangeEvent) => { + if (multiline) { + const contentWidth = e.nativeEvent.layout.width + const maxContentWidth = textInputWidth.current + // Check if the input content doesn't fit in a single line + // (add EPS to avoid rounding errors) + setShouldWrapLine(contentWidth + EPS >= maxContentWidth) + } + }, + [multiline], + ) + + const isInputEmpty = !value?.length + // On iOS use just the multiline prop to determine if the input should wrap + // On Android, wrap the input only if it's content doesn't fit in a single line + // and the input is multiline + const isMultiline = multiline && shouldWrapLine + + const fallbackTextInputAlignment = inputAlignmentProp === 'flex-start' ? 'left' : 'center' + const textInputAlignment = textAlign ?? fallbackTextInputAlignment + + const suffix = + inputSuffix && (alwaysShowInputSuffix || (value && !value.includes(inputSuffix))) ? ( + + ) : null + + return ( + + {/* + Helper Flex to measure the max width of the input and switch to multiline if needed + (multiline input behavior on Android is weird and the input flickers when the width + of the TextInput component changes. As a workaround, we measure the max width of the input + and switch to multiline if the content doesn't fit in a single line) + */} + + + {suffix} + + + + {suffix} + + ) +} diff --git a/apps/mobile/src/features/import/InputWithSuffix.ios.tsx b/apps/mobile/src/features/import/InputWithSuffix.ios.tsx new file mode 100644 index 00000000..5cf771db --- /dev/null +++ b/apps/mobile/src/features/import/InputWithSuffix.ios.tsx @@ -0,0 +1,65 @@ +import { InputWithSuffixProps } from 'src/features/import/InputWIthSuffixProps' +import { Flex } from 'ui/src' +import { TextInput } from 'uniswap/src/components/input/TextInput' +import { TestID } from 'uniswap/src/test/fixtures/testIDs' + +export default function InputWithSuffix({ + alwaysShowInputSuffix = false, + value, + inputSuffix, + inputSuffixColor, + inputAlignment: inputAlignmentProp, + inputFontSize, + inputMaxFontSizeMultiplier, + multiline = true, + textAlign, + textInputRef, + lineHeight, + ...inputProps +}: InputWithSuffixProps): JSX.Element { + const isInputEmpty = !value?.length + + const fallbackTextInputAlignment = inputAlignmentProp === 'flex-start' ? 'left' : 'center' + const textInputAlignment = textAlign ?? fallbackTextInputAlignment + + return ( + + + {inputSuffix && (alwaysShowInputSuffix || (value && !value.includes(inputSuffix))) ? ( + + ) : null} + + ) +} diff --git a/apps/mobile/src/features/import/InputWithSuffix.tsx b/apps/mobile/src/features/import/InputWithSuffix.tsx new file mode 100644 index 00000000..bbb2e43f --- /dev/null +++ b/apps/mobile/src/features/import/InputWithSuffix.tsx @@ -0,0 +1,6 @@ +import { InputWithSuffixProps } from 'src/features/import/InputWIthSuffixProps' +import { PlatformSplitStubError } from 'utilities/src/errors' + +export default function InputWithSuffix(_props: InputWithSuffixProps): JSX.Element { + throw new PlatformSplitStubError('InputWithSuffix') +} diff --git a/apps/mobile/src/features/import/__snapshots__/GenericImportForm.test.tsx.snap b/apps/mobile/src/features/import/__snapshots__/GenericImportForm.test.tsx.snap new file mode 100644 index 00000000..285b81bd --- /dev/null +++ b/apps/mobile/src/features/import/__snapshots__/GenericImportForm.test.tsx.snap @@ -0,0 +1,329 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`GenericImportForm renders a placeholder when there is no value 1`] = ` + + + + + + + + seed phrase + + + + + + + + Paste + + + + + + + + + + + + + +`; diff --git a/apps/mobile/src/features/lockScreen/LockScreenModal.tsx b/apps/mobile/src/features/lockScreen/LockScreenModal.tsx new file mode 100644 index 00000000..5b2c3f57 --- /dev/null +++ b/apps/mobile/src/features/lockScreen/LockScreenModal.tsx @@ -0,0 +1,137 @@ +import { BlurView } from 'expo-blur' +import React, { memo } from 'react' +import { useTranslation } from 'react-i18next' +import { Image } from 'react-native' +import Animated, { FadeIn, FadeOut } from 'react-native-reanimated' +import { useBiometricsIcon } from 'src/components/icons/useBiometricsIcon' +import { SPLASH_SCREEN_IMAGE_SIZE } from 'src/features/appLoading/SplashScreen' +import { useBiometricsAlert } from 'src/features/biometrics/useBiometricsAlert' +import { useDeviceSupportsBiometricAuth } from 'src/features/biometrics/useDeviceSupportsBiometricAuth' +import { useOsBiometricAuthEnabled } from 'src/features/biometrics/useOsBiometricAuthEnabled' +import { useBiometricName, useBiometricPrompt } from 'src/features/biometricsSettings/hooks' +import { useLockScreenState } from 'src/features/lockScreen/hooks/useLockScreenState' +import { Button, Flex, flexStyles, TouchableArea, useIsDarkMode } from 'ui/src' +import { UNISWAP_MONO_LOGO_LARGE } from 'ui/src/assets' +import { AnimatedFlex } from 'ui/src/components/layout/AnimatedFlex' +import { useDeviceDimensions } from 'ui/src/hooks/useDeviceDimensions' +import { spacing, zIndexes } from 'ui/src/theme' +import { useAppInsets } from 'uniswap/src/hooks/useAppInsets' +import { isAndroid } from 'utilities/src/platform' +import { useEvent } from 'utilities/src/react/hooks' + +const fadeIn = FadeIn.duration(250) +const fadeOut = FadeOut.duration(250) + +const useBiometricAuth = (): (() => Promise) => { + const { t } = useTranslation() + const { trigger } = useBiometricPrompt() + const { touchId } = useDeviceSupportsBiometricAuth() + const biometricsMethod = useBiometricName(touchId) + const { showBiometricsAlert } = useBiometricsAlert({ t }) + const isBiometricsEnabled = useOsBiometricAuthEnabled() + + const onPress = useEvent(async (): Promise => { + await trigger({ + failureCallback: () => { + if (!isBiometricsEnabled) { + showBiometricsAlert(biometricsMethod) + } + }, + }) + }) + + return onPress +} + +export const LockScreenModal = memo(function LockScreenModal(): JSX.Element | null { + const { isLockScreenVisible } = useLockScreenState() + const onPress = useBiometricAuth() + + if (!isLockScreenVisible) { + return null + } + + // We do not add explicit error boundary here as we can not hide or replace + // the lock screen on error, hence we fallback to the global error boundary + return ( + + + + + + ) +}) + +const BlurredLockScreen = memo(function BlurredLockScreen(): JSX.Element { + const { manualRetryRequired } = useLockScreenState() + const isDarkMode = useIsDarkMode() + const bottomInset = useBottomInset() + const dimensions = useDeviceDimensions() + + return ( + + + {manualRetryRequired && ( + + + + )} + + ) +}) + +const UnlockButton = memo(function UnlockButton(): JSX.Element { + const { t } = useTranslation() + const renderBiometricsIcon = useBiometricsIcon() + const onPress = useBiometricAuth() + + return ( + + + + ) +}) + +const FullScreenFader = memo(function FullScreenFader({ children }: { children: React.ReactNode }): JSX.Element { + const dimensions = useDeviceDimensions() + return ( + + {children} + + ) +}) + +const useBottomInset = (): number => { + const insets = useAppInsets() + const additional = isAndroid ? spacing.spacing24 : 0 + return insets.bottom + additional +} diff --git a/apps/mobile/src/features/lockScreen/hooks/useLockScreenOnBlur.ts b/apps/mobile/src/features/lockScreen/hooks/useLockScreenOnBlur.ts new file mode 100644 index 00000000..f1063a02 --- /dev/null +++ b/apps/mobile/src/features/lockScreen/hooks/useLockScreenOnBlur.ts @@ -0,0 +1,20 @@ +import { useEffect } from 'react' +import { useDispatch } from 'react-redux' +import { setLockScreenOnBlur } from 'src/features/lockScreen/lockScreenSlice' + +/** + * Hook that sets the lock screen on blur state to true when the screen is focused and false when it is not. + * Use: to protect screen content such as seed phrase input from being accessed when the app is the app switcher, backgrounded, etc. + * @param isDisabled - Whether the lock screen on blur state should be disabled. + */ + +export function useLockScreenOnBlur(isDisabled?: boolean): void { + const dispatch = useDispatch() + + useEffect(() => { + dispatch(setLockScreenOnBlur(isDisabled ? false : true)) + return () => { + dispatch(setLockScreenOnBlur(false)) + } + }, [isDisabled, dispatch]) +} diff --git a/apps/mobile/src/features/lockScreen/hooks/useLockScreenState.ts b/apps/mobile/src/features/lockScreen/hooks/useLockScreenState.ts new file mode 100644 index 00000000..fb5f65dd --- /dev/null +++ b/apps/mobile/src/features/lockScreen/hooks/useLockScreenState.ts @@ -0,0 +1,16 @@ +import { useSelector } from 'react-redux' +import { selectIsLockScreenVisible, selectManualRetryRequired } from 'src/features/lockScreen/lockScreenSlice' + +interface LockScreenContextValue { + isLockScreenVisible: boolean + manualRetryRequired: boolean +} + +export function useLockScreenState(): LockScreenContextValue { + const isLockScreenVisible = useSelector(selectIsLockScreenVisible) + const manualRetryRequired = useSelector(selectManualRetryRequired) + return { + isLockScreenVisible, + manualRetryRequired, + } +} diff --git a/apps/mobile/src/features/lockScreen/hooks/usePreventLock.ts b/apps/mobile/src/features/lockScreen/hooks/usePreventLock.ts new file mode 100644 index 00000000..ee4b70a2 --- /dev/null +++ b/apps/mobile/src/features/lockScreen/hooks/usePreventLock.ts @@ -0,0 +1,31 @@ +import { useDispatch } from 'react-redux' +import { setPreventLock } from 'src/features/lockScreen/lockScreenSlice' +import { waitFrame } from 'utilities/src/react/delayUtils' +import { useEvent } from 'utilities/src/react/hooks' + +/** + * Custom hook to prevent the app from entering a background state when calling a function. + * + * There are times when we call a function and sometimes the app will enter a background state, or on + * android the app will enter an "inactive" state. This `preventLock` function will temporarily + * prevent the app from locking if enabled (biometric auth app access) + */ +export function usePreventLock(): { + preventLock: (operation: () => Promise) => Promise +} { + const dispatch = useDispatch() + + const preventLock = useEvent(async (operation: () => Promise): Promise => { + dispatch(setPreventLock(true)) + try { + await waitFrame() + return await operation() + } finally { + await waitFrame() + // always reset preventLock to false + dispatch(setPreventLock(false)) + } + }) + + return { preventLock } +} diff --git a/apps/mobile/src/features/lockScreen/lockScreenSaga.ts b/apps/mobile/src/features/lockScreen/lockScreenSaga.ts new file mode 100644 index 00000000..e3fc5d09 --- /dev/null +++ b/apps/mobile/src/features/lockScreen/lockScreenSaga.ts @@ -0,0 +1,147 @@ +import { PayloadAction } from '@reduxjs/toolkit' +import { AppStateStatus } from 'react-native' +import { SagaIterator } from 'redux-saga' +import { selectIsFromBackground, transitionAppState } from 'src/features/appState/appStateSlice' +import { BiometricAuthenticationStatus } from 'src/features/biometrics/biometrics-utils' +import { + selectAuthenticationStatusIsAuthenticated, + setAuthenticationStatus, + triggerAuthentication, +} from 'src/features/biometrics/biometricsSlice' +import { selectRequiredForAppAccess } from 'src/features/biometricsSettings/slice' +import { + LockScreenVisibility, + selectIsLockScreenVisible, + selectLockScreenOnBlur, + selectPreventLock, + setLockScreenVisibility, + setManualRetryRequired, +} from 'src/features/lockScreen/lockScreenSlice' +import { onSplashScreenHidden } from 'src/features/splashScreen/splashScreenSlice' +import { call, put, select, takeEvery, takeLatest } from 'typed-redux-saga' + +//------------------------------ +// LockScreen saga +//------------------------------ + +export function* lockScreenSaga(): SagaIterator { + // setup initial lock screen state on app load if required + yield* call(setupInitialLockScreenState) + // handle when splash screen is hidden + yield* takeLatest(onSplashScreenHidden.type, onSplashScreenHide) + // handle when app state changes + yield* takeLatest(transitionAppState.type, onAppStateTransition) + // handle authentication status change in dedicated saga + yield* takeEvery(setAuthenticationStatus.type, onAuthenticationStatusChange) +} + +function* setupInitialLockScreenState(): SagaIterator { + if (yield* call(shouldPresentLockScreen)) { + yield* put(setLockScreenVisibility(LockScreenVisibility.Visible)) + } +} + +function* onSplashScreenHide(): SagaIterator { + const isRequiredForAppAccess = yield* select(selectRequiredForAppAccess) + // if biometrics are required for app access, trigger authentication + if (isRequiredForAppAccess) { + yield* call(handleTriggerAuthentication) + } +} + +function* onAuthenticationStatusChange(action: PayloadAction): SagaIterator { + const isVisible = yield* select(selectIsLockScreenVisible) + if (isVisible) { + // on success, dismiss the lock screen + if (action.payload === BiometricAuthenticationStatus.Authenticated) { + yield* put(setLockScreenVisibility(LockScreenVisibility.Hidden)) + yield* put(setManualRetryRequired(false)) + } + // on failure, show the manual retry button + // authenticated and authenticating are not failures, + // everything else is a failure + if ( + action.payload !== BiometricAuthenticationStatus.Authenticated && + action.payload !== BiometricAuthenticationStatus.Authenticating + ) { + yield* put(setManualRetryRequired(true)) + } + } +} + +function* onAppStateTransition(action: PayloadAction): SagaIterator { + switch (action.payload) { + case 'inactive': + yield* call(toInactiveTransition) + break + case 'background': + yield* call(toBackgroundTransition) + break + case 'active': + yield* call(toActiveTransition) + break + } +} + +// handle -> inactive +function* toInactiveTransition(): SagaIterator { + if (yield* call(shouldPresentLockScreen)) { + yield* put(setLockScreenVisibility(LockScreenVisibility.Visible)) + } +} + +// handle -> background +function* toBackgroundTransition(): SagaIterator { + if (yield* call(shouldPresentLockScreen)) { + yield* put(setLockScreenVisibility(LockScreenVisibility.Visible)) + // invalidate authentication on backgrounding if + // biometrics are required for app access + yield* call(handleInvalidateAuthentication) + } +} + +// handle -> active +function* toActiveTransition(): SagaIterator { + if (yield* call(shouldDismissLockScreen)) { + yield* put(setLockScreenVisibility(LockScreenVisibility.Hidden)) + } else { + // only trigger authentication if the app was in the background + if (yield* select(selectIsFromBackground)) { + yield* call(handleTriggerAuthentication) + } + // the lock screen will be dismissed when the authentication status changes to authenticated + } +} + +function* handleInvalidateAuthentication(): SagaIterator { + const preventLock = yield* select(selectPreventLock) + if (preventLock) { + return + } + if (yield* select(selectRequiredForAppAccess)) { + yield* put(setAuthenticationStatus(BiometricAuthenticationStatus.Invalid)) + } + yield* put(setManualRetryRequired(false)) +} + +function* handleTriggerAuthentication(): SagaIterator { + yield* put(triggerAuthentication({})) +} + +function* shouldDismissLockScreen(): SagaIterator { + if (!(yield* select(selectRequiredForAppAccess))) { + // always dismiss lock screen if biometrics are not required for app access + return true + } + return yield* select(selectAuthenticationStatusIsAuthenticated) +} + +function* shouldPresentLockScreen(): SagaIterator { + const preventLock = yield* select(selectPreventLock) + if (preventLock) { + return false + } + const requiredForAppAccess = yield* select(selectRequiredForAppAccess) + const lockScreenOnBlur = yield* select(selectLockScreenOnBlur) + return requiredForAppAccess || lockScreenOnBlur +} diff --git a/apps/mobile/src/features/lockScreen/lockScreenSlice.ts b/apps/mobile/src/features/lockScreen/lockScreenSlice.ts new file mode 100644 index 00000000..a69a5ddc --- /dev/null +++ b/apps/mobile/src/features/lockScreen/lockScreenSlice.ts @@ -0,0 +1,66 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit' + +//------------------------------------------------------------------------------------------------ +// LockScreen +//------------------------------------------------------------------------------------------------ + +export enum LockScreenVisibility { + Init = 'init', + Visible = 'visible', + Hidden = 'hidden', +} + +// oxlint-disable-next-line import/no-unused-modules +export interface LockScreenState { + visibility: LockScreenVisibility + onBlur: boolean + preventLock: boolean + manualRetryRequired: boolean +} + +const initialState: LockScreenState = { + visibility: LockScreenVisibility.Init, + onBlur: false, + preventLock: false, + manualRetryRequired: false, +} + +const lockScreenSlice = createSlice({ + name: 'lockScreen', + initialState, + reducers: { + setLockScreenVisibility: ( + state, + action: PayloadAction, + ) => { + state.visibility = action.payload + }, + setLockScreenOnBlur: (state, action: PayloadAction) => { + state.onBlur = action.payload + }, + setPreventLock: (state, action: PayloadAction) => { + state.preventLock = action.payload + }, + setManualRetryRequired: (state, action: PayloadAction) => { + state.manualRetryRequired = action.payload + }, + }, +}) + +export const { setLockScreenVisibility, setLockScreenOnBlur, setPreventLock, setManualRetryRequired } = + lockScreenSlice.actions +export const lockScreenReducer = lockScreenSlice.reducer + +//------------------------------ +// LockScreen selectors +//------------------------------ + +export const selectIsLockScreenVisible = (state: { lockScreen: LockScreenState }): boolean => + state.lockScreen.visibility === LockScreenVisibility.Visible + +export const selectLockScreenOnBlur = (state: { lockScreen: LockScreenState }): boolean => state.lockScreen.onBlur + +export const selectPreventLock = (state: { lockScreen: LockScreenState }): boolean => state.lockScreen.preventLock + +export const selectManualRetryRequired = (state: { lockScreen: LockScreenState }): boolean => + state.lockScreen.manualRetryRequired diff --git a/apps/mobile/src/features/modals/ModalsState.ts b/apps/mobile/src/features/modals/ModalsState.ts new file mode 100644 index 00000000..8c0bb95e --- /dev/null +++ b/apps/mobile/src/features/modals/ModalsState.ts @@ -0,0 +1,18 @@ +import { FiatOnRampModalState } from 'src/screens/FiatOnRampModalState' +import { ScannerModalState } from 'uniswap/src/components/ReceiveQRCode/constants' +import { ModalName } from 'uniswap/src/features/telemetry/constants' +import { TransactionScreen } from 'uniswap/src/features/transactions/components/TransactionModal/TransactionModalContext' +import { TransactionState } from 'uniswap/src/features/transactions/types/transactionState' + +export interface AppModalState { + isOpen: boolean + initialState?: T +} + +export interface ModalsState { + [ModalName.Experiments]: AppModalState + [ModalName.FiatOnRampAggregator]: AppModalState + [ModalName.QueuedOrderModal]: AppModalState + [ModalName.Send]: AppModalState + [ModalName.WalletConnectScan]: AppModalState +} diff --git a/apps/mobile/src/features/modals/hooks/useOpenReceiveModal.ts b/apps/mobile/src/features/modals/hooks/useOpenReceiveModal.ts new file mode 100644 index 00000000..5e547905 --- /dev/null +++ b/apps/mobile/src/features/modals/hooks/useOpenReceiveModal.ts @@ -0,0 +1,18 @@ +import { useDispatch } from 'react-redux' +import { useAppStackNavigation } from 'src/app/navigation/types' +import { openModal } from 'src/features/modals/modalSlice' +import { ScannerModalState } from 'uniswap/src/components/ReceiveQRCode/constants' +import { useCexTransferProviders } from 'uniswap/src/features/fiatOnRamp/useCexTransferProviders' +import { ModalName } from 'uniswap/src/features/telemetry/constants' + +export function useOpenReceiveModal(): () => void { + const dispatch = useDispatch() + const navigation = useAppStackNavigation() + const cexTransferProviders = useCexTransferProviders() + + return () => { + cexTransferProviders.length > 0 + ? navigation.navigate(ModalName.ReceiveCryptoModal, { serviceProviders: cexTransferProviders }) + : dispatch(openModal({ name: ModalName.WalletConnectScan, initialState: ScannerModalState.WalletQr })) + } +} diff --git a/apps/mobile/src/features/modals/modalSlice.test.ts b/apps/mobile/src/features/modals/modalSlice.test.ts new file mode 100644 index 00000000..8a37de86 --- /dev/null +++ b/apps/mobile/src/features/modals/modalSlice.test.ts @@ -0,0 +1,38 @@ +import { createStore, Store } from '@reduxjs/toolkit' +import { closeModal, initialModalsState, modalsReducer, openModal } from 'src/features/modals/modalSlice' +import { ModalsState } from 'src/features/modals/ModalsState' +import { ScannerModalState } from 'uniswap/src/components/ReceiveQRCode/constants' +import { ModalName } from 'uniswap/src/features/telemetry/constants' + +const initialState = { ...initialModalsState } +const modalName = ModalName.WalletConnectScan + +describe('modals reducer', () => { + let store: Store + + beforeEach(() => { + store = createStore(modalsReducer, initialState) + }) + + it('opens modals and sets initial state', () => { + expect(store.getState()[modalName].isOpen).toEqual(false) + + store.dispatch(openModal({ name: modalName, initialState: ScannerModalState.ScanQr })) + expect(store.getState()[modalName].isOpen).toEqual(true) + expect(store.getState()[modalName].initialState).toEqual(ScannerModalState.ScanQr) + }) + + it('closes modals', () => { + // initially closed + expect(store.getState()[modalName].isOpen).toEqual(false) + + // open it + store.dispatch(openModal({ name: modalName, initialState: ScannerModalState.ScanQr })) + expect(store.getState()[modalName].isOpen).toEqual(true) + + // now close it + store.dispatch(closeModal({ name: modalName })) + expect(store.getState()[modalName].isOpen).toEqual(false) + expect(store.getState()[modalName].initialState).toEqual(undefined) + }) +}) diff --git a/apps/mobile/src/features/modals/modalSlice.ts b/apps/mobile/src/features/modals/modalSlice.ts new file mode 100644 index 00000000..82ab8ab2 --- /dev/null +++ b/apps/mobile/src/features/modals/modalSlice.ts @@ -0,0 +1,90 @@ +import { createSlice, type PayloadAction } from '@reduxjs/toolkit' +import { type ModalsState } from 'src/features/modals/ModalsState' +import { type FiatOnRampModalState } from 'src/screens/FiatOnRampModalState' +import { ScannerModalState } from 'uniswap/src/components/ReceiveQRCode/constants' +import { ModalName } from 'uniswap/src/features/telemetry/constants' +import { type TransactionScreen } from 'uniswap/src/features/transactions/components/TransactionModal/TransactionModalContext' +import { type TransactionState } from 'uniswap/src/features/transactions/types/transactionState' +import { getKeys } from 'utilities/src/primitives/objects' + +/** + * *********** DEPRECATION NOTICE *********** + * + * This modal system is deprecated in favor of React Navigation. + * Please do not add any new modals to this redux slice. + * See apps/mobile/src/app/navigation/navigation.tsx + * + * *********** DEPRECATION NOTICE *********** + */ + +type FiatOnRampAggregatorModalParams = { + name: typeof ModalName.FiatOnRampAggregator + initialState?: FiatOnRampModalState +} + +type WalletConnectModalParams = { + name: typeof ModalName.WalletConnectScan + initialState: ScannerModalState +} + +type SendModalParams = { + name: typeof ModalName.Send + initialState?: TransactionState & { + sendScreen?: TransactionScreen + } +} + +type OpenModalParams = FiatOnRampAggregatorModalParams | SendModalParams | WalletConnectModalParams + +type CloseModalParams = { name: keyof ModalsState } + +const createInitialModalState = (overrides?: Partial): ModalsState => { + const defaultState = Object.values(ModalName).reduce((state, key) => { + return { + ...state, + [key]: { + isOpen: false, + initialState: undefined, + }, + } + }, {} as ModalsState) + + return { + ...defaultState, + ...overrides, + } +} + +export const initialModalsState: ModalsState = createInitialModalState({ + [ModalName.WalletConnectScan]: { + isOpen: false, + initialState: ScannerModalState.ScanQr, + }, +}) + +const slice = createSlice({ + name: 'modals', + initialState: initialModalsState, + reducers: { + openModal: (state, action: PayloadAction) => { + const { name, initialState } = action.payload + state[name].isOpen = true + state[name].initialState = initialState + }, + closeModal: (state, action: PayloadAction) => { + const { name } = action.payload + state[name].isOpen = false + state[name].initialState = undefined + }, + closeAllModals: (state) => { + getKeys(state).forEach((modalName) => { + state[modalName].isOpen = false + state[modalName].initialState = undefined + }) + }, + resetModals: () => initialModalsState, + }, +}) + +export const { openModal, closeModal, closeAllModals, resetModals } = slice.actions +export const { reducer: modalsReducer } = slice diff --git a/apps/mobile/src/features/modals/selectModalState.ts b/apps/mobile/src/features/modals/selectModalState.ts new file mode 100644 index 00000000..f1a80cef --- /dev/null +++ b/apps/mobile/src/features/modals/selectModalState.ts @@ -0,0 +1,6 @@ +import { MobileState } from 'src/app/mobileReducer' +import { ModalsState } from 'src/features/modals/ModalsState' + +export function selectModalState(name: T): (state: MobileState) => ModalsState[T] { + return (state) => state.modals[name] +} diff --git a/apps/mobile/src/features/modals/selectSomeModalOpen.ts b/apps/mobile/src/features/modals/selectSomeModalOpen.ts new file mode 100644 index 00000000..e41805dd --- /dev/null +++ b/apps/mobile/src/features/modals/selectSomeModalOpen.ts @@ -0,0 +1,5 @@ +import { MobileState } from 'src/app/mobileReducer' + +export function selectSomeModalOpen(state: MobileState): boolean { + return Object.values(state.modals).some((modalState) => modalState.isOpen) +} diff --git a/apps/mobile/src/features/notifications/NotificationToastWrapper.tsx b/apps/mobile/src/features/notifications/NotificationToastWrapper.tsx new file mode 100644 index 00000000..09cccf89 --- /dev/null +++ b/apps/mobile/src/features/notifications/NotificationToastWrapper.tsx @@ -0,0 +1,32 @@ +import React from 'react' +import { ScantasticCompleteNotification } from 'src/features/notifications/ScantasticCompleteNotification' +import { WCNotification } from 'src/features/notifications/WCNotification' +import { useSelectAddressNotifications } from 'uniswap/src/features/notifications/slice/hooks' +import { AppNotification, AppNotificationType } from 'uniswap/src/features/notifications/slice/types' +import { WalletNotificationToastRouter } from 'wallet/src/features/notifications/components/SharedNotificationToastRouter' +import { useActiveAccountAddress } from 'wallet/src/features/wallet/hooks' + +export function NotificationToastWrapper(): JSX.Element | null { + const activeAccountAddress = useActiveAccountAddress() + const notifications = useSelectAddressNotifications(activeAccountAddress) + const notification = notifications?.[0] + + if (!notification) { + return null + } + + return +} + +function NotificationToastRouter({ notification }: { notification: AppNotification }): JSX.Element | null { + // Insert Mobile-only notifications here. + // Shared wallet notifications should go in SharedNotificationToastRouter. + switch (notification.type) { + case AppNotificationType.WalletConnect: + return + case AppNotificationType.ScantasticComplete: + return + } + + return +} diff --git a/apps/mobile/src/features/notifications/Onesignal.ts b/apps/mobile/src/features/notifications/Onesignal.ts new file mode 100644 index 00000000..6d3741c9 --- /dev/null +++ b/apps/mobile/src/features/notifications/Onesignal.ts @@ -0,0 +1,98 @@ +import { Linking } from 'react-native' +import { OneSignal } from 'react-native-onesignal' +import { NotificationType } from 'src/features/notifications/constants' +import { startSilentPushListener } from 'src/features/notifications/SilentPushListener' +import { config } from 'uniswap/src/config' +import { GQL_QUERIES_TO_REFETCH_ON_TXN_UPDATE } from 'uniswap/src/features/portfolio/portfolioUpdates/constants' +import { getUniqueId } from 'utilities/src/device/uniqueId' +import { logger } from 'utilities/src/logger/logger' +import { isAndroid } from 'utilities/src/platform' +import { ONE_SECOND_MS } from 'utilities/src/time/time' +import { apolloClientRef } from 'wallet/src/data/apollo/usePersistedApolloClient' + +export const initOneSignal = (): void => { + // Uncomment for local debugging + // OneSignal.Debug.setLogLevel(LogLevel.Verbose) + + OneSignal.initialize(config.onesignalAppId) + + startSilentPushListener() + + OneSignal.Notifications.addEventListener('foregroundWillDisplay', (event) => { + const notification = event.getNotification() + const additionalData = notification.additionalData as { notification_type?: string } | undefined + const notificationType = additionalData?.notification_type + + let enabled = false + + if (isAndroid) { + if (notificationType === NotificationType.UnfundedWalletReminder) { + enabled = true + } + } + + if (!enabled) { + // Prevent default will avoid showing OS notifications while app is in foreground + event.preventDefault() + } + }) + + OneSignal.Notifications.addEventListener('click', (event) => { + logger.debug('Onesignal', 'setNotificationOpenedHandler', `Notification opened: ${event.notification}`) + + setTimeout( + () => + apolloClientRef.current?.refetchQueries({ + include: GQL_QUERIES_TO_REFETCH_ON_TXN_UPDATE, + }), + ONE_SECOND_MS, // Delay by 1s to give a buffer for data sources to synchronize + ) + + // This emits a url event when coldStart = false. Don't call openURI because that will + // send the user to Safari to open the universal link. When coldStart = true, OneSignal + // handles the url event and navigates correctly. + if (event.notification.launchURL) { + Linking.emit('url', { url: event.notification.launchURL }) + } + }) + + getUniqueId() + .then((deviceId) => { + if (deviceId) { + OneSignal.login(deviceId) + } + }) + .catch(() => + logger.error('Failed to get device ID for OneSignal', { + tags: { + file: 'Onesignal.ts', + function: 'initOneSignal', + }, + }), + ) +} + +export const promptPushPermission = async (): Promise => { + const response = await OneSignal.Notifications.requestPermission(true) + logger.debug('Onesignal', 'promptForPushNotificationsWithUserResponse', `Prompt response: ${response}`) + + // Explicitly opt in to push notifications if permission was granted + if (response) { + OneSignal.User.pushSubscription.optIn() + } + + return response +} + +export const getOneSignalUserIdOrError = async (): Promise => { + const onesignalUserId = await OneSignal.User.getOnesignalId() + if (!onesignalUserId) { + throw new Error('Onesignal user ID is not defined') + } + return onesignalUserId +} + +export const getOneSignalPushToken = async (): Promise => { + const onesignalPushToken = await OneSignal.User.pushSubscription.getTokenAsync() + return onesignalPushToken +} diff --git a/apps/mobile/src/features/notifications/ScantasticCompleteNotification.tsx b/apps/mobile/src/features/notifications/ScantasticCompleteNotification.tsx new file mode 100644 index 00000000..f19c3ff9 --- /dev/null +++ b/apps/mobile/src/features/notifications/ScantasticCompleteNotification.tsx @@ -0,0 +1,40 @@ +import React from 'react' +import { useTranslation } from 'react-i18next' +import { Flex } from 'ui/src' +import { Check, Laptop } from 'ui/src/components/icons' +import { NotificationToast } from 'uniswap/src/components/notifications/NotificationToast' +import { ScantasticCompleteNotification as ScantasticCompleteNotificationType } from 'uniswap/src/features/notifications/slice/types' + +export function ScantasticCompleteNotification({ + notification: { hideDelay }, +}: { + notification: ScantasticCompleteNotificationType +}): JSX.Element { + const { t } = useTranslation() + return ( + + + + + + + + + } + subtitle={t('notifications.scantastic.subtitle')} + title={t('notifications.scantastic.title')} + /> + ) +} diff --git a/apps/mobile/src/features/notifications/SilentPushListener.ts b/apps/mobile/src/features/notifications/SilentPushListener.ts new file mode 100644 index 00000000..688cd683 --- /dev/null +++ b/apps/mobile/src/features/notifications/SilentPushListener.ts @@ -0,0 +1,70 @@ +import { NativeEventEmitter, NativeModules, Platform } from 'react-native' +import { WalletEventName } from 'uniswap/src/features/telemetry/constants/wallet' +import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { logger } from 'utilities/src/logger/logger' +import { isMobileApp } from 'utilities/src/platform' + +const EVENT_NAME = 'SilentPushReceived' + +interface SilentPushEventEmitterInterface { + addListener: (eventName: string) => void + removeListeners: (count: number) => void +} + +declare module 'react-native' { + interface NativeModulesStatic { + SilentPushEventEmitter: SilentPushEventEmitterInterface + } +} + +const { SilentPushEventEmitter } = NativeModules + +const eventEmitter = isMobileApp ? new NativeEventEmitter(SilentPushEventEmitter) : undefined + +let subscription: { remove: () => void } | undefined + +const handleSilentPush = (payload: Record): void => { + logger.debug('SilentPush', 'handleSilentPush', 'Silent push received', payload) + + // Onesignal Silent Push payload stores the template id in the 'custom' object with key 'i' + if ('custom' in payload && payload['custom']) { + try { + const customPayload = typeof payload['custom'] === 'string' ? JSON.parse(payload['custom']) : payload['custom'] + + if (!('i' in customPayload) || typeof customPayload.i !== 'string') { + return + } + + sendAnalyticsEvent(WalletEventName.SilentPushReceived, { + template_id: customPayload.i, + }) + logger.debug('SilentPush', 'handleSilentPush', 'Silent push event sent', { + template_id: customPayload.i, + }) + } catch (error) { + logger.error(error, { + tags: { + file: 'SilentPushListener.ts', + function: 'handleSilentPush', + }, + }) + } + } +} + +export const startSilentPushListener = (): void => { + if (subscription) { + return + } + + if (!eventEmitter) { + logger.warn('SilentPush', 'startSilentPushListener', 'Native event emitter unavailable', { + platform: Platform.OS, + moduleLoaded: Boolean(SilentPushEventEmitter), + }) + return + } + + subscription = eventEmitter.addListener(EVENT_NAME, handleSilentPush) + logger.debug('SilentPush', 'startSilentPushListener', 'Listener registered') +} diff --git a/apps/mobile/src/features/notifications/WCNotification.tsx b/apps/mobile/src/features/notifications/WCNotification.tsx new file mode 100644 index 00000000..94b35306 --- /dev/null +++ b/apps/mobile/src/features/notifications/WCNotification.tsx @@ -0,0 +1,57 @@ +import React from 'react' +import { useDispatch } from 'react-redux' +import { openModal } from 'src/features/modals/modalSlice' +import { iconSizes } from 'ui/src/theme' +import { DappLogoWithTxStatus } from 'uniswap/src/components/CurrencyLogo/LogoWithTxStatus' +import { NotificationToast } from 'uniswap/src/components/notifications/NotificationToast' +import { ScannerModalState } from 'uniswap/src/components/ReceiveQRCode/constants' +import { toSupportedChainId } from 'uniswap/src/features/chains/utils' +import { NOTIFICATION_ICON_SIZE } from 'uniswap/src/features/notifications/constants' +import { WalletConnectNotification } from 'uniswap/src/features/notifications/slice/types' +import { ModalName } from 'uniswap/src/features/telemetry/constants' +import { WalletConnectEvent } from 'uniswap/src/types/walletConnect' +import { formWCNotificationTitle } from 'wallet/src/features/notifications/utils' + +export function WCNotification({ notification }: { notification: WalletConnectNotification }): JSX.Element { + const { imageUrl, chainId, address, event, hideDelay, dappName } = notification + const dispatch = useDispatch() + const validChainId = toSupportedChainId(chainId) + const title = formWCNotificationTitle(notification) + + const smallToastEvents = [ + WalletConnectEvent.Connected, + WalletConnectEvent.Disconnected, + WalletConnectEvent.NetworkChanged, + ] + const smallToast = smallToastEvents.includes(event) + + const icon = ( + + ) + + const onPressNotification = (): void => { + dispatch( + openModal({ + name: ModalName.WalletConnectScan, + initialState: ScannerModalState.ConnectedDapps, + }), + ) + } + + return ( + + ) +} diff --git a/apps/mobile/src/features/notifications/constants.ts b/apps/mobile/src/features/notifications/constants.ts new file mode 100644 index 00000000..00726dd0 --- /dev/null +++ b/apps/mobile/src/features/notifications/constants.ts @@ -0,0 +1,19 @@ +// Enum value represents tag name in OneSignal +export enum NotifSettingType { + GeneralUpdates = 'settings_general_updates_enabled', +} + +// Enum value represents tag name in OneSignal +export enum OneSignalUserTagField { + OnboardingCompletedAt = 'onboarding_completed_at', + OnboardingImportType = 'onboarding_import_type', + OnboardingWalletAddress = 'onboarding_wallet_address', + SwapLastCompletedAt = 'swap_last_completed_at', + AccountIsUnfunded = 'account_is_unfunded', + GatingUnfundedWalletsEnabled = 'gating_unfunded_wallets_enabled', + ActiveWalletAddress = 'active_wallet_address', +} + +export enum NotificationType { + UnfundedWalletReminder = 'unfunded_wallet_reminder', +} diff --git a/apps/mobile/src/features/notifications/hooks/useNotificationOSPermissionsEnabled.ts b/apps/mobile/src/features/notifications/hooks/useNotificationOSPermissionsEnabled.ts new file mode 100644 index 00000000..84e5393c --- /dev/null +++ b/apps/mobile/src/features/notifications/hooks/useNotificationOSPermissionsEnabled.ts @@ -0,0 +1,33 @@ +import { useFocusEffect } from '@react-navigation/core' +import { useState } from 'react' +import { checkNotifications } from 'react-native-permissions' +import { useAppStateTrigger } from 'src/utils/useAppStateTrigger' + +export enum NotificationPermission { + Enabled = 'enabled', + Disabled = 'disabled', + Loading = 'loading', +} + +export function useNotificationOSPermissionsEnabled(): { + notificationPermissionsEnabled: NotificationPermission + checkNotificationPermissions: () => Promise +} { + const [notificationPermissionsEnabled, setNotificationPermissionsEnabled] = useState( + NotificationPermission.Loading, + ) + + const checkNotificationPermissions = async (): Promise => { + const { status } = await checkNotifications() + const permission = status === 'granted' ? NotificationPermission.Enabled : NotificationPermission.Disabled + setNotificationPermissionsEnabled(permission) + } + + useFocusEffect(() => { + checkNotificationPermissions().catch(() => undefined) + }) + + useAppStateTrigger({ from: 'background', to: 'active', callback: checkNotificationPermissions }) + + return { notificationPermissionsEnabled, checkNotificationPermissions } +} diff --git a/apps/mobile/src/features/notifications/hooks/useNotificationsToggle.test.ts b/apps/mobile/src/features/notifications/hooks/useNotificationsToggle.test.ts new file mode 100644 index 00000000..c5fc0db6 --- /dev/null +++ b/apps/mobile/src/features/notifications/hooks/useNotificationsToggle.test.ts @@ -0,0 +1,209 @@ +import { useDispatch } from 'react-redux' +import { + NotificationPermission, + useNotificationOSPermissionsEnabled, +} from 'src/features/notifications/hooks/useNotificationOSPermissionsEnabled' +import { useAddressNotificationToggle } from 'src/features/notifications/hooks/useNotificationsToggle' +import { promptPushPermission } from 'src/features/notifications/Onesignal' +import { showNotificationSettingsAlert } from 'src/features/notifications/showNotificationSettingsAlert' +import { act, renderHook, waitFor } from 'src/test/test-utils' +import { useSelectAccountNotificationSetting } from 'wallet/src/features/wallet/hooks' + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), // Keep all other exports + useDispatch: jest.fn(), +})) + +jest.mock('src/features/notifications/Onesignal', () => ({ + promptPushPermission: jest.fn(), +})) + +jest.mock('src/features/notifications/hooks/useNotificationOSPermissionsEnabled', () => ({ + ...jest.requireActual('src/features/notifications/hooks/useNotificationOSPermissionsEnabled'), + useNotificationOSPermissionsEnabled: jest.fn(), +})) + +jest.mock('wallet/src/features/wallet/accounts/editAccountSaga', () => ({ + ...jest.requireActual('wallet/src/features/wallet/accounts/editAccountSaga'), + editAccountActions: { + trigger: jest.fn((payload) => ({ type: 'EDIT_ACCOUNT', payload })), + }, +})) + +jest.mock('src/features/notifications/showNotificationSettingsAlert', () => ({ + showNotificationSettingsAlert: jest.fn(), +})) + +jest.mock('src/utils/useAppStateTrigger', () => ({ + useAppStateTrigger: jest.fn(), +})) + +jest.mock('wallet/src/features/wallet/hooks', () => ({ + useSelectAccountNotificationSetting: jest.fn(), +})) + +describe('useAddressNotificationToggle', () => { + const mockAddress = '0xAddress' + const mockDispatch = jest.fn() + const mockUseDispatch = useDispatch as jest.MockedFunction + const mockPermissionPrompt = jest.mocked(promptPushPermission) + const mockSettingsAlert = jest.mocked(showNotificationSettingsAlert) + const mockUseSelectAccountNotificationSetting = jest.mocked(useSelectAccountNotificationSetting) + const mockUseNotificationOSPermissionsQuery = jest.mocked(useNotificationOSPermissionsEnabled) + + beforeEach(() => { + jest.clearAllMocks() + mockUseDispatch.mockReturnValue(mockDispatch) + }) + + // oxlint-disable-next-line typescript/explicit-function-return-type + function setupHook({ + osPermissionStatus = NotificationPermission.Enabled, + firebaseEnabled = true, + onPermissionChanged, + }: { + osPermissionStatus?: NotificationPermission + firebaseEnabled?: boolean + onPermissionChanged?: (enabled: boolean) => void + } = {}) { + mockUseNotificationOSPermissionsQuery.mockReturnValue({ + notificationPermissionsEnabled: osPermissionStatus, + checkNotificationPermissions: jest.fn(), + }) + mockUseSelectAccountNotificationSetting.mockReturnValue(firebaseEnabled) + return renderHook(() => useAddressNotificationToggle({ address: mockAddress, onToggle: onPermissionChanged })) + } + + describe('initial states', () => { + it('returns enabled when both OS and Firebase are enabled', () => { + const { result } = setupHook({ + osPermissionStatus: NotificationPermission.Enabled, + firebaseEnabled: true, + }) + + expect(result.current.isEnabled).toBe(true) + expect(result.current.isPending).toBe(false) + }) + + it('returns disabled when OS permissions are disabled', () => { + const { result } = setupHook({ + osPermissionStatus: NotificationPermission.Disabled, + firebaseEnabled: true, + }) + + expect(result.current.isEnabled).toBe(false) + expect(result.current.isPending).toBe(false) + }) + + it('returns disabled when Firebase is disabled', () => { + const { result } = setupHook({ + osPermissionStatus: NotificationPermission.Enabled, + firebaseEnabled: false, + }) + + expect(result.current.isEnabled).toBe(false) + expect(result.current.isPending).toBe(false) + }) + }) + + describe('toggle flows', () => { + // For the toggle test + it('handles toggle when OS permissions are enabled', async () => { + const { result } = setupHook({ + osPermissionStatus: NotificationPermission.Enabled, + firebaseEnabled: true, + }) + + await act(async () => { + result.current.toggle() + await new Promise(requestAnimationFrame) + }) + await waitFor(() => { + expect(mockDispatch).toHaveBeenCalled() + expect(result.current.isEnabled).toBe(false) + }) + }) + + it('handles OS permission prompt flow successfully', async () => { + mockPermissionPrompt.mockResolvedValueOnce(true) + + const { result } = setupHook({ + osPermissionStatus: NotificationPermission.Disabled, + firebaseEnabled: false, + }) + + await act(async () => { + result.current.toggle() + await new Promise(requestAnimationFrame) + }) + + await waitFor(() => { + expect(mockPermissionPrompt).toHaveBeenCalled() + expect(mockDispatch).toHaveBeenCalled() + expect(result.current.isEnabled).toBe(true) + }) + }) + + it('handles OS permission prompt flow failure', async () => { + mockPermissionPrompt.mockResolvedValueOnce(false) + + const { result } = setupHook({ + osPermissionStatus: NotificationPermission.Disabled, + firebaseEnabled: false, + }) + + await act(async () => { + result.current.toggle() + await new Promise(requestAnimationFrame) + }) + + await waitFor(() => { + expect(mockPermissionPrompt).toHaveBeenCalled() + expect(mockSettingsAlert).toHaveBeenCalled() + expect(result.current.isEnabled).toBe(false) + expect(result.current.isPending).toBe(false) + }) + }) + }) + + describe('callbacks', () => { + it('calls onPermissionChanged after successful toggle', async () => { + const onPermissionChanged = jest.fn() + + const { result } = setupHook({ + osPermissionStatus: NotificationPermission.Enabled, + firebaseEnabled: false, + onPermissionChanged, + }) + + await act(async () => { + result.current.toggle() + await new Promise(requestAnimationFrame) + }) + + await waitFor(() => { + expect(onPermissionChanged).toHaveBeenCalledWith(true) + }) + }) + }) + + describe('error handling', () => { + it('shows settings alert and resets state on OS permission denial', async () => { + mockPermissionPrompt.mockResolvedValueOnce(false) + + const { result } = setupHook({ + osPermissionStatus: NotificationPermission.Disabled, + }) + + await act(async () => { + result.current.toggle() + await new Promise(requestAnimationFrame) + }) + + await waitFor(() => { + expect(mockSettingsAlert).toHaveBeenCalled() + expect(result.current.isEnabled).toBe(false) + }) + }) + }) +}) diff --git a/apps/mobile/src/features/notifications/hooks/useNotificationsToggle.ts b/apps/mobile/src/features/notifications/hooks/useNotificationsToggle.ts new file mode 100644 index 00000000..e84ccb22 --- /dev/null +++ b/apps/mobile/src/features/notifications/hooks/useNotificationsToggle.ts @@ -0,0 +1,197 @@ +import { useMutation } from '@tanstack/react-query' +import { useCallback, useEffect, useState } from 'react' +import { useDispatch, useSelector } from 'react-redux' +import { NotifSettingType } from 'src/features/notifications/constants' +import { + NotificationPermission, + useNotificationOSPermissionsEnabled, +} from 'src/features/notifications/hooks/useNotificationOSPermissionsEnabled' +import { usePromptPushPermission } from 'src/features/notifications/hooks/usePromptPushPermission' +import { selectAllPushNotificationSettings } from 'src/features/notifications/selectors' +import { showNotificationSettingsAlert } from 'src/features/notifications/showNotificationSettingsAlert' +import { updateNotifSettings } from 'src/features/notifications/slice' +import { waitFrame } from 'utilities/src/react/delayUtils' +import { EditAccountAction, editAccountActions } from 'wallet/src/features/wallet/accounts/editAccountSaga' +import { useSelectAccountNotificationSetting } from 'wallet/src/features/wallet/hooks' + +enum NotificationError { + OsPermissionDenied = 'OS_PERMISSION_DENIED', +} + +export function useAddressNotificationToggle({ + address, + onToggle, +}: { + address: string + onToggle?: (enabled: boolean) => void +}): ReturnType { + const dispatch = useDispatch() + const isAppPermissionEnabled = useSelectAccountNotificationSetting(address) + + const handleToggle = useCallback( + (enabled: boolean) => { + dispatch( + editAccountActions.trigger({ + type: EditAccountAction.TogglePushNotification, + enabled, + address, + }), + ) + onToggle?.(enabled) + }, + [address, dispatch, onToggle], + ) + + return useBaseNotificationToggle({ isAppPermissionEnabled, onToggle: handleToggle }) +} + +export function useSettingNotificationToggle({ + type, + onToggle, +}: { + type: NotifSettingType + onToggle?: (enabled: boolean) => void +}): ReturnType { + const dispatch = useDispatch() + const { generalUpdatesEnabled } = useSelector(selectAllPushNotificationSettings) + + const permissionEnabledMap: Record = { + [NotifSettingType.GeneralUpdates]: generalUpdatesEnabled, + } + const isAppPermissionEnabled = permissionEnabledMap[type] + + const handleToggle = useCallback( + (enabled: boolean) => { + dispatch(updateNotifSettings({ [type]: enabled })) + onToggle?.(enabled) + }, + [dispatch, onToggle, type], + ) + + return useBaseNotificationToggle({ isAppPermissionEnabled, onToggle: handleToggle }) +} + +/** + * useNotificationToggle + * + * Enabling notifications across different initial states. + * + * What goes into enabling notifications? + * - OS permissions + * - Firebase settings (via redux) + * + * What states can the user be in? + * - Denied notifications at the OS level + * - Enabled notifications at the OS level + * - Denied notifications during onboarding + * - Enabled notifications during onboarding + * - Skipped notifications during onboarding + * + * Situation A: Denied notifications at the OS level + * - User goes to toggle notifications + * - We optimistically enable firebase settings + * - We prompt the user to go to settings and re-enable notifications + * - App is backgrounded and user goes to settings + * - App is foregrounded and we refetch the OS permissions + * - Notifications are enabled + * + * Situation B: User skipped notifications during onboarding + * - User goes to toggle notifications + * - We request the OS permissions + * - Notifications are enabled + * + * Situation C: User has notifications enabled but wants to disable + * - User goes to toggle notifications + * - We disable Firebase settings immediately + * - OS permissions remain enabled but notifications stop + * - User can re-enable without OS prompt + * + * Situation D: User enabled during onboarding but removed OS permissions later + * - User has Firebase enabled but OS permissions are off + * - User goes to toggle notifications + * - We detect mismatched state + * - We maintain Firebase settings as enabled + * - We prompt user to restore OS permissions + * - Normal OS permission flow resumes + */ + +function useBaseNotificationToggle({ + isAppPermissionEnabled, + onToggle, +}: { + isAppPermissionEnabled: boolean + onToggle: (enabled: boolean) => void +}): { + isEnabled: boolean + isPending: boolean + toggle: () => void +} { + // Get real states from different systems + const { notificationPermissionsEnabled: osPermissionStatus } = useNotificationOSPermissionsEnabled() + const isOSPermissionEnabled = osPermissionStatus === NotificationPermission.Enabled + + // Derive real enabled state - only true if both systems are enabled + const isEnabled = isOSPermissionEnabled && isAppPermissionEnabled + + // Optimistic UI state + const [optimisticEnabled, setOptimisticEnabled] = useState(isEnabled) + const promptPushPermission = usePromptPushPermission() + // Helper to handle OS permission request and state update + const requestOSPermissions = useCallback(async (): Promise => { + const granted = await promptPushPermission() + if (!granted) { + // Keep app permissions enabled for when OS permissions are restored + onToggle(true) + // this means the user denied the permission at the system level + // and needs to go to settings to re-enable (boo) + throw new Error(NotificationError.OsPermissionDenied) + } + return true + }, [onToggle, promptPushPermission]) + + // Reset optimistic state if real state changes + useEffect(() => { + setOptimisticEnabled(isEnabled) + }, [isEnabled]) + + const mutation = useMutation({ + onMutate: async () => { + // Wait for next frame to ensure UI updates without flashing + await waitFrame() + // Only show optimistic updates when we have OS permissions + if (isOSPermissionEnabled) { + setOptimisticEnabled(!optimisticEnabled) + } + }, + mutationFn: async () => { + const isOsEnabled = isOSPermissionEnabled || (await requestOSPermissions()) + + // After this point, we're guaranteed to have requested OS permissions + // If we just obtained permissions, we want to enable notifications + // Otherwise, we're toggling the current redux state + // oxlint-disable-next-line typescript/no-unnecessary-condition + const shouldEnable = isOsEnabled ? !isAppPermissionEnabled : true + return shouldEnable + }, + onError: (error) => { + if (error.message === NotificationError.OsPermissionDenied) { + // user will need to go to settings to re-enable notifications + // when they come back, the real state will be refetched and the UI will update automatically + showNotificationSettingsAlert() + setOptimisticEnabled(false) + } + }, + onSuccess: (enabled) => { + // setState will bail if the value is the same as the current state + // so we can safely call it without conditionals + setOptimisticEnabled(enabled) + onToggle(enabled) + }, + }) + + return { + isEnabled: optimisticEnabled, + isPending: mutation.isPending, + toggle: () => mutation.mutate(), + } +} diff --git a/apps/mobile/src/features/notifications/hooks/usePromptPushPermission.ts b/apps/mobile/src/features/notifications/hooks/usePromptPushPermission.ts new file mode 100644 index 00000000..e333f1bf --- /dev/null +++ b/apps/mobile/src/features/notifications/hooks/usePromptPushPermission.ts @@ -0,0 +1,20 @@ +import { usePreventLock } from 'src/features/lockScreen/hooks/usePreventLock' +import { promptPushPermission } from 'src/features/notifications/Onesignal' +import { useEvent } from 'utilities/src/react/hooks' + +/** + * Custom hook to handle push notification permissions with Android-specific considerations. + * + * On Android, requesting permissions causes the app to briefly enter a background state. + * We use preventLock to ensure this temporary background state doesn't trigger any + * app lock mechanisms during the permission request flow. + * + * @see https://github.com/OneSignal/react-native-onesignal/issues/1658#issuecomment-1974849646 + */ +export function usePromptPushPermission(): () => Promise { + const { preventLock } = usePreventLock() + + return useEvent(async (): Promise => { + return preventLock(promptPushPermission) + }) +} diff --git a/apps/mobile/src/features/notifications/saga.ts b/apps/mobile/src/features/notifications/saga.ts new file mode 100644 index 00000000..edef583f --- /dev/null +++ b/apps/mobile/src/features/notifications/saga.ts @@ -0,0 +1,66 @@ +import { OneSignal } from 'react-native-onesignal' +import { NotifSettingType, OneSignalUserTagField } from 'src/features/notifications/constants' +import { selectAllPushNotificationSettings } from 'src/features/notifications/selectors' +import { initNotifsForNewUser, updateNotifSettings } from 'src/features/notifications/slice' +import { call, select, takeEvery } from 'typed-redux-saga' +import { finalizeTransaction } from 'uniswap/src/features/transactions/slice' +import { TransactionStatus, TransactionType } from 'uniswap/src/features/transactions/types/transactionDetails' +import { ONE_SECOND_MS } from 'utilities/src/time/time' +import { selectActiveAccountAddress, selectFinishedOnboarding } from 'wallet/src/features/wallet/selectors' +import { removeAccounts, setAccountAsActive } from 'wallet/src/features/wallet/slice' + +export function* pushNotificationsWatcherSaga() { + yield* call(syncWithOneSignal) + yield* call(syncActiveWalletAddressTag) + + yield* takeEvery(initNotifsForNewUser.type, initNewUser) + yield* takeEvery(updateNotifSettings.type, syncWithOneSignal) + yield* takeEvery(finalizeTransaction.type, processFinalizedTx) + yield* takeEvery(setAccountAsActive.type, syncActiveWalletAddressTag) + yield* takeEvery(removeAccounts.type, syncActiveWalletAddressTag) +} + +/** + * Due to our app not having an account abstraction, OneSignal values are device-specific. + * So, this is intentionally driving local changes as the source of truth, + * since OneSignal is not a fully reliable and scalable backend. + * If we ever need to share settings across devices, this will need to change. + */ +function* syncWithOneSignal() { + const finishedOnboarding = yield* select(selectFinishedOnboarding) + + if (finishedOnboarding) { + const { generalUpdatesEnabled } = yield* select(selectAllPushNotificationSettings) + + yield* call(OneSignal.User.addTags, { + [NotifSettingType.GeneralUpdates]: generalUpdatesEnabled.toString(), + }) + } +} + +function* initNewUser() { + yield* call(OneSignal.User.addTags, { + [NotifSettingType.GeneralUpdates]: 'true', + }) +} + +function* processFinalizedTx(action: ReturnType) { + const isSuccessfulSwap = + action.payload.typeInfo.type === TransactionType.Swap && action.payload.status === TransactionStatus.Success + if (isSuccessfulSwap) { + yield* call( + OneSignal.User.addTag, + OneSignalUserTagField.SwapLastCompletedAt, + Math.floor(Date.now() / ONE_SECOND_MS).toString(), + ) + } +} + +function* syncActiveWalletAddressTag() { + const activeAddress = yield* select(selectActiveAccountAddress) + if (activeAddress) { + yield* call(OneSignal.User.addTag, OneSignalUserTagField.ActiveWalletAddress, activeAddress) + } else { + yield* call(OneSignal.User.removeTag, OneSignalUserTagField.ActiveWalletAddress) + } +} diff --git a/apps/mobile/src/features/notifications/selectors.ts b/apps/mobile/src/features/notifications/selectors.ts new file mode 100644 index 00000000..7aa08173 --- /dev/null +++ b/apps/mobile/src/features/notifications/selectors.ts @@ -0,0 +1,10 @@ +import { MobileState } from 'src/app/mobileReducer' + +export const selectAllPushNotificationSettings = ( + state: MobileState, +): { + generalUpdatesEnabled: boolean +} => { + const { generalUpdatesEnabled } = state.pushNotifications + return { generalUpdatesEnabled } +} diff --git a/apps/mobile/src/features/notifications/showNotificationSettingsAlert.ts b/apps/mobile/src/features/notifications/showNotificationSettingsAlert.ts new file mode 100644 index 00000000..e42095ef --- /dev/null +++ b/apps/mobile/src/features/notifications/showNotificationSettingsAlert.ts @@ -0,0 +1,16 @@ +import { Alert } from 'react-native' +import { openNotificationSettings } from 'src/utils/linking' +import i18n from 'uniswap/src/i18n' + +export const showNotificationSettingsAlert = (): void => { + Alert.alert( + i18n.t('onboarding.notification.permission.title'), + i18n.t('onboarding.notification.permission.message'), + [ + { text: i18n.t('common.navigation.settings'), onPress: openNotificationSettings }, + { + text: i18n.t('common.button.cancel'), + }, + ], + ) +} diff --git a/apps/mobile/src/features/notifications/slice.ts b/apps/mobile/src/features/notifications/slice.ts new file mode 100644 index 00000000..1ba972e9 --- /dev/null +++ b/apps/mobile/src/features/notifications/slice.ts @@ -0,0 +1,36 @@ +import { createSlice, type PayloadAction } from '@reduxjs/toolkit' +import { NotifSettingType } from 'src/features/notifications/constants' + +// oxlint-disable-next-line import/no-unused-modules +export interface PushNotificationsState { + generalUpdatesEnabled: boolean +} + +export const initialPushNotificationsState: PushNotificationsState = { + generalUpdatesEnabled: true, +} + +type SettingsUpdatePayload = { + [k in NotifSettingType]?: boolean +} + +const slice = createSlice({ + name: 'pushNotifications', + initialState: initialPushNotificationsState, + reducers: { + updateNotifSettings: (state, action: PayloadAction) => { + if (action.payload[NotifSettingType.GeneralUpdates] !== undefined) { + state.generalUpdatesEnabled = action.payload[NotifSettingType.GeneralUpdates] + } + }, + initNotifsForNewUser: (state) => { + // Primary used to trigger side effects in saga + state.generalUpdatesEnabled = true + }, + resetPushNotifications: () => initialPushNotificationsState, + }, +}) + +export const { initNotifsForNewUser, updateNotifSettings, resetPushNotifications } = slice.actions + +export const pushNotificationsReducer = slice.reducer diff --git a/apps/mobile/src/features/onboarding/BackupSpeedBumpModal.tsx b/apps/mobile/src/features/onboarding/BackupSpeedBumpModal.tsx new file mode 100644 index 00000000..b369a1b6 --- /dev/null +++ b/apps/mobile/src/features/onboarding/BackupSpeedBumpModal.tsx @@ -0,0 +1,118 @@ +import { useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { LockPreviewImage } from 'src/features/onboarding/LockPreviewImage' +import { Button, Flex, LabeledCheckbox, Text, useIsDarkMode, useShadowPropsShort } from 'ui/src' +import { CheckCircleFilled } from 'ui/src/components/icons' +import { Modal } from 'uniswap/src/components/modals/Modal' +import { ModalName } from 'uniswap/src/features/telemetry/constants' +import { BackupType } from 'wallet/src/features/wallet/accounts/types' + +const PREVIEW_BOX_HEIGHT = 122 + +type BackupSpeedBumpModalProps = { + backupType: BackupType.Cloud | BackupType.Manual + + onContinue: () => void + onClose: () => void +} +export function BackupSpeedBumpModal({ backupType, onContinue, onClose }: BackupSpeedBumpModalProps): JSX.Element { + const { t } = useTranslation() + const [checked, setChecked] = useState(false) + + // oxlint-disable-next-line consistent-return + const { preview, title, description, disclaimer } = useMemo(() => { + switch (backupType) { + case BackupType.Cloud: + return { + preview: , + title: t('onboarding.backup.speedBump.cloud.title'), + description: t('onboarding.backup.speedBump.cloud.description'), + disclaimer: t('onboarding.backup.speedBump.cloud.disclaimer'), + } + case BackupType.Manual: + return { + preview: , + title: t('onboarding.backup.speedBump.manual.title'), + description: t('onboarding.backup.speedBump.manual.description'), + disclaimer: t('onboarding.backup.speedBump.manual.disclaimer'), + } + } + }, [backupType, t]) + + return ( + + + {preview} + + + {title} + + + {description} + + + + setChecked(!checked)} + > + { + setChecked(!checked) + }} + /> + + {disclaimer} + + + + + + + + + + ) +} + +const BULLET_COUNT = 13 +const BULLET = '•' + +function CloudBackupPreview(): JSX.Element { + const isDarkMode = useIsDarkMode() + const shadowProps = useShadowPropsShort() + const bullets = new Array(BULLET_COUNT).fill(BULLET) + + return ( + + + + {bullets} + + + + + ) +} diff --git a/apps/mobile/src/features/onboarding/LockPreviewImage.tsx b/apps/mobile/src/features/onboarding/LockPreviewImage.tsx new file mode 100644 index 00000000..398e3c21 --- /dev/null +++ b/apps/mobile/src/features/onboarding/LockPreviewImage.tsx @@ -0,0 +1,71 @@ +import { Flex, useShadowPropsShort } from 'ui/src' +import { Lock } from 'ui/src/components/icons' + +const BOX_ROW_COUNT = 4 +const BOX_COL_COUNT = 2 +const BOX_HEIGHT = 7 +const BOX_WIDTH = 77 +const BOXES_CONTAINER_HEIGHT = 136 +const BOXES_CONTAINER_WIDTH = 222 + +const DEFAULT_PREVIEW_HEIGHT = 122 + +export function LockPreviewImage({ height = DEFAULT_PREVIEW_HEIGHT }: { height?: number }): JSX.Element { + const shadowProps = useShadowPropsShort() + + const rows: number[][] = new Array(BOX_ROW_COUNT).fill(new Array(BOX_COL_COUNT).fill(0)) + + return ( + + + + {rows.map((cols, rowIndex) => ( + + {cols.map((_, colIndex) => ( + + ))} + + ))} + + + + + + + + + + ) +} diff --git a/apps/mobile/src/features/onboarding/OnboardingScreen.tsx b/apps/mobile/src/features/onboarding/OnboardingScreen.tsx new file mode 100644 index 00000000..8af03d4d --- /dev/null +++ b/apps/mobile/src/features/onboarding/OnboardingScreen.tsx @@ -0,0 +1,136 @@ +import { useFocusEffect } from '@react-navigation/core' +import { useHeaderHeight } from '@react-navigation/elements' +import React, { PropsWithChildren, useCallback } from 'react' +import { BackHandler, StyleSheet } from 'react-native' +import { KeyboardAvoidingView } from 'react-native-keyboard-controller' +import { FadeIn, FadeOut } from 'react-native-reanimated' +import { HeaderSkipButton, renderHeaderBackButton } from 'src/app/navigation/components' +import { useOnboardingStackNavigation } from 'src/app/navigation/types' +import { Screen, SHORT_SCREEN_HEADER_HEIGHT_RATIO } from 'src/components/layout/Screen' +import { useRegionalizedLineHeight } from 'src/components/text/useRegionalizedLineHeight' +import { Flex, GeneratedIcon, SpaceTokens, Text, useMedia } from 'ui/src' +import { AnimatedFlex } from 'ui/src/components/layout/AnimatedFlex' +import { fonts } from 'ui/src/theme' +import { useAppInsets } from 'uniswap/src/hooks/useAppInsets' +import { isIOS } from 'utilities/src/platform' + +type OnboardingScreenProps = { + subtitle?: string + title?: string + Icon?: GeneratedIcon + paddingTop?: SpaceTokens + childrenGap?: SpaceTokens + keyboardAvoidingViewEnabled?: boolean + disableGoBack?: boolean + onSkip?: () => void + ignoreContainerPaddingX?: boolean + ignoreTextContainerMarginBottom?: boolean +} + +export function OnboardingScreen({ + title, + subtitle, + Icon, + children, + paddingTop = '$none', + keyboardAvoidingViewEnabled = true, + disableGoBack = false, + onSkip, + ignoreContainerPaddingX, + ignoreTextContainerMarginBottom, +}: PropsWithChildren): JSX.Element { + const navigation = useOnboardingStackNavigation() + const headerHeight = useHeaderHeight() + const insets = useAppInsets() + const media = useMedia() + // TODO(WALL-5483): remove this once we improve seed recovery screen design on smaller devices + const showIcon = !media.short + + const gapSize = media.short ? '$none' : '$spacing16' + + useFocusEffect( + useCallback(() => { + navigation.setOptions({ + headerLeft: disableGoBack ? (): null => null : renderHeaderBackButton, + gestureEnabled: !disableGoBack, + headerRight: !onSkip + ? (): null => null + : (_props): JSX.Element => onSkip()} />, + }) + const subscription = BackHandler.addEventListener('hardwareBackPress', () => { + return disableGoBack + }) + + return subscription.remove + }, [navigation, disableGoBack, onSkip]), + ) + + const titleLineHeight = useRegionalizedLineHeight() + + return ( + + + + {/* Text content */} + + {showIcon && Icon && ( + + + + + + )} + {title && ( + + {title} + + )} + {subtitle ? ( + + {subtitle} + + ) : null} + + {/* page content */} + + {children} + + + + + ) +} + +const WrapperStyle = StyleSheet.create({ + base: { + flex: 1, + justifyContent: 'flex-end', + }, +}) diff --git a/apps/mobile/src/features/onboarding/OptionCard.tsx b/apps/mobile/src/features/onboarding/OptionCard.tsx new file mode 100644 index 00000000..0705e1ac --- /dev/null +++ b/apps/mobile/src/features/onboarding/OptionCard.tsx @@ -0,0 +1,88 @@ +import React from 'react' +import { Flex, Text, TouchableArea, useIsDarkMode } from 'ui/src' +import { iconSizes } from 'ui/src/theme' +import { ElementName } from 'uniswap/src/features/telemetry/constants' +import Trace from 'uniswap/src/features/telemetry/Trace' +import { TestIDType } from 'uniswap/src/test/fixtures/testIDs' + +export function OptionCard({ + title, + blurb, + icon, + onPress, + elementName, + disabled, + opacity, + badgeText, + testID, +}: { + title: string + blurb: string + icon: React.ReactNode + onPress: () => void + elementName: ElementName + testID: TestIDType + disabled?: boolean + opacity?: number + badgeText?: string | undefined +}): JSX.Element { + const isDarkMode = useIsDarkMode() + + return ( + + + + + {icon} + + + + + + + {title} + + + {badgeText && ( + + + {badgeText} + + + )} + + + {blurb} + + + + + + + ) +} diff --git a/apps/mobile/src/features/onboarding/PasswordError.tsx b/apps/mobile/src/features/onboarding/PasswordError.tsx new file mode 100644 index 00000000..6e4c6af5 --- /dev/null +++ b/apps/mobile/src/features/onboarding/PasswordError.tsx @@ -0,0 +1,21 @@ +import React from 'react' +import { StyleProp, ViewStyle } from 'react-native' +import { Flex, Text } from 'ui/src' +import { AnimatedFlex } from 'ui/src/components/layout/AnimatedFlex' + +interface PasswordErrorProps { + errorText: string + style?: StyleProp +} + +export function PasswordError({ errorText, style }: PasswordErrorProps): JSX.Element { + return ( + + + + {errorText} + + + + ) +} diff --git a/apps/mobile/src/features/onboarding/SafeKeyboardOnboardingScreen.tsx b/apps/mobile/src/features/onboarding/SafeKeyboardOnboardingScreen.tsx new file mode 100644 index 00000000..9d29ea5c --- /dev/null +++ b/apps/mobile/src/features/onboarding/SafeKeyboardOnboardingScreen.tsx @@ -0,0 +1,120 @@ +import { useHeaderHeight } from '@react-navigation/elements' +import { LinearGradient } from 'expo-linear-gradient' +import React, { PropsWithChildren } from 'react' +import { ScrollViewProps, StyleSheet } from 'react-native' +import { FadeIn, FadeOut } from 'react-native-reanimated' +import { SafeKeyboardScreen } from 'src/components/layout/SafeKeyboardScreen' +import { Flex, GeneratedIcon, SpaceTokens, Text, TouchableArea, useMedia, useSporeColors } from 'ui/src' +import { AnimatedFlex } from 'ui/src/components/layout/AnimatedFlex' +import { opacify } from 'ui/src/theme' + +type OnboardingScreenProps = { + subtitle?: string + title?: string + Icon?: GeneratedIcon + paddingTop?: SpaceTokens + footer?: JSX.Element + minHeightWhenKeyboardExpanded?: boolean + onHeaderPress?: () => void + keyboardDismissMode?: ScrollViewProps['keyboardDismissMode'] +} + +export function SafeKeyboardOnboardingScreen({ + title, + subtitle, + Icon, + children, + footer, + paddingTop = '$none', + minHeightWhenKeyboardExpanded = true, + onHeaderPress, + keyboardDismissMode, +}: PropsWithChildren): JSX.Element { + const headerHeight = useHeaderHeight() + const colors = useSporeColors() + const media = useMedia() + + const normalGradientPadding = 1.5 + const responsiveGradientPadding = media.short ? 1.25 : normalGradientPadding + + const heading = ( + <> + {title || subtitle ? ( + + {Icon && ( + + + + + + )} + {title && ( + + {title} + + )} + {subtitle && ( + + {subtitle} + + )} + + ) : null} + + ) + const aboveChildren = onHeaderPress ? ( + + {heading} + + ) : ( + heading + ) + + const page = ( + <> + {aboveChildren} + + {children} + + + ) + + return ( + + } + keyboardDismissMode={keyboardDismissMode} + minHeightWhenKeyboardExpanded={minHeightWhenKeyboardExpanded} + > + + {page} + + + ) +} + +const styles = StyleSheet.create({ + gradient: { + left: 0, + position: 'absolute', + right: 0, + top: 0, + zIndex: 1, + }, +}) diff --git a/apps/mobile/src/features/onboarding/ScreenshotWarningModal.tsx b/apps/mobile/src/features/onboarding/ScreenshotWarningModal.tsx new file mode 100644 index 00000000..f034fb27 --- /dev/null +++ b/apps/mobile/src/features/onboarding/ScreenshotWarningModal.tsx @@ -0,0 +1,23 @@ +import { useTranslation } from 'react-i18next' +import { AppStackScreenProp } from 'src/app/navigation/types' +import { useReactNavigationModal } from 'src/components/modals/useReactNavigationModal' +import { WarningModal } from 'uniswap/src/components/modals/WarningModal/WarningModal' +import { ModalName } from 'uniswap/src/features/telemetry/constants' + +export function ScreenshotWarningModal({ route }: AppStackScreenProp): JSX.Element { + const { t } = useTranslation() + const { onClose } = useReactNavigationModal() + const acknowledgeText = route.params?.acknowledgeText + + return ( + + ) +} diff --git a/apps/mobile/src/features/onboarding/hooks.ts b/apps/mobile/src/features/onboarding/hooks.ts new file mode 100644 index 00000000..830b0f3d --- /dev/null +++ b/apps/mobile/src/features/onboarding/hooks.ts @@ -0,0 +1,71 @@ +import { SharedEventName } from '@uniswap/analytics-events' +import { OneSignal } from 'react-native-onesignal' +import { useDispatch } from 'react-redux' +import { OnboardingStackBaseParams, useOnboardingStackNavigation } from 'src/app/navigation/types' +import { setOnboardingTimestamp } from 'src/features/analytics/onboardingTimestamp' +import { OneSignalUserTagField } from 'src/features/notifications/constants' +import { initNotifsForNewUser } from 'src/features/notifications/slice' +import { MobileAppsFlyerEvents } from 'uniswap/src/features/telemetry/constants' +import { sendAnalyticsEvent, sendAppsFlyerEvent } from 'uniswap/src/features/telemetry/send' +import { OnboardingEntryPoint } from 'uniswap/src/types/onboarding' +import { MobileScreens } from 'uniswap/src/types/screens/mobile' +import { logger } from 'utilities/src/logger/logger' +import { ONE_SECOND_MS } from 'utilities/src/time/time' +import { useOnboardingContext } from 'wallet/src/features/onboarding/OnboardingContext' +import { setAndroidCloudBackupEmail, setFinishedOnboarding } from 'wallet/src/features/wallet/slice' + +/** + * Bundles various actions that should be performed to complete onboarding. + * + * Used within the final screen of various onboarding flows. + */ +export function useCompleteOnboardingCallback({ + entryPoint, + importType, +}: OnboardingStackBaseParams): () => Promise { + const dispatch = useDispatch() + const { getAllOnboardingAccounts, finishOnboarding, getAndroidBackupEmail } = useOnboardingContext() + const navigation = useOnboardingStackNavigation() + + const onboardingAccounts = getAllOnboardingAccounts() + const onboardingAddresses = onboardingAccounts.map((account) => account.address) + const androidBackupEmail = getAndroidBackupEmail() + + return async () => { + // Run all shared onboarding completion logic + await finishOnboarding({ importType }) + + // Initializes notification settings + dispatch(initNotifsForNewUser()) + OneSignal.User.addTags({ + [OneSignalUserTagField.OnboardingWalletAddress]: onboardingAddresses[0] ?? '', + [OneSignalUserTagField.OnboardingCompletedAt]: Math.floor(Date.now() / ONE_SECOND_MS).toString(), + [OneSignalUserTagField.OnboardingImportType]: importType, + }) + + // Send appsflyer event for mobile attribution + if (entryPoint === OnboardingEntryPoint.FreshInstallOrReplace) { + sendAppsFlyerEvent(MobileAppsFlyerEvents.OnboardingCompleted, { importType }).catch((error) => + logger.debug('hooks', 'useCompleteOnboardingCallback', error), + ) + } + + // Log TOS acceptance for new wallets after they are activated + if (entryPoint === OnboardingEntryPoint.FreshInstallOrReplace) { + onboardingAddresses.forEach((address: string) => { + sendAnalyticsEvent(SharedEventName.TERMS_OF_SERVICE_ACCEPTED, { address }) + }) + } + + if (androidBackupEmail) { + dispatch(setAndroidCloudBackupEmail({ email: androidBackupEmail })) + } + + // Exit flow + dispatch(setFinishedOnboarding({ finishedOnboarding: true })) + setOnboardingTimestamp() + if (entryPoint === OnboardingEntryPoint.Sidebar) { + navigation.navigate(MobileScreens.Home) + } + } +} diff --git a/apps/mobile/src/features/scantastic/ScantasticEncryption.ts b/apps/mobile/src/features/scantastic/ScantasticEncryption.ts new file mode 100644 index 00000000..8882666e --- /dev/null +++ b/apps/mobile/src/features/scantastic/ScantasticEncryption.ts @@ -0,0 +1,26 @@ +interface ScantasticEncryption { + // oxlint-disable-next-line max-params -- biome-parity: oxlint is stricter here + getEncryptedMnemonic: (mnemonicId: string, n: string, e: string) => Promise +} + +declare module 'react-native' { + interface NativeModulesStatic { + ScantasticEncryption: ScantasticEncryption + } +} + +import { NativeModules } from 'react-native' + +const { ScantasticEncryption } = NativeModules + +export function getEncryptedMnemonic({ + mnemonicId, + modulus, + exponent, +}: { + mnemonicId: string + modulus: string + exponent: string +}): Promise { + return ScantasticEncryption.getEncryptedMnemonic(mnemonicId, modulus, exponent) +} diff --git a/apps/mobile/src/features/scantastic/ScantasticModal.tsx b/apps/mobile/src/features/scantastic/ScantasticModal.tsx new file mode 100644 index 00000000..72ffa35e --- /dev/null +++ b/apps/mobile/src/features/scantastic/ScantasticModal.tsx @@ -0,0 +1,389 @@ +import React, { useCallback, useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useDispatch } from 'react-redux' +import { AppStackScreenProp } from 'src/app/navigation/types' +import { useReactNavigationModal } from 'src/components/modals/useReactNavigationModal' +import { useBiometricAppSettings } from 'src/features/biometrics/useBiometricAppSettings' +import { useBiometricPrompt } from 'src/features/biometricsSettings/hooks' +import { closeAllModals } from 'src/features/modals/modalSlice' +import { getEncryptedMnemonic } from 'src/features/scantastic/ScantasticEncryption' +import { Button, Flex, Text, TouchableArea, useSporeColors } from 'ui/src' +import { AlertTriangleFilled, Faceid, Laptop, LinkBrokenHorizontal, Wifi } from 'ui/src/components/icons' +import { Modal } from 'uniswap/src/components/modals/Modal' +import { uniswapUrls } from 'uniswap/src/constants/urls' +import { pushNotification } from 'uniswap/src/features/notifications/slice/slice' +import { AppNotificationType } from 'uniswap/src/features/notifications/slice/types' +import { ModalName } from 'uniswap/src/features/telemetry/constants' +import { TestID } from 'uniswap/src/test/fixtures/testIDs' +import { logger } from 'utilities/src/logger/logger' +import { ONE_MINUTE_MS, ONE_SECOND_MS } from 'utilities/src/time/time' +import { useInterval } from 'utilities/src/time/timing' +import { useSignerAccounts } from 'wallet/src/features/wallet/hooks' +import { getOtpDurationString } from 'wallet/src/utils/duration' + +const IP_MISMATCH_STATUS_CODE = 401 + +enum OtpState { + Pending = 'pending', + Redeemed = 'redeemed', + Expired = 'expired', +} +interface OtpStateApiResponse { + otp?: OtpState + expiresAtInSeconds?: number +} + +export function ScantasticModal({ route }: AppStackScreenProp): JSX.Element | null { + const { t } = useTranslation() + const colors = useSporeColors() + const dispatch = useDispatch() + const { onClose } = useReactNavigationModal() + + // Use the first mnemonic account because zero-balance mnemonic accounts will fail to retrieve the mnemonic from rnEthers + const account = useSignerAccounts().sort( + (account1, account2) => account1.derivationIndex - account2.derivationIndex, + )[0] + + if (!account) { + throw new Error('This should not be accessed with no mnemonic accounts') + } + + const params = route.params.params + + const [OTP, setOTP] = useState('') + // Once a user has scanned a QR they have 6 minutes to correctly input the OTP + const [expirationTimestamp, setExpirationTimestamp] = useState(Date.now() + 6 * ONE_MINUTE_MS) + const pubKey = params.publicKey + const uuid = params.uuid + const device = `${params.vendor || ''} ${params.model || ''}`.trim() + const browser = params.browser || '' + + const [expired, setExpired] = useState(false) + const [redeemed, setRedeemed] = useState(false) + const [error, setError] = useState('') + + // Warning state if backend response identifies mismatched IPs between devices + const [showIPWarning, setShowIPWarning] = useState(false) + + const [expiryText, setExpiryText] = useState('') + const setExpirationText = useCallback(() => { + const expirationString = getOtpDurationString(expirationTimestamp) + setExpiryText(expirationString) + }, [expirationTimestamp]) + useInterval(setExpirationText, ONE_SECOND_MS) + + useEffect(() => { + if (redeemed) { + dispatch( + pushNotification({ + type: AppNotificationType.ScantasticComplete, + hideDelay: 6 * ONE_SECOND_MS, + }), + ) + onClose() + dispatch(closeAllModals()) + } + }, [redeemed, onClose, dispatch]) + + useEffect(() => { + const interval = setInterval(() => { + const timeLeft = expirationTimestamp - Date.now() + setExpired(timeLeft <= 0) + }, ONE_SECOND_MS) + + return () => clearInterval(interval) + }, [expirationTimestamp]) + + const onEncryptSeedphrase = async (): Promise => { + setError('') + let encryptedSeedphrase = '' + const { n, e } = pubKey + try { + encryptedSeedphrase = await getEncryptedMnemonic({ + mnemonicId: account.address, + modulus: n, + exponent: e, + }) + } catch (err) { + setError(t('scantastic.error.encryption')) + logger.error(err, { + tags: { + file: 'ScantasticModal', + function: 'onEncryptSeedphrase->getEncryptedMnemonic', + }, + extra: { + address: account.address, + n, + e, + }, + }) + } + + try { + // submit encrypted blob + const response = await fetch(`${uniswapUrls.scantasticApiUrl}/blob`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Origin: 'https://uniswap.org', + }, + body: JSON.stringify({ + uuid, + blob: encryptedSeedphrase, + }), + }) + + if (response.status === IP_MISMATCH_STATUS_CODE) { + setShowIPWarning(true) + return + } + + if (!response.ok) { + throw new Error(`Failed to post blob: ${await response.text()}`) + } + const data = await response.json() + if (!data?.otp) { + throw new Error('OTP unavailable') + } else { + setExpirationTimestamp(Date.now() + ONE_MINUTE_MS * 2) + setOTP(data.otp) + } + } catch (err) { + setError(t('scantastic.error.noCode')) + logger.error(err, { + tags: { + file: 'ScantasticModal', + function: `onEncryptSeedphrase->fetch`, + }, + extra: { uuid }, + }) + } + } + + const { trigger: biometricTrigger } = useBiometricPrompt(onEncryptSeedphrase) + const { + requiredForAppAccess: biometricAuthRequiredForAppAccess, + requiredForTransactions: biometricAuthRequiredForTransactions, + } = useBiometricAppSettings() + const requiresBiometricAuth = biometricAuthRequiredForAppAccess || biometricAuthRequiredForTransactions + + const onConfirmSync = async (): Promise => { + if (requiresBiometricAuth) { + await biometricTrigger() + } else { + await onEncryptSeedphrase() + } + } + + const checkOTPState = useCallback(async (): Promise => { + if (!OTP || !uuid) { + return + } + try { + const response = await fetch(`${uniswapUrls.scantasticApiUrl}/otp-state/${uuid}`, { + method: 'POST', + headers: { + Accept: 'application/json', + Origin: 'https://uniswap.org', + }, + }) + if (!response.ok) { + throw new Error(`Failed to check OTP state: ${await response.text()}`) + } + const data: OtpStateApiResponse = await response.json() + const otpState = data.otp + if (!otpState) { + throw new Error('No OTP state received.') + } + if (data.expiresAtInSeconds) { + setExpirationTimestamp(data.expiresAtInSeconds * ONE_SECOND_MS) + } + if (otpState === OtpState.Redeemed) { + setRedeemed(true) + } + if (otpState === OtpState.Expired) { + setExpired(true) + } + } catch (e) { + logger.error(e, { + tags: { + file: 'ScantasticModal', + function: `checkOTPState`, + }, + extra: { uuid }, + }) + } + }, [OTP, uuid]) + + useInterval(checkOTPState, ONE_SECOND_MS, true) + + if (showIPWarning) { + return ( + + + + + + + {t('scantastic.modal.ipMismatch.title')} + + {t('scantastic.modal.ipMismatch.description')} + + + + + + + + ) + } + + if (expired) { + return ( + + + + + + {t('scantastic.error.timeout.title')} + + {t('scantastic.error.timeout.message')} + + + + + + + ) + } + + if (OTP) { + return ( + + + + + + {t('scantastic.code.title')} + + {t('scantastic.code.subtitle')} + + + {OTP.substring(0, 3).split('').join(' ')} + {OTP.substring(3).split('').join(' ')} + + + {expiryText} + + + + ) + } + + if (error) { + return ( + + + + + + {t('common.text.error')} + + {error} + + + + + + + ) + } + + const renderDeviceDetails = Boolean(device || browser) + + return ( + + + + + + + {t('scantastic.confirmation.title')} + + + {t('scantastic.confirmation.subtitle')} + + {renderDeviceDetails && ( + + {device && ( + + + {t('scantastic.confirmation.label.device')} + + + {device} + + + )} + {browser && ( + + + {t('scantastic.confirmation.label.browser')} + + + {browser} + + + )} + + )} + + + + {t('scantastic.confirmation.warning')} + + + + + + + + + {t('common.button.cancel')} + + + + + + ) +} diff --git a/apps/mobile/src/features/scantastic/ScantasticModalState.ts b/apps/mobile/src/features/scantastic/ScantasticModalState.ts new file mode 100644 index 00000000..9c3ce9d8 --- /dev/null +++ b/apps/mobile/src/features/scantastic/ScantasticModalState.ts @@ -0,0 +1,5 @@ +import { ScantasticParams } from 'wallet/src/features/scantastic/types' + +export interface ScantasticModalState { + params: ScantasticParams +} diff --git a/apps/mobile/src/features/send/SendFlow.tsx b/apps/mobile/src/features/send/SendFlow.tsx new file mode 100644 index 00000000..dabbd43c --- /dev/null +++ b/apps/mobile/src/features/send/SendFlow.tsx @@ -0,0 +1,95 @@ +import { useCallback } from 'react' +import { useDispatch, useSelector } from 'react-redux' +import { useBiometricsIcon } from 'src/components/icons/useBiometricsIcon' +import { useBiometricAppSettings } from 'src/features/biometrics/useBiometricAppSettings' +import { useOsBiometricAuthEnabled } from 'src/features/biometrics/useOsBiometricAuthEnabled' +import { useBiometricPrompt } from 'src/features/biometricsSettings/hooks' +import { closeModal } from 'src/features/modals/modalSlice' +import { selectModalState } from 'src/features/modals/selectModalState' +import { SendFormScreen } from 'src/features/send/SendFormScreen' +import { SendRecipientSelectFullScreen } from 'src/features/send/SendRecipientSelectFullScreen' +import { SendReviewScreen } from 'src/features/send/SendReviewScreen' +import { useWalletRestore } from 'src/features/wallet/useWalletRestore' +import { ModalName, SectionName } from 'uniswap/src/features/telemetry/constants' +import Trace from 'uniswap/src/features/telemetry/Trace' +import { TransactionSettingsStoreContextProvider } from 'uniswap/src/features/transactions/components/settings/stores/transactionSettingsStore/TransactionSettingsStoreContextProvider' +import { TransactionModal } from 'uniswap/src/features/transactions/components/TransactionModal/TransactionModal' +import { + TransactionScreen, + useTransactionModalContext, +} from 'uniswap/src/features/transactions/components/TransactionModal/TransactionModalContext' +import { SwapFormStoreContextProvider } from 'uniswap/src/features/transactions/swap/stores/swapFormStore/SwapFormStoreContextProvider' +import { SendContextProvider, useSendContext } from 'wallet/src/features/transactions/contexts/SendContext' + +export function SendFlow(): JSX.Element { + const dispatch = useDispatch() + const { initialState } = useSelector(selectModalState(ModalName.Send)) + + const onClose = useCallback(() => { + dispatch(closeModal({ name: ModalName.Send })) + }, [dispatch]) + + const { walletNeedsRestore, openWalletRestoreModal } = useWalletRestore() + + const isBiometricAuthEnabled = useOsBiometricAuthEnabled() + const { requiredForTransactions } = useBiometricAppSettings() + const { trigger: biometricsTrigger } = useBiometricPrompt() + const useBiometricIcon = useBiometricsIcon() + const renderBiometricsIcon = + isBiometricAuthEnabled && requiredForTransactions && useBiometricIcon ? useBiometricIcon : null + + return ( + + + + + + + + + + ) +} + +function CurrentScreen({ screenOverride }: { screenOverride?: TransactionScreen }): JSX.Element { + const { screen, setScreen } = useTransactionModalContext() + const { recipient } = useSendContext() + + if (screenOverride) { + setScreen(screenOverride) + } + + // If no recipient, force full screen recipient select. Need to render this outside of `SendFormScreen` to ensure that + // the modals are rendered correctly, and animations can properly measure the available space for the decimal pad. + if (!recipient) { + return ( + + + + ) + } + + switch (screen) { + case TransactionScreen.Form: + return ( + + + + ) + case TransactionScreen.Review: + return ( + + + + ) + default: + throw new Error(`Unknown screen: ${screen}`) + } +} diff --git a/apps/mobile/src/features/send/SendFormButton.tsx b/apps/mobile/src/features/send/SendFormButton.tsx new file mode 100644 index 00000000..740f40d2 --- /dev/null +++ b/apps/mobile/src/features/send/SendFormButton.tsx @@ -0,0 +1,122 @@ +import React, { Dispatch, SetStateAction, useCallback, useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { useSelector } from 'react-redux' +import { Button, Flex } from 'ui/src' +import { WarningLabel } from 'uniswap/src/components/modals/WarningModal/types' +import { nativeOnChain } from 'uniswap/src/constants/tokens' +import { AccountType } from 'uniswap/src/features/accounts/types' +import { selectHasDismissedLowNetworkTokenWarning } from 'uniswap/src/features/behaviorHistory/selectors' +import { UniswapEventName } from 'uniswap/src/features/telemetry/constants' +import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { useDismissedCompatibleAddressWarnings } from 'uniswap/src/features/tokens/warnings/slice/hooks' +import { useTransactionModalContext } from 'uniswap/src/features/transactions/components/TransactionModal/TransactionModalContext' +import { useIsBlocked } from 'uniswap/src/features/trm/hooks' +import { TestID } from 'uniswap/src/test/fixtures/testIDs' +import { useSendContext } from 'wallet/src/features/transactions/contexts/SendContext' +import { isAmountGreaterThanZero } from 'wallet/src/features/transactions/utils' +import { useIsBlockedActiveAddress } from 'wallet/src/features/trm/hooks' +import { useActiveAccountWithThrow } from 'wallet/src/features/wallet/hooks' + +export function SendFormButton({ + setShowViewOnlyModal, + setShowMaxTransferModal, + setShowCompatibleAddressModal, + goToReviewScreen, +}: { + setShowViewOnlyModal: Dispatch> + setShowMaxTransferModal: Dispatch> + setShowCompatibleAddressModal: Dispatch> + goToReviewScreen: () => void +}): JSX.Element { + const { t } = useTranslation() + const account = useActiveAccountWithThrow() + + const hasDismissedLowNetworkTokenWarning = useSelector(selectHasDismissedLowNetworkTokenWarning) + + const { + warnings, + recipient, + isMax, + derivedSendInfo: { chainId, currencyInInfo }, + exactAmountToken, + exactAmountFiat, + } = useSendContext() + const { walletNeedsRestore } = useTransactionModalContext() + const hasValueGreaterThanZero = useMemo(() => { + return isAmountGreaterThanZero({ + exactAmountToken, + exactAmountFiat, + currency: currencyInInfo?.currency, + }) + }, [exactAmountToken, exactAmountFiat, currencyInInfo?.currency]) + + const isViewOnlyWallet = account.type === AccountType.Readonly + + const { isBlocked: isActiveBlocked, isBlockedLoading: isActiveBlockedLoading } = useIsBlockedActiveAddress() + const { isBlocked: isRecipientBlocked, isBlockedLoading: isRecipientBlockedLoading } = useIsBlocked(recipient) + const isBlocked = isActiveBlocked || isRecipientBlocked + const isBlockedLoading = isActiveBlockedLoading || isRecipientBlockedLoading + const { tokenWarningDismissed: isCompatibleAddressDismissed } = useDismissedCompatibleAddressWarnings( + currencyInInfo?.currency, + ) + const isUnichainBridgedAsset = Boolean(currencyInInfo?.isBridged) && !isCompatibleAddressDismissed + + const insufficientGasFunds = warnings.warnings.some((warning) => warning.type === WarningLabel.InsufficientGasFunds) + + const actionButtonDisabled = + !!warnings.blockingWarning || isBlocked || isBlockedLoading || walletNeedsRestore || !hasValueGreaterThanZero + + const onPressReview = useCallback(() => { + if (isViewOnlyWallet) { + setShowViewOnlyModal(true) + return + } + + if (!hasDismissedLowNetworkTokenWarning && isMax && currencyInInfo?.currency.isNative) { + sendAnalyticsEvent(UniswapEventName.LowNetworkTokenInfoModalOpened, { location: 'send' }) + setShowMaxTransferModal(true) + return + } + + if (isUnichainBridgedAsset) { + setShowCompatibleAddressModal(true) + return + } + + goToReviewScreen() + }, [ + isViewOnlyWallet, + hasDismissedLowNetworkTokenWarning, + isMax, + currencyInInfo?.currency.isNative, + isUnichainBridgedAsset, + goToReviewScreen, + setShowViewOnlyModal, + setShowMaxTransferModal, + setShowCompatibleAddressModal, + ]) + + const nativeCurrencySymbol = nativeOnChain(chainId).symbol ?? '' + + const buttonText = insufficientGasFunds + ? t('send.warning.insufficientFunds.title', { + currencySymbol: nativeCurrencySymbol, + }) + : t('send.button.review') + + return ( + + + + ) +} diff --git a/apps/mobile/src/features/send/SendFormScreen.tsx b/apps/mobile/src/features/send/SendFormScreen.tsx new file mode 100644 index 00000000..5ec113f6 --- /dev/null +++ b/apps/mobile/src/features/send/SendFormScreen.tsx @@ -0,0 +1,242 @@ +import { useFocusEffect } from '@react-navigation/core' +import React, { useCallback, useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { TouchableWithoutFeedback } from 'react-native' +import Animated from 'react-native-reanimated' +import { RecipientSelect } from 'src/components/RecipientSelect/RecipientSelect' +import { SEND_CONTENT_RENDER_DELAY_MS } from 'src/features/send/constants' +import { SendFormButton } from 'src/features/send/SendFormButton' +import { SendHeader } from 'src/features/send/SendHeader' +import { SendTokenForm } from 'src/features/send/SendTokenForm' +import { Flex, useSporeColors } from 'ui/src' +import { Eye } from 'ui/src/components/icons' +import { Modal } from 'uniswap/src/components/modals/Modal' +import { WarningSeverity } from 'uniswap/src/components/modals/WarningModal/types' +import { WarningModal } from 'uniswap/src/components/modals/WarningModal/WarningModal' +import { TokenSelectorModal } from 'uniswap/src/components/TokenSelector/TokenSelector' +import { TokenSelectorFlow, TokenSelectorVariation } from 'uniswap/src/components/TokenSelector/types' +import { UniverseChainId } from 'uniswap/src/features/chains/types' +import { ModalName } from 'uniswap/src/features/telemetry/constants' +import { + TransactionModalFooterContainer, + TransactionModalInnerContainer, +} from 'uniswap/src/features/transactions/components/TransactionModal/TransactionModal' +import { + TransactionScreen, + useTransactionModalContext, +} from 'uniswap/src/features/transactions/components/TransactionModal/TransactionModalContext' +import { CompatibleAddressModal } from 'uniswap/src/features/transactions/modals/CompatibleAddressModal' +import { LowNativeBalanceModal } from 'uniswap/src/features/transactions/modals/LowNativeBalanceModal' +import { CurrencyField } from 'uniswap/src/types/currency' +import { createTransactionId } from 'uniswap/src/utils/createTransactionId' +import { useActiveAddresses } from 'wallet/src/features/accounts/store/hooks' +import { useSendContext } from 'wallet/src/features/transactions/contexts/SendContext' + +function useGoToReviewScreen(): () => void { + const { updateSendForm } = useSendContext() + const { setScreen } = useTransactionModalContext() + return useCallback(() => { + const txId = createTransactionId() + updateSendForm({ txId }) + setScreen(TransactionScreen.Review) + }, [setScreen, updateSendForm]) +} + +export function SendFormScreen(): JSX.Element { + const [hideContent, setHideContent] = useState(true) + useEffect(() => { + setTimeout(() => setHideContent(false), SEND_CONTENT_RENDER_DELAY_MS) + }, []) + + return +} + +function SendFormScreenContent({ hideContent }: { hideContent: boolean }): JSX.Element { + const { t } = useTranslation() + const colors = useSporeColors() + const { bottomSheetViewStyles } = useTransactionModalContext() + const { showRecipientSelector, recipient, derivedSendInfo, updateSendForm } = useSendContext() + const [showViewOnlyModal, setShowViewOnlyModal] = useState(false) + const [showMaxTransferModal, setShowMaxTransferModal] = useState(false) + const [showCompatibleAddressModal, setShowCompatibleAddressModal] = useState(false) + + const onSelectRecipient = useCallback( + (newRecipient: string) => { + updateSendForm({ recipient: newRecipient, showRecipientSelector: false }) + }, + [updateSendForm], + ) + + const onHideRecipientSelector = useCallback(() => { + updateSendForm({ showRecipientSelector: false }) + }, [updateSendForm]) + + const hideLowNetworkTokenWarning = useCallback(() => { + setShowMaxTransferModal(false) + }, []) + + const hideViewOnlyModal = useCallback(() => { + setShowViewOnlyModal(false) + }, []) + + const hideCompatibleAddressModal = useCallback(() => { + setShowCompatibleAddressModal(false) + }, []) + + const goToReviewScreen = useGoToReviewScreen() + + // Renders recipient select within a bottom sheet, only used when a recipient already exists. If no recipient + // a full screen select view is rendered within `SendFlow` + const showRecipientSelectBottomSheet = recipient && showRecipientSelector + + return ( + <> + + {showRecipientSelectBottomSheet && ( + + + + + + )} + + {!hideContent && ( + <> + + + + )} + + + + + + ) +} + +function SendFormContent({ + showViewOnlyModal, + hideViewOnlyModal, + showLowNetworkTokenWarning, + hideLowNetworkTokenWarning, + showCompatibleAddressModal, + hideCompatibleAddressModal, +}: { + showViewOnlyModal: boolean + hideViewOnlyModal: () => void + showLowNetworkTokenWarning: boolean + hideLowNetworkTokenWarning: () => void + showCompatibleAddressModal: boolean + hideCompatibleAddressModal: () => void +}): JSX.Element { + const { t } = useTranslation() + const { + derivedSendInfo: { currencyInInfo }, + } = useSendContext() + + const goToReviewScreen = useGoToReviewScreen() + + const addresses = useActiveAddresses() + + const { selectingCurrencyField, onSelectCurrency, updateSendForm } = useSendContext() + + const onHideTokenSelector = useCallback(() => { + updateSendForm({ selectingCurrencyField: undefined }) + }, [updateSendForm]) + + const onCloseLowNativeBalanceWarning = useCallback(() => { + hideViewOnlyModal() + hideLowNetworkTokenWarning() + }, [hideViewOnlyModal, hideLowNetworkTokenWarning]) + + const onAcknowledgeLowNativeBalanceWarning = useCallback(() => { + hideLowNetworkTokenWarning() + goToReviewScreen() + }, [hideLowNetworkTokenWarning, goToReviewScreen]) + + const onCloseCompatibleAddressWarning = useCallback(() => { + hideCompatibleAddressModal() + }, [hideCompatibleAddressModal]) + + const onAcknowledgeCompatibleAddressWarning = useCallback(() => { + hideCompatibleAddressModal() + goToReviewScreen() + }, [hideCompatibleAddressModal, goToReviewScreen]) + + return ( + <> + } + isOpen={showViewOnlyModal} + modalName={ModalName.SwapWarning} + severity={WarningSeverity.Low} + title={t('send.warning.viewOnly.title')} + onClose={hideViewOnlyModal} + onAcknowledge={hideViewOnlyModal} + /> + + + + {currencyInInfo && ( + + )} + {/* Do not remove `accessible`, this allows maestro to view components within this */} + + + + + + + + {!!selectingCurrencyField && ( + + )} + + ) +} diff --git a/apps/mobile/src/features/send/SendHeader.tsx b/apps/mobile/src/features/send/SendHeader.tsx new file mode 100644 index 00000000..87451c95 --- /dev/null +++ b/apps/mobile/src/features/send/SendHeader.tsx @@ -0,0 +1,53 @@ +import React, { Dispatch, SetStateAction } from 'react' +import { useTranslation } from 'react-i18next' +import { Flex, Text, TouchableArea } from 'ui/src' +import { Eye } from 'ui/src/components/icons' +import { AccountType } from 'uniswap/src/features/accounts/types' +import { useActiveAccountWithThrow } from 'wallet/src/features/wallet/hooks' + +type HeaderContentProps = { + flowName: string + setShowViewOnlyModal: Dispatch> +} + +export function SendHeader({ flowName, setShowViewOnlyModal }: HeaderContentProps): JSX.Element { + const account = useActiveAccountWithThrow() + const { t } = useTranslation() + + const isViewOnlyWallet = account.type === AccountType.Readonly + + return ( + + + {flowName} + + + {isViewOnlyWallet ? ( + setShowViewOnlyModal(true)} + > + + + + {t('swap.header.viewOnly')} + + + + ) : null} + + + ) +} diff --git a/apps/mobile/src/features/send/SendRecipientSelectFullScreen.tsx b/apps/mobile/src/features/send/SendRecipientSelectFullScreen.tsx new file mode 100644 index 00000000..774dea3e --- /dev/null +++ b/apps/mobile/src/features/send/SendRecipientSelectFullScreen.tsx @@ -0,0 +1,50 @@ +import React, { useCallback, useEffect, useState } from 'react' +import { RecipientSelect } from 'src/components/RecipientSelect/RecipientSelect' +import { SEND_CONTENT_RENDER_DELAY_MS } from 'src/features/send/constants' +import { Spacer } from 'ui/src' +import { UniverseChainId } from 'uniswap/src/features/chains/types' +import { TransactionModalInnerContainer } from 'uniswap/src/features/transactions/components/TransactionModal/TransactionModal' +import { useTransactionModalContext } from 'uniswap/src/features/transactions/components/TransactionModal/TransactionModalContext' +import { useSendContext } from 'wallet/src/features/transactions/contexts/SendContext' + +// We add a short hardcoded delay to allow the sheet to animate quickly both on first render and when going back from Review -> Form. +export function SendRecipientSelectFullScreen(): JSX.Element { + const [hideContent, setHideContent] = useState(true) + useEffect(() => { + setTimeout(() => setHideContent(false), SEND_CONTENT_RENDER_DELAY_MS) + }, []) + + return +} + +function SendRecipientSelectFullScreenContent({ hideContent }: { hideContent: boolean }): JSX.Element { + const { bottomSheetViewStyles } = useTransactionModalContext() + const { recipient, derivedSendInfo, updateSendForm } = useSendContext() + + const onSelectRecipient = useCallback( + (newRecipient: string) => { + updateSendForm({ recipient: newRecipient, showRecipientSelector: false }) + }, + [updateSendForm], + ) + + const onHideRecipientSelector = useCallback(() => { + updateSendForm({ showRecipientSelector: false }) + }, [updateSendForm]) + + return ( + + {!hideContent && ( + <> + + + + )} + + ) +} diff --git a/apps/mobile/src/features/send/SendReviewScreen.tsx b/apps/mobile/src/features/send/SendReviewScreen.tsx new file mode 100644 index 00000000..a942c576 --- /dev/null +++ b/apps/mobile/src/features/send/SendReviewScreen.tsx @@ -0,0 +1,41 @@ +import React, { useEffect, useState } from 'react' +import { SEND_CONTENT_RENDER_DELAY_MS } from 'src/features/send/constants' +import { Flex } from 'ui/src/components/layout/Flex' +import { useHapticFeedback } from 'uniswap/src/features/settings/useHapticFeedback/useHapticFeedback' +import { TransactionModalInnerContainer } from 'uniswap/src/features/transactions/components/TransactionModal/TransactionModal' +import { useTransactionModalContext } from 'uniswap/src/features/transactions/components/TransactionModal/TransactionModalContext' +import { SendReviewDetails } from 'wallet/src/features/transactions/send/SendReviewDetails' + +// We add a short hardcoded delay to allow the sheet to animate quickly both on first render and when going back from Review -> Form. +export function SendReviewScreen(): JSX.Element { + const [hideContent, setHideContent] = useState(true) + useEffect(() => { + setTimeout(() => setHideContent(false), SEND_CONTENT_RENDER_DELAY_MS) + }, []) + + return +} + +function SendReviewScreenContent({ hideContent }: { hideContent: boolean }): JSX.Element { + const { bottomSheetViewStyles, renderBiometricsIcon, onClose, authTrigger } = useTransactionModalContext() + + const { hapticFeedback } = useHapticFeedback() + + // Same logic we apply in `SwapReviewScreen` + // We forcefully hide the content via `hideContent` to allow the bottom sheet to animate faster while still allowing all API requests to trigger ASAP. + // The value of `height + mb` must be equal to the height of the fully rendered component to avoid the modal jumping on open. + if (hideContent) { + return + } + + return ( + + + + ) +} diff --git a/apps/mobile/src/features/send/SendTokenForm.tsx b/apps/mobile/src/features/send/SendTokenForm.tsx new file mode 100644 index 00000000..aed39a37 --- /dev/null +++ b/apps/mobile/src/features/send/SendTokenForm.tsx @@ -0,0 +1,436 @@ +/* oxlint-disable complexity */ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { StyleSheet } from 'react-native' +import { Flex, Text, TouchableArea } from 'ui/src' +import { AlertCircle } from 'ui/src/components/icons' +import { useDeviceDimensions } from 'ui/src/hooks/useDeviceDimensions' +import { iconSizes, spacing } from 'ui/src/theme' +import { CurrencyInputPanel } from 'uniswap/src/components/CurrencyInputPanel/CurrencyInputPanel' +import type { CurrencyInputPanelRef } from 'uniswap/src/components/CurrencyInputPanel/types' +import type { TextInputProps } from 'uniswap/src/components/input/TextInput' +import { getAlertColor } from 'uniswap/src/components/modals/WarningModal/getAlertColor' +import { WarningLabel, WarningSeverity } from 'uniswap/src/components/modals/WarningModal/types' +import { WarningModal } from 'uniswap/src/components/modals/WarningModal/WarningModal' +import { NFTTransfer } from 'uniswap/src/components/nfts/NFTTransfer' +import { MAX_FIAT_INPUT_DECIMALS } from 'uniswap/src/constants/transactions' +import { ModalName } from 'uniswap/src/features/telemetry/constants' +import { + DecimalPadCalculatedSpaceId, + DecimalPadCalculateSpace, + DecimalPadInput, + type DecimalPadInputRef, +} from 'uniswap/src/features/transactions/components/DecimalPadInput/DecimalPadInput' +import { InsufficientNativeTokenWarning } from 'uniswap/src/features/transactions/components/InsufficientNativeTokenWarning/InsufficientNativeTokenWarning' +import { useUSDCValue } from 'uniswap/src/features/transactions/hooks/useUSDCPriceWrapper' +import { useUSDTokenUpdater } from 'uniswap/src/features/transactions/hooks/useUSDTokenUpdater' +import { BlockedAddressWarning } from 'uniswap/src/features/transactions/modals/BlockedAddressWarning' +import { SwapArrowButton } from 'uniswap/src/features/transactions/swap/components/SwapArrowButton' +import { TransactionType } from 'uniswap/src/features/transactions/types/transactionDetails' +import { useIsBlocked } from 'uniswap/src/features/trm/hooks' +import { CurrencyField } from 'uniswap/src/types/currency' +import { dismissNativeKeyboard } from 'utilities/src/device/keyboard/dismissNativeKeyboard' +import { truncateToMaxDecimals } from 'utilities/src/format/truncateToMaxDecimals' +import { isSafeNumber } from 'utilities/src/primitives/integer' +import { RecipientInputPanel } from 'wallet/src/components/input/RecipientInputPanel' +import { useSendContext } from 'wallet/src/features/transactions/contexts/SendContext' +import { EmptyGasFeeRow, GasFeeRow } from 'wallet/src/features/transactions/send/GasFeeRow' +import { useShowSendNetworkNotification } from 'wallet/src/features/transactions/send/hooks/useShowSendNetworkNotification' +import { isAmountGreaterThanZero } from 'wallet/src/features/transactions/utils' +import { useIsBlockedActiveAddress } from 'wallet/src/features/trm/hooks' + +const TRANSFER_DIRECTION_BUTTON_SIZE = iconSizes.icon20 +const TRANSFER_DIRECTION_BUTTON_INNER_PADDING = spacing.spacing12 +const TRANSFER_DIRECTION_BUTTON_BORDER_WIDTH = spacing.spacing4 +const ON_SELECTION_CHANGE_WAIT_TIME_MS = 500 + +export function SendTokenForm(): JSX.Element { + const { t } = useTranslation() + const { fullHeight } = useDeviceDimensions() + + const { updateSendForm, derivedSendInfo, warnings, gasFee } = useSendContext() + + const [currencyFieldFocused, setCurrencyFieldFocused] = useState(true) + const [showWarningModal, setShowWarningModal] = useState(false) + + const { + currencyAmounts, + currencyBalances, + exactAmountToken, + exactAmountFiat, + recipient, + isFiatInput = false, + currencyInInfo, + nftIn, + } = derivedSendInfo + + const currencyIn = currencyInInfo?.currency + + const onFiatAmountUpdated = useCallback( + (amount: string): void => { + updateSendForm({ exactAmountFiat: amount }) + }, + [updateSendForm], + ) + + const onTokenAmountUpdated = useCallback( + (amount: string): void => { + updateSendForm({ exactAmountToken: amount }) + }, + [updateSendForm], + ) + + useShowSendNetworkNotification({ chainId: currencyIn?.chainId }) + + const inputCurrencyUSDValue = useUSDCValue(currencyAmounts[CurrencyField.INPUT]) + + const onShowTokenSelector = useCallback(() => { + updateSendForm({ selectingCurrencyField: CurrencyField.INPUT }) + }, [updateSendForm]) + + const onShowRecipientSelector = useCallback(() => { + updateSendForm({ showRecipientSelector: true }) + }, [updateSendForm]) + + const { isBlocked: isActiveBlocked } = useIsBlockedActiveAddress() + const { isBlocked: isRecipientBlocked } = useIsBlocked(recipient) + const isBlocked = isActiveBlocked || isRecipientBlocked + + const onTransferWarningClick = (): void => { + dismissNativeKeyboard() + setShowWarningModal(true) + } + const transferWarning = warnings.warnings.find((warning) => warning.severity >= WarningSeverity.Low) + const isInsufficientGasFundsWarning = transferWarning?.type === WarningLabel.InsufficientGasFunds + const transferWarningColor = getAlertColor(transferWarning?.severity) + const SendWarningIcon = transferWarning?.icon ?? AlertCircle + + const currencyInputPanelRef = useRef(null) + + const exactAmountTokenRef = useRef(exactAmountToken) + const exactAmountFiatRef = useRef(exactAmountFiat) + const exactValueRef = isFiatInput ? exactAmountFiatRef : exactAmountTokenRef + + const onSetExactAmount = useCallback( + (amount: string) => { + // Omit parsing errors by checking if amount exceeds Number range limit + if (!isSafeNumber(amount)) { + return + } + + amountUpdatedTimeRef.current = Date.now() + + if (isFiatInput) { + exactAmountFiatRef.current = amount + updateSendForm({ exactAmountFiat: amount }) + } else { + exactAmountTokenRef.current = amount + updateSendForm({ exactAmountToken: amount }) + } + }, + [isFiatInput, updateSendForm], + ) + + // Decimal pad logic + const decimalPadRef = useRef(null) + const maxDecimals = isFiatInput ? MAX_FIAT_INPUT_DECIMALS : (currencyIn?.decimals ?? 0) + const selectionRef = useRef(undefined) + const pendingSelectionTimeoutRef = useRef>(undefined) + const amountUpdatedTimeRef = useRef(0) + + useEffect(() => { + return () => clearTimeout(pendingSelectionTimeoutRef.current) + }, []) + + const onInputSelectionChange = useCallback( + (start: number, end: number) => { + if (Date.now() - amountUpdatedTimeRef.current < ON_SELECTION_CHANGE_WAIT_TIME_MS) { + // We only want to trigger this callback when the user is manually moving the cursor, + // but this function is also triggered when the input value is updated, which causes issues on Android. + // We use `amountUpdatedTimeRef` to check if the input value was updated recently, and if so, + // we assume that the user is actually typing and not manually moving the cursor. + return + } + selectionRef.current = { start, end } + decimalPadRef.current?.updateDisabledKeys() + exactAmountTokenRef.current = exactAmountToken + exactAmountFiatRef.current = exactAmountFiat + }, + [exactAmountFiat, exactAmountToken], + ) + + const resetSelection = useCallback( + ({ start, end }: { start: number; end?: number; currencyField?: CurrencyField }) => { + // Update refs first to have the latest selection state available in the DecimalPadInput + // component and properly update disabled keys of the decimal pad. + // We reset the native selection on the next tick because we need to wait for the native input to be updated. + // This is needed because of the combination of state (delayed update) + ref (instant update) to improve performance. + selectionRef.current = { start, end } + const inputFieldRef = currencyInputPanelRef.current?.textInputRef + + if (inputFieldRef) { + // Cancel any pending native selection update to prevent stale cursor positions + // when typing fast (the previous timeout would set the cursor to an outdated position). + clearTimeout(pendingSelectionTimeoutRef.current) + pendingSelectionTimeoutRef.current = setTimeout(() => { + inputFieldRef.current?.setNativeProps({ selection: { start, end } }) + }, 0) + } + }, + [], + ) + + const onSetMax = useCallback( + (amount: string) => { + exactAmountTokenRef.current = amount + updateSendForm({ + exactAmountToken: amount, + isMax: true, + isFiatInput: false, + focusOnCurrencyField: null, + }) + + // We want this update to happen on the next tick, after the input value is updated. + setTimeout(() => { + resetSelection({ + start: exactAmountTokenRef.current.length, + end: exactAmountTokenRef.current.length, + }) + decimalPadRef.current?.updateDisabledKeys() + }, 0) + }, + [resetSelection, updateSendForm], + ) + + const onToggleFiatInput = useCallback(() => { + const newIsFiatInput = !isFiatInput + + exactAmountFiatRef.current = exactAmountFiat + exactAmountTokenRef.current = exactAmountToken + + updateSendForm({ isFiatInput: newIsFiatInput }) + // We want this update to happen on the next tick, after the input value is updated. + setTimeout(() => { + const amount = newIsFiatInput ? exactAmountFiat : exactAmountToken + resetSelection({ + start: amount.length, + end: amount.length, + }) + }, 0) + }, [exactAmountFiat, exactAmountToken, isFiatInput, resetSelection, updateSendForm]) + + useUSDTokenUpdater({ + onFiatAmountUpdated, + onTokenAmountUpdated, + isFiatInput, + exactAmountToken, + exactAmountFiat, + currency: currencyIn ?? undefined, + }) + + const [decimalPadReady, setDecimalPadReady] = useState(false) + + const onDecimalPadReady = useCallback(() => setDecimalPadReady(true), []) + + const onDecimalPadTriggerInputShake = useCallback(() => { + currencyInputPanelRef.current?.triggerShakeAnimation() + }, []) + + const decimalPadSetValue = useCallback( + (value: string): void => { + // We disable the `DecimalPad` when the input reaches the max number of decimals, + // but we still need to truncate in case the user moves the cursor and adds a decimal separator in the middle of the input. + const truncatedValue = truncateToMaxDecimals({ + value, + maxDecimals, + }) + + if (!isSafeNumber(truncatedValue)) { + return + } + + amountUpdatedTimeRef.current = Date.now() + + if (isFiatInput) { + exactAmountFiatRef.current = truncatedValue + } else { + exactAmountTokenRef.current = truncatedValue + } + + updateSendForm({ + exactAmountFiat: isFiatInput ? truncatedValue : undefined, + exactAmountToken: !isFiatInput ? truncatedValue : undefined, + exactCurrencyField: CurrencyField.INPUT, + focusOnCurrencyField: CurrencyField.INPUT, + }) + }, + [isFiatInput, maxDecimals, updateSendForm], + ) + + const fiatOrTokenGreaterThanZero = useMemo((): boolean => { + return isAmountGreaterThanZero({ + exactAmountToken, + exactAmountFiat, + currency: currencyIn, + }) + }, [exactAmountToken, exactAmountFiat, currencyIn]) + + return ( + <> + {transferWarning?.title && !isInsufficientGasFundsWarning && ( + } + isOpen={showWarningModal} + modalName={ModalName.SendWarning} + severity={transferWarning.severity} + title={transferWarning.title} + onClose={(): void => setShowWarningModal(false)} + onAcknowledge={(): void => setShowWarningModal(false)} + /> + )} + + + {nftIn ? ( + + ) : ( + + setCurrencyFieldFocused(true)} + onSelectionChange={onInputSelectionChange} + onSetExactAmount={onSetExactAmount} + onSetPresetValue={onSetMax} + onShowTokenSelector={onShowTokenSelector} + onToggleIsFiatMode={onToggleFiatInput} + /> + + )} + + + + + + + + + + + + {recipient && ( + + )} + {isBlocked ? ( + + ) : null} + + + {!transferWarning && currencyIn?.chainId && !isBlocked && fiatOrTokenGreaterThanZero ? ( + + ) : ( + + )} + + {transferWarning && !isBlocked && !isInsufficientGasFundsWarning ? ( + + + + + {transferWarning.title} + + + + ) : null} + + + + + {!nftIn && ( + <> + + + + + + + )} + + + ) +} diff --git a/apps/mobile/src/features/send/constants.ts b/apps/mobile/src/features/send/constants.ts new file mode 100644 index 00000000..9b20b8e2 --- /dev/null +++ b/apps/mobile/src/features/send/constants.ts @@ -0,0 +1 @@ +export const SEND_CONTENT_RENDER_DELAY_MS = 25 diff --git a/apps/mobile/src/features/sessions/createHashcashWorkerChannel.ts b/apps/mobile/src/features/sessions/createHashcashWorkerChannel.ts new file mode 100644 index 00000000..0f922d3f --- /dev/null +++ b/apps/mobile/src/features/sessions/createHashcashWorkerChannel.ts @@ -0,0 +1,68 @@ +/** + * Native Worker Channel Factory + * + * Creates a HashcashWorkerChannel using the native hashcash Nitro module. + * The native implementation uses platform-specific optimizations: + * - iOS: CommonCrypto for SHA256 + * - Android: java.security.MessageDigest + * + * Both run computations on background threads to avoid blocking the UI. + */ + +import { base64 } from '@scure/base' +import { HashcashNative } from '@universe/hashcash-native' +import type { ProofResult } from '@universe/sessions/src/challenge-solvers/hashcash/core' +import type { + FindProofParams, + HashcashWorkerAPI, + HashcashWorkerChannel, +} from '@universe/sessions/src/challenge-solvers/hashcash/worker/types' + +/** + * Creates a channel to the native hashcash solver. + * + * Unlike web workers or worklets, the native module is a singleton + * that persists throughout the app lifecycle. The terminate() method + * is a no-op since we don't need to clean up the native module. + */ +function createHashcashWorkerChannel(): HashcashWorkerChannel { + const api: HashcashWorkerAPI = { + async findProof(params: FindProofParams): Promise { + const result = await HashcashNative.findProof({ + challenge: { + difficulty: params.challenge.difficulty, + subject: params.challenge.subject, + nonce: params.challenge.nonce, + maxProofLength: params.challenge.max_proof_length, + }, + rangeStart: params.rangeStart, + rangeSize: params.rangeSize, + }) + + if (!result) { + return null + } + + // Convert base64 hash back to Uint8Array for compatibility + return { + counter: result.counter, + hash: base64.decode(result.hashBase64), + attempts: result.attempts, + timeMs: result.timeMs, + } + }, + + async cancel(): Promise { + HashcashNative.cancel() + }, + } + + return { + api, + terminate(): void { + // No-op - native module persists throughout app lifecycle + }, + } +} + +export { createHashcashWorkerChannel } diff --git a/apps/mobile/src/features/settings/hooks/useAdvancedSettingsMenuState.ts b/apps/mobile/src/features/settings/hooks/useAdvancedSettingsMenuState.ts new file mode 100644 index 00000000..72801b4a --- /dev/null +++ b/apps/mobile/src/features/settings/hooks/useAdvancedSettingsMenuState.ts @@ -0,0 +1,97 @@ +import { useNavigation } from '@react-navigation/core' +import { useCallback } from 'react' +import { useDispatch } from 'react-redux' +import { OnboardingStackNavigationProp, SettingsStackNavigationProp } from 'src/app/navigation/types' +import { useEnabledChains } from 'uniswap/src/features/chains/hooks/useEnabledChains' +import { setIsTestnetModeEnabled } from 'uniswap/src/features/settings/slice' +import { ModalName, WalletEventName } from 'uniswap/src/features/telemetry/constants' +import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { MobileScreens } from 'uniswap/src/types/screens/mobile' +import { SmartWalletAdvancedSettingsModalState } from 'wallet/src/components/smartWallet/modals/SmartWalletAdvancedSettingsModal' + +// avoids rendering during animation which makes it laggy +// set to a bit above the Switch animation "simple" which is 80ms +const AVOID_RENDER_DURING_ANIMATION_MS = 100 + +type AdvancedSettingsMenuState = SmartWalletAdvancedSettingsModalState & { + /** Toggle handler that takes no arguments - for use with settings row toggles */ + handleTestnetModeToggle: () => void +} + +type UseAdvancedSettingsMenuStateOptions = { + /** Optional callback to close the modal. If not provided, uses navigation.goBack() */ + onClose?: () => void +} + +/** + * Hook that provides the state and callbacks needed to open and interact with + * the SmartWalletAdvancedSettingsModal. Used by both the Settings screen and + * the TestnetModeBanner navigation. + */ +export function useAdvancedSettingsMenuState(options?: UseAdvancedSettingsMenuStateOptions): AdvancedSettingsMenuState { + const navigation = useNavigation() + const dispatch = useDispatch() + const { isTestnetModeEnabled } = useEnabledChains() + + const handleTestnetModeToggleInternal = useCallback( + (newIsTestnetMode: boolean): void => { + const fireAnalytic = (): void => + sendAnalyticsEvent(WalletEventName.TestnetModeToggled, { + enabled: newIsTestnetMode, + location: 'settings', + }) + + // Close the advanced settings modal first + if (options?.onClose) { + options.onClose() + } else { + navigation.goBack() + } + + setTimeout(() => { + // trigger before toggling on (ie disabling analytics) + if (newIsTestnetMode) { + fireAnalytic() + navigation.navigate(ModalName.TestnetMode, {}) + } + + dispatch(setIsTestnetModeEnabled(newIsTestnetMode)) + + // trigger after toggling off (ie enabling analytics) + if (!newIsTestnetMode) { + fireAnalytic() + } + }, AVOID_RENDER_DURING_ANIMATION_MS) + }, + [dispatch, navigation, options], + ) + + // Handler for modal switch (receives isChecked from switch component) + const onTestnetModeToggled = useCallback( + (isChecked: boolean): void => { + handleTestnetModeToggleInternal(isChecked) + }, + [handleTestnetModeToggleInternal], + ) + + // Handler for settings row toggle (no arguments, toggles current state) + const handleTestnetModeToggle = useCallback((): void => { + handleTestnetModeToggleInternal(!isTestnetModeEnabled) + }, [handleTestnetModeToggleInternal, isTestnetModeEnabled]) + + const onPressSmartWallet = useCallback((): void => { + navigation.navigate(MobileScreens.SettingsSmartWallet) + }, [navigation]) + + const onPressStorage = useCallback((): void => { + navigation.navigate(MobileScreens.SettingsStorage) + }, [navigation]) + + return { + isTestnetEnabled: isTestnetModeEnabled, + onTestnetModeToggled, + onPressSmartWallet, + onPressStorage, + handleTestnetModeToggle, + } +} diff --git a/apps/mobile/src/features/smartWallet/hooks/useOnEnableSmartWallet.tsx b/apps/mobile/src/features/smartWallet/hooks/useOnEnableSmartWallet.tsx new file mode 100644 index 00000000..0056bf29 --- /dev/null +++ b/apps/mobile/src/features/smartWallet/hooks/useOnEnableSmartWallet.tsx @@ -0,0 +1,41 @@ +import { useCallback } from 'react' +import { useDispatch } from 'react-redux' +import { navigate } from 'src/app/navigation/rootNavigation' +import { useBiometricAppSettings } from 'src/features/biometrics/useBiometricAppSettings' +import { useBiometricPrompt } from 'src/features/biometricsSettings/hooks' +import { ModalName } from 'uniswap/src/features/telemetry/constants' +import { useActiveAccount } from 'wallet/src/features/wallet/hooks' +import { setSmartWalletConsent } from 'wallet/src/features/wallet/slice' + +/** + * Hook for enabling smart wallet with biometrics in modals. + * @returns A function that enables smart wallet with biometrics if needed + */ +export function useOnEnableSmartWallet(): () => void { + const dispatch = useDispatch() + const accountAddress = useActiveAccount()?.address + + const { trigger } = useBiometricPrompt() + const { requiredForTransactions: requiresBiometrics } = useBiometricAppSettings() + + if (!accountAddress) { + throw new Error('Account address is required') + } + + const successAction = useCallback(() => { + dispatch(setSmartWalletConsent({ address: accountAddress, smartWalletConsent: true })) + navigate(ModalName.SmartWalletEnabledModal, { + showReconnectDappPrompt: true, + }) + }, [accountAddress, dispatch]) + + return useCallback(async () => { + if (requiresBiometrics) { + await trigger({ + successCallback: successAction, + }) + } else { + successAction() + } + }, [requiresBiometrics, successAction, trigger]) +} diff --git a/apps/mobile/src/features/splashScreen/splashScreenSaga.ts b/apps/mobile/src/features/splashScreen/splashScreenSaga.ts new file mode 100644 index 00000000..e11eac92 --- /dev/null +++ b/apps/mobile/src/features/splashScreen/splashScreenSaga.ts @@ -0,0 +1,35 @@ +import BootSplash from 'react-native-bootsplash' +import { SagaIterator } from 'redux-saga' +import { + dismissSplashScreen, + onSplashScreenHidden, + selectSplashScreenDismissRequested, + selectSplashScreenIsHidden, +} from 'src/features/splashScreen/splashScreenSlice' +import { call, put, select, takeLatest } from 'typed-redux-saga' + +//------------------------------ +// SplashScreen saga +//------------------------------ + +export function* splashScreenSaga(): SagaIterator { + if (yield* select(selectSplashScreenIsHidden)) { + return + } + // if the splash screen was dismissed before the + // saga was started, we need to hide it immediately + if (yield* select(selectSplashScreenDismissRequested)) { + yield* call(hideSplashScreen) + } + // otherwise, we need to wait for the splash screen to be dismissed + // via a dispatch of the dismissSplashScreen action + yield* takeLatest(dismissSplashScreen.type, hideSplashScreen) +} + +function* hideSplashScreen(): SagaIterator { + // ensures smooth fade out + yield* call(async () => new Promise(requestAnimationFrame)) + yield* call(BootSplash.hide, { fade: true }) + // on hide, we need to set the visibility to hidden + yield* put(onSplashScreenHidden()) +} diff --git a/apps/mobile/src/features/splashScreen/splashScreenSlice.ts b/apps/mobile/src/features/splashScreen/splashScreenSlice.ts new file mode 100644 index 00000000..b75c3d92 --- /dev/null +++ b/apps/mobile/src/features/splashScreen/splashScreenSlice.ts @@ -0,0 +1,50 @@ +import { createSlice } from '@reduxjs/toolkit' + +//------------------------------------------------------------------------------------------------ +// Splash Screen +//------------------------------------------------------------------------------------------------ + +enum SplashScreenVisibility { + INIT = 'init', + HIDDEN = 'hidden', +} + +// oxlint-disable-next-line import/no-unused-modules +export interface SplashScreenState { + visibility: SplashScreenVisibility + dismissRequested: boolean +} + +const initialState: SplashScreenState = { + visibility: SplashScreenVisibility.INIT, + dismissRequested: false, +} + +const splashScreenSlice = createSlice({ + name: 'splashScreen', + initialState, + reducers: { + dismissSplashScreen: (state) => { + state.dismissRequested = true + }, + onSplashScreenHidden: (state) => { + state.visibility = SplashScreenVisibility.HIDDEN + }, + }, +}) + +export const { dismissSplashScreen, onSplashScreenHidden } = splashScreenSlice.actions +export const splashScreenReducer = splashScreenSlice.reducer + +//------------------------------ +// Splash Screen selectors +//------------------------------ + +const selectSplashScreen = (state: { splashScreen: SplashScreenState }): SplashScreenState['visibility'] => + state.splashScreen.visibility + +export const selectSplashScreenIsHidden = (state: { splashScreen: SplashScreenState }): boolean => + selectSplashScreen(state) === SplashScreenVisibility.HIDDEN + +export const selectSplashScreenDismissRequested = (state: { splashScreen: SplashScreenState }): boolean => + state.splashScreen.dismissRequested diff --git a/apps/mobile/src/features/splashScreen/useHideSplashScreen.ts b/apps/mobile/src/features/splashScreen/useHideSplashScreen.ts new file mode 100644 index 00000000..1b557c17 --- /dev/null +++ b/apps/mobile/src/features/splashScreen/useHideSplashScreen.ts @@ -0,0 +1,15 @@ +import { useDispatch } from 'react-redux' +import { dismissSplashScreen } from 'src/features/splashScreen/splashScreenSlice' +import { useEvent } from 'utilities/src/react/hooks' + +/** + * Custom wrapped function to hide the splash screen. + * We need this so that we can hide any errors that may occur (e.g. unhandled promise rejection when FaceID is unlocking) + */ +export function useHideSplashScreen(): () => void { + const dispatch = useDispatch() + + return useEvent(() => { + dispatch(dismissSplashScreen()) + }) +} diff --git a/apps/mobile/src/features/statsig/statsigMMKVStorageProvider.ts b/apps/mobile/src/features/statsig/statsigMMKVStorageProvider.ts new file mode 100644 index 00000000..67d76fd3 --- /dev/null +++ b/apps/mobile/src/features/statsig/statsigMMKVStorageProvider.ts @@ -0,0 +1,13 @@ +import { MMKV } from 'react-native-mmkv' + +const mmkv = new MMKV() + +export const statsigMMKVStorageProvider = { + isReady: (): boolean => true, + isReadyResolver: (): null => null, + getProviderName: (): string => 'MMKV', + getAllKeys: (): string[] => mmkv.getAllKeys(), + getItem: (key: string): string | null => mmkv.getString(key) ?? null, + setItem: (key: string, value: string): void => mmkv.set(key, value), + removeItem: (key: string): void => mmkv.delete(key), +} diff --git a/apps/mobile/src/features/telemetry/directLogScreens.ts b/apps/mobile/src/features/telemetry/directLogScreens.ts new file mode 100644 index 00000000..ec46a661 --- /dev/null +++ b/apps/mobile/src/features/telemetry/directLogScreens.ts @@ -0,0 +1,7 @@ +import { MobileScreens } from 'uniswap/src/types/screens/mobile' + +export const DIRECT_LOG_ONLY_SCREENS: string[] = [MobileScreens.TokenDetails, MobileScreens.ExternalProfile] + +export function shouldLogScreen(directFromPage: boolean | undefined, screen: string | undefined): boolean { + return directFromPage || screen === undefined || !DIRECT_LOG_ONLY_SCREENS.includes(screen) +} diff --git a/apps/mobile/src/features/telemetry/saga.ts b/apps/mobile/src/features/telemetry/saga.ts new file mode 100644 index 00000000..3ac823b2 --- /dev/null +++ b/apps/mobile/src/features/telemetry/saga.ts @@ -0,0 +1,51 @@ +import ReactNativeIdfaAaid from '@sparkfabrik/react-native-idfa-aaid' +import { ANONYMOUS_DEVICE_ID, OriginApplication } from '@uniswap/analytics' +import DeviceInfo from 'react-native-device-info' +import { call, delay, fork, select } from 'typed-redux-saga' +import { uniswapUrls } from 'uniswap/src/constants/urls' +import { MobileUserPropertyName } from 'uniswap/src/features/telemetry/user' +import { getUniqueId } from 'utilities/src/device/uniqueId' +import { isTestEnv } from 'utilities/src/environment/env' +import { logger } from 'utilities/src/logger/logger' +import { isAndroid } from 'utilities/src/platform' +// oxlint-disable-next-line no-restricted-imports -- Required for analytics initialization +import { analytics } from 'utilities/src/telemetry/analytics/analytics' +import { ApplicationTransport } from 'utilities/src/telemetry/analytics/ApplicationTransport' +import { selectAllowAnalytics } from 'wallet/src/features/telemetry/selectors' +import { watchTransactionEvents } from 'wallet/src/features/transactions/watcher/transactionFinalizationSaga' + +export function* telemetrySaga() { + if (isTestEnv()) { + logger.debug('telemetry/saga.ts', 'telemetrySaga', 'Skipping Amplitude initialization in test environment') + } else { + yield* delay(1) + + const allowAnalytics = yield* select(selectAllowAnalytics) + + yield* call(analytics.init, { + transportProvider: new ApplicationTransport({ + serverUrl: uniswapUrls.amplitudeProxyUrl, + appOrigin: OriginApplication.MOBILE, + originOverride: uniswapUrls.apiOrigin, + appBuild: DeviceInfo.getBundleId(), + }), + allowed: allowAnalytics, + userIdGetter: getUniqueId, + }) + + if (isAndroid) { + // Only need GAID, not using IDFA + const advertisingInfoResponse = yield* call(ReactNativeIdfaAaid.getAdvertisingInfo) + const adTrackingAllowed = allowAnalytics && !advertisingInfoResponse.isAdTrackingLimited + if (adTrackingAllowed) { + yield* call( + analytics.setUserProperty, + MobileUserPropertyName.AdvertisingId, + advertisingInfoResponse.id ? advertisingInfoResponse.id : ANONYMOUS_DEVICE_ID, + ) + } + } + } + + yield* fork(watchTransactionEvents) +} diff --git a/apps/mobile/src/features/telemetry/utils.ts b/apps/mobile/src/features/telemetry/utils.ts new file mode 100644 index 00000000..1bea1fc6 --- /dev/null +++ b/apps/mobile/src/features/telemetry/utils.ts @@ -0,0 +1,49 @@ +import { RootParamList } from 'src/app/navigation/types' +import { MobileNavScreen, MobileScreens } from 'uniswap/src/types/screens/mobile' + +export enum AuthMethod { + FaceId = 'FaceId', + None = 'None', + TouchId = 'TouchId', + // alphabetize additional values. +} + +export function getAuthMethod({ + isSettingEnabled, + isTouchIdSupported, + isFaceIdSupported, +}: { + isSettingEnabled: boolean + isTouchIdSupported: boolean + isFaceIdSupported: boolean +}): AuthMethod { + if (isSettingEnabled) { + // both cannot be true since no iOS device supports both + if (isFaceIdSupported) { + return AuthMethod.FaceId + } + if (isTouchIdSupported) { + return AuthMethod.TouchId + } + } + + return AuthMethod.None +} + +export function getEventParams( + screen: MobileNavScreen, + params: RootParamList[MobileNavScreen], +): Record | undefined { + switch (screen) { + case MobileScreens.SettingsWallet: + return { + address: (params as RootParamList[MobileScreens.SettingsWallet]).address, + } + case MobileScreens.SettingsWalletEdit: + return { + address: (params as RootParamList[MobileScreens.SettingsWalletEdit]).address, + } + default: + return undefined + } +} diff --git a/apps/mobile/src/features/testnetMode/TestnetSwitchModal.tsx b/apps/mobile/src/features/testnetMode/TestnetSwitchModal.tsx new file mode 100644 index 00000000..82c2341a --- /dev/null +++ b/apps/mobile/src/features/testnetMode/TestnetSwitchModal.tsx @@ -0,0 +1,56 @@ +import React from 'react' +import { useTranslation } from 'react-i18next' +import { useDispatch } from 'react-redux' +import { AppStackScreenProp } from 'src/app/navigation/types' +import { useReactNavigationModal } from 'src/components/modals/useReactNavigationModal' +import { Wrench } from 'ui/src/components/icons' +import { WarningSeverity } from 'uniswap/src/components/modals/WarningModal/types' +import { WarningModal } from 'uniswap/src/components/modals/WarningModal/WarningModal' +import { setIsTestnetModeEnabled } from 'uniswap/src/features/settings/slice' +import { ModalName, WalletEventName } from 'uniswap/src/features/telemetry/constants' +import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' + +export function TestnetSwitchModal({ route }: AppStackScreenProp): JSX.Element { + const { onClose } = useReactNavigationModal() + const dispatch = useDispatch() + const { t } = useTranslation() + + const { switchToMode } = route.params + + const onToggleTestnetMode = (): void => { + onClose() + dispatch(setIsTestnetModeEnabled(switchToMode === 'testnet')) + + sendAnalyticsEvent(WalletEventName.TestnetModeToggled, { + enabled: switchToMode === 'testnet', + location: 'deep_link_modal', + }) + } + + const onReject = (): void => { + onClose() + } + + const toTestnetModeDescription = t('testnet.modal.swapDeepLink.description.toTestnetMode') + const toProdModeDescription = t('testnet.modal.swapDeepLink.description.toProdMode') + + const toTestnetModeTitle = t('testnet.modal.swapDeepLink.title.toTestnetMode') + const toProdModeTitle = t('testnet.modal.swapDeepLink.title.toProdMode') + + return ( + } + // only show if swap form state is provided + modalName={ModalName.TestnetSwitchModal} + severity={WarningSeverity.None} + title={switchToMode === 'production' ? toProdModeTitle : toTestnetModeTitle} + onAcknowledge={onToggleTestnetMode} + onClose={onReject} + onReject={onReject} + /> + ) +} diff --git a/apps/mobile/src/features/testnetMode/TestnetSwitchModalState.ts b/apps/mobile/src/features/testnetMode/TestnetSwitchModalState.ts new file mode 100644 index 00000000..41a6e21f --- /dev/null +++ b/apps/mobile/src/features/testnetMode/TestnetSwitchModalState.ts @@ -0,0 +1,3 @@ +export interface TestnetSwitchModalState { + switchToMode: 'testnet' | 'production' +} diff --git a/apps/mobile/src/features/tweaks/selectors.ts b/apps/mobile/src/features/tweaks/selectors.ts new file mode 100644 index 00000000..2c12bce4 --- /dev/null +++ b/apps/mobile/src/features/tweaks/selectors.ts @@ -0,0 +1,5 @@ +import { TweaksState } from 'src/features/tweaks/slice' +import { CustomEndpoint } from 'uniswap/src/data/links' + +export const selectCustomEndpoint = (state: { tweaks: TweaksState }): CustomEndpoint | undefined => + state.tweaks.customEndpoint diff --git a/apps/mobile/src/features/tweaks/slice.ts b/apps/mobile/src/features/tweaks/slice.ts new file mode 100644 index 00000000..380ee219 --- /dev/null +++ b/apps/mobile/src/features/tweaks/slice.ts @@ -0,0 +1,22 @@ +import { createSlice, type PayloadAction } from '@reduxjs/toolkit' +import { type CustomEndpoint } from 'uniswap/src/data/links' + +export interface TweaksState { + customEndpoint?: CustomEndpoint +} + +export const initialTweaksState: TweaksState = {} + +const slice = createSlice({ + name: 'tweaks', + initialState: initialTweaksState, + reducers: { + setCustomEndpoint: (state, { payload: { customEndpoint } }: PayloadAction<{ customEndpoint?: CustomEndpoint }>) => { + state.customEndpoint = customEndpoint + }, + resetTweaks: () => initialTweaksState, + }, +}) + +export const { setCustomEndpoint, resetTweaks } = slice.actions +export const { reducer: tweaksReducer } = slice diff --git a/apps/mobile/src/features/unitags/ClaimUnitagScreen.tsx b/apps/mobile/src/features/unitags/ClaimUnitagScreen.tsx new file mode 100644 index 00000000..a8b4ffcb --- /dev/null +++ b/apps/mobile/src/features/unitags/ClaimUnitagScreen.tsx @@ -0,0 +1,71 @@ +import { NativeStackScreenProps } from '@react-navigation/native-stack' +import { default as React } from 'react' +import { useTranslation } from 'react-i18next' +import { navigate } from 'src/app/navigation/rootNavigation' +import { SafeKeyboardOnboardingScreen } from 'src/features/onboarding/SafeKeyboardOnboardingScreen' +import { useNavigationHeader } from 'src/utils/useNavigationHeader' +import { Person } from 'ui/src/components/icons' +import { UnitagEventName } from 'uniswap/src/features/telemetry/constants' +import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { ClaimUnitagContent } from 'uniswap/src/features/unitags/ClaimUnitagContent' +import { ImportType, OnboardingEntryPoint } from 'uniswap/src/types/onboarding' +import { + MobileScreens, + OnboardingScreens, + SharedUnitagScreenParams, + UnitagScreens, + UnitagStackParamList, +} from 'uniswap/src/types/screens/mobile' +import { + useCreateOnboardingAccountIfNone, + useOnboardingContext, +} from 'wallet/src/features/onboarding/OnboardingContext' + +type Props = NativeStackScreenProps + +export function ClaimUnitagScreen({ navigation, route }: Props): JSX.Element { + const { entryPoint, address } = route.params + const { t } = useTranslation() + + useCreateOnboardingAccountIfNone() + const { getOnboardingAccountAddress } = useOnboardingContext() + const onboardingAccountAddress = getOnboardingAccountAddress() + + const onNavigateContinue = (params: SharedUnitagScreenParams[UnitagScreens.ChooseProfilePicture]): void => { + navigate(entryPoint === OnboardingScreens.Landing ? MobileScreens.OnboardingStack : MobileScreens.UnitagStack, { + screen: UnitagScreens.ChooseProfilePicture, + params, + }) + } + + const onPressSkip = (): void => { + sendAnalyticsEvent(UnitagEventName.UnitagOnboardingActionTaken, { action: 'later' }) + // Navigate to next screen if in onboarding + navigate(MobileScreens.OnboardingStack, { + screen: OnboardingScreens.Notifications, + params: { + importType: ImportType.CreateNew, + entryPoint: OnboardingEntryPoint.FreshInstallOrReplace, + }, + }) + } + + const showSkipButton = entryPoint === OnboardingScreens.Landing + useNavigationHeader(navigation, showSkipButton ? onPressSkip : undefined) + + const title = + entryPoint === MobileScreens.Home + ? t('unitags.onboarding.claim.title.claim') + : t('unitags.onboarding.claim.title.choose') + + return ( + + + + ) +} diff --git a/apps/mobile/src/features/unitags/EditUnitagProfileScreen.tsx b/apps/mobile/src/features/unitags/EditUnitagProfileScreen.tsx new file mode 100644 index 00000000..1c1f056a --- /dev/null +++ b/apps/mobile/src/features/unitags/EditUnitagProfileScreen.tsx @@ -0,0 +1,119 @@ +import { useNavigation } from '@react-navigation/native' +import React, { useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { StyleSheet } from 'react-native' +import ContextMenu from 'react-native-context-menu-view' +import { KeyboardStickyView } from 'react-native-keyboard-controller' +import { navigate } from 'src/app/navigation/rootNavigation' +import { UnitagStackScreenProp } from 'src/app/navigation/types' +import { BackHeader } from 'src/components/layout/BackHeader' +import { Screen } from 'src/components/layout/Screen' +import { Flex, Text } from 'ui/src' +import { Ellipsis } from 'ui/src/components/icons' +import { useBottomSheetSafeKeyboard } from 'uniswap/src/components/modals/useBottomSheetSafeKeyboard' +import { MobileScreens, UnitagScreens } from 'uniswap/src/types/screens/mobile' +import { dismissNativeKeyboard } from 'utilities/src/device/keyboard/dismissNativeKeyboard' +import { ChangeUnitagModal } from 'wallet/src/features/unitags/ChangeUnitagModal' +import { DeleteUnitagModal } from 'wallet/src/features/unitags/DeleteUnitagModal' +import { EditUnitagProfileContent } from 'wallet/src/features/unitags/EditUnitagProfileContent' + +export function EditUnitagProfileScreen({ route }: UnitagStackScreenProp): JSX.Element { + const { address, unitag, entryPoint } = route.params + const { t } = useTranslation() + const navigation = useNavigation() + const { keyboardHeight } = useBottomSheetSafeKeyboard() + + const [showDeleteUnitagModal, setShowDeleteUnitagModal] = useState(false) + const [showChangeUnitagModal, setShowChangeUnitagModal] = useState(false) + + const onNavigate = (): void => { + navigate(MobileScreens.Home) + } + + const onBack = (): void => { + navigation.goBack() + } + + const onCloseChangeModal = (): void => { + setShowChangeUnitagModal(false) + } + + const onCloseDeleteModal = (): void => { + setShowDeleteUnitagModal(false) + } + + const menuActions = useMemo(() => { + return [ + { title: t('unitags.profile.action.edit'), systemIcon: 'pencil' }, + { title: t('unitags.profile.action.delete'), systemIcon: 'trash', destructive: true }, + ] + }, [t]) + + return ( + + + { + dismissNativeKeyboard() + // Emitted index based on order of menu action array + // Edit username + if (e.nativeEvent.index === 0) { + setShowChangeUnitagModal(true) + } + // Delete username + if (e.nativeEvent.index === 1) { + setShowDeleteUnitagModal(true) + } + }} + > + + + + + ) : undefined + } + p="$spacing16" + onPressBack={ + // If entering from confirmation screen, back btn navigates to home + entryPoint === UnitagScreens.UnitagConfirmation ? (): void => navigate(MobileScreens.Home) : undefined + } + > + {t('settings.setting.wallet.action.editProfile')} + + + + {showDeleteUnitagModal && ( + + )} + {showChangeUnitagModal && ( + + )} + + ) +} + +const styles = StyleSheet.create({ + base: { + flex: 1, + justifyContent: 'flex-end', + }, +}) diff --git a/apps/mobile/src/features/unitags/UnitagChooseProfilePicScreen.tsx b/apps/mobile/src/features/unitags/UnitagChooseProfilePicScreen.tsx new file mode 100644 index 00000000..afc3c2c0 --- /dev/null +++ b/apps/mobile/src/features/unitags/UnitagChooseProfilePicScreen.tsx @@ -0,0 +1,81 @@ +import React from 'react' +import { useTranslation } from 'react-i18next' +import { navigate } from 'src/app/navigation/rootNavigation' +import { UnitagStackScreenProp } from 'src/app/navigation/types' +import { SafeKeyboardOnboardingScreen } from 'src/features/onboarding/SafeKeyboardOnboardingScreen' +import { useNavigationHeader } from 'src/utils/useNavigationHeader' +import { Flex } from 'ui/src' +import { Photo } from 'ui/src/components/icons' +import { UnitagEventName } from 'uniswap/src/features/telemetry/constants' +import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { ImportType, OnboardingEntryPoint } from 'uniswap/src/types/onboarding' +import { MobileScreens, OnboardingScreens, UnitagScreens } from 'uniswap/src/types/screens/mobile' +import { useOnboardingContext } from 'wallet/src/features/onboarding/OnboardingContext' +import { UnitagChooseProfilePicContent } from 'wallet/src/features/unitags/UnitagChooseProfilePicContent' + +export function UnitagChooseProfilePicScreen({ + navigation, + route, +}: UnitagStackScreenProp): JSX.Element { + const { entryPoint, unitag, unitagFontSize, address } = route.params + + const { t } = useTranslation() + const { addUnitagClaim } = useOnboardingContext() + + const handleContinue = async (imageUri: string | undefined): Promise => { + if (entryPoint === OnboardingScreens.Landing) { + addUnitagClaim({ address, username: unitag, avatarUri: imageUri }) + + navigate(MobileScreens.OnboardingStack, { + screen: OnboardingScreens.Notifications, + params: { + importType: ImportType.CreateNew, + entryPoint: OnboardingEntryPoint.FreshInstallOrReplace, + }, + }) + } else { + navigate(MobileScreens.UnitagStack, { + screen: UnitagScreens.UnitagConfirmation, + params: { + unitag, + address, + profilePictureUri: imageUri, + }, + }) + } + } + + const onPressSkip = (): void => { + sendAnalyticsEvent(UnitagEventName.UnitagOnboardingActionTaken, { action: 'later' }) + + navigate(MobileScreens.OnboardingStack, { + screen: OnboardingScreens.Notifications, + params: { + importType: ImportType.CreateNew, + entryPoint: OnboardingEntryPoint.FreshInstallOrReplace, + }, + }) + } + + const showSkipButton = entryPoint === OnboardingScreens.Landing + useNavigationHeader(navigation, showSkipButton ? onPressSkip : undefined) + + return ( + + + + + + ) +} diff --git a/apps/mobile/src/features/unitags/UnitagConfirmationScreen.tsx b/apps/mobile/src/features/unitags/UnitagConfirmationScreen.tsx new file mode 100644 index 00000000..37b374b0 --- /dev/null +++ b/apps/mobile/src/features/unitags/UnitagConfirmationScreen.tsx @@ -0,0 +1,184 @@ +import React, { useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { navigate } from 'src/app/navigation/rootNavigation' +import { UnitagStackScreenProp } from 'src/app/navigation/types' +import { Screen } from 'src/components/layout/Screen' +import { AnimatePresence, Button, Flex, Text } from 'ui/src' +import { AnimateInOrder } from 'ui/src/animations' +import { useDeviceDimensions } from 'ui/src/hooks/useDeviceDimensions' +import { spacing } from 'ui/src/theme' +import { UNITAG_SUFFIX } from 'uniswap/src/features/unitags/constants' +import { useAppInsets } from 'uniswap/src/hooks/useAppInsets' +import { MobileScreens, UnitagScreens } from 'uniswap/src/types/screens/mobile' +import { + EmojiElement, + ENSElement, + FroggyElement, + HeartElement, + OpenseaElement, + ReceiveUSDCElement, + SendElement, + SwapElement, + TextElement, +} from 'wallet/src/components/landing/elements' +import { AnimatedArcCircle } from 'wallet/src/components/landing/shapes/AnimatedArcCircle' +import { UnitagWithProfilePicture } from 'wallet/src/features/unitags/UnitagWithProfilePicture' + +const OUTER_CIRCLE_ARCS = [ + { startAngle: -130, endAngle: -50 }, // Upper arc + { startAngle: 50, endAngle: 125 }, // Lower arc +] + +const INNER_CIRCLE_ARCS = [ + { startAngle: -115, endAngle: -30 }, // Upper arc + { startAngle: 60, endAngle: 170 }, // Lower arc +] + +export function UnitagConfirmationScreen({ + route, +}: UnitagStackScreenProp): JSX.Element { + const { unitag, address, profilePictureUri } = route.params + const dimensions = useDeviceDimensions() + const insets = useAppInsets() + const { t } = useTranslation() + + const boxWidth = dimensions.fullWidth - insets.left - insets.right - spacing.spacing32 + + const onPressCustomize = (): void => { + navigate(MobileScreens.UnitagStack, { + screen: UnitagScreens.EditProfile, + params: { + address, + unitag, + entryPoint: UnitagScreens.UnitagConfirmation, + }, + }) + } + + const onPressDone = (): void => { + navigate(MobileScreens.Home) + } + + const elementsToAnimate = useMemo( + () => [ + { element: , coordinates: { x: 5, y: 0 } }, + { element: , coordinates: { x: 10, y: 2 } }, + { element: , coordinates: { x: 8.2, y: 4 } }, + { element: , coordinates: { x: 9, y: 7 } }, + { element: , coordinates: { x: 10, y: 10 } }, + { element: , coordinates: { x: 1, y: 8.5 } }, + { + element: , + coordinates: { x: 0, y: 5 }, + }, + { element: , coordinates: { x: 1, y: 2 } }, + { element: , coordinates: { x: 3.5, y: 2.5 } }, + ], + [t], + ) + + return ( + + + + + + + + + + + {elementsToAnimate.map(({ element, coordinates }, index) => ( + + {element} + + ))} + + + + + + + + {t('unitags.claim.confirmation.success.long')} + + + {t('unitags.claim.confirmation.description', { + unitagAddress: `${unitag}${UNITAG_SUFFIX}`, + })} + + + + + + + + + + + + + ) +} + +// Calculates top and left insets for absolute positioned element based +// on a 10x10 coordinate system where top left is 0,0. +function getInsetPropsForCoordinates({ boxWidth, x, y }: { boxWidth: number; x: number; y: number }): { + top?: number + right?: number + bottom?: number + left?: number +} { + const unitSize = 10 + const unit = boxWidth / unitSize + + let top + let bottom + let left + let right + + if (x < unitSize / 2) { + left = x * unit + } else if (x > unitSize / 2) { + right = (unitSize - x) * unit + } + + if (y < unitSize / 2) { + top = y * unit + } else if (y > unitSize / 2) { + bottom = (unitSize - y) * unit + } + + return { top, right, bottom, left } +} diff --git a/apps/mobile/src/features/wallet/saga.ts b/apps/mobile/src/features/wallet/saga.ts new file mode 100644 index 00000000..c196918c --- /dev/null +++ b/apps/mobile/src/features/wallet/saga.ts @@ -0,0 +1,31 @@ +import { CommonActions } from '@react-navigation/core' +import { dispatchNavigationAction } from 'src/app/navigation/rootNavigation' +import { call, put, takeEvery } from 'typed-redux-saga' +import { pushNotification } from 'uniswap/src/features/notifications/slice/slice' +import { AppNotificationType } from 'uniswap/src/features/notifications/slice/types' +import i18n from 'uniswap/src/i18n' +import { MobileScreens } from 'uniswap/src/types/screens/mobile' +import { restoreMnemonicComplete } from 'wallet/src/features/wallet/slice' + +/** + * Watch when we've restored a mnemonic (new phone migration) + */ +export function* restoreMnemonicCompleteWatcher() { + yield* takeEvery(restoreMnemonicComplete, onRestoreMnemonicComplete) +} + +function* onRestoreMnemonicComplete() { + yield* put( + pushNotification({ + type: AppNotificationType.Success, + title: i18n.t('notification.restore.success'), + }), + ) + yield* call( + dispatchNavigationAction, + CommonActions.reset({ + index: 0, + routes: [{ name: MobileScreens.Home }], + }), + ) +} diff --git a/apps/mobile/src/features/wallet/useWalletRestore.test.ts b/apps/mobile/src/features/wallet/useWalletRestore.test.ts new file mode 100644 index 00000000..6c5c9e29 --- /dev/null +++ b/apps/mobile/src/features/wallet/useWalletRestore.test.ts @@ -0,0 +1,37 @@ +import { WalletRestoreType } from 'src/components/RestoreWalletModal/RestoreWalletModalState' +import { checkWalletNeedsRestore } from 'src/features/wallet/useWalletRestore' +import { Keyring } from 'wallet/src/features/wallet/Keyring/Keyring' + +jest.mock('wallet/src/features/wallet/Keyring/Keyring', () => ({ + Keyring: { + getAddressesForStoredPrivateKeys: jest.fn(), + getMnemonicIds: jest.fn(), + }, +})) + +describe('checkWalletNeedsRestore', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('returns NoRestoreNeeded type when no mnemonic ID exists since there is no wallet to restore', async () => { + expect(await checkWalletNeedsRestore(undefined, true)).toBe(WalletRestoreType.None) + expect(await checkWalletNeedsRestore(undefined, false)).toBe(WalletRestoreType.None) + }) + + it('returns SeedPhrase type when private keys are present but no seed phrase is available', async () => { + const mnemonicId = '123' + jest.mocked(Keyring.getAddressesForStoredPrivateKeys).mockResolvedValue([mnemonicId]) + jest.mocked(Keyring.getMnemonicIds).mockResolvedValue([]) + expect(await checkWalletNeedsRestore(mnemonicId, true)).toBe(WalletRestoreType.SeedPhrase) + expect(await checkWalletNeedsRestore(mnemonicId, false)).toBe(WalletRestoreType.None) + }) + + it('returns NewDevice type when no private keys or seed phrase is available', async () => { + const mnemonicId = '123' + jest.mocked(Keyring.getAddressesForStoredPrivateKeys).mockResolvedValue([]) + jest.mocked(Keyring.getMnemonicIds).mockResolvedValue([]) + expect(await checkWalletNeedsRestore(mnemonicId, true)).toBe(WalletRestoreType.NewDevice) + expect(await checkWalletNeedsRestore(mnemonicId, false)).toBe(WalletRestoreType.NewDevice) + }) +}) diff --git a/apps/mobile/src/features/wallet/useWalletRestore.ts b/apps/mobile/src/features/wallet/useWalletRestore.ts new file mode 100644 index 00000000..d0e4e621 --- /dev/null +++ b/apps/mobile/src/features/wallet/useWalletRestore.ts @@ -0,0 +1,114 @@ +import { useFocusEffect } from '@react-navigation/core' +import { FeatureFlags, useFeatureFlag } from '@universe/gating' +import { useCallback, useEffect, useRef, useState } from 'react' +import { navigate } from 'src/app/navigation/rootNavigation' +import { WalletRestoreType } from 'src/components/RestoreWalletModal/RestoreWalletModalState' +import { ModalName } from 'uniswap/src/features/telemetry/constants' +import { logger } from 'utilities/src/logger/logger' +import { useSignerAccounts } from 'wallet/src/features/wallet/hooks' +import { Keyring } from 'wallet/src/features/wallet/Keyring/Keyring' + +type Props = { + /** + * If true, this hook will be responsible for opening the restore modal. Otherwise + * the caller is responsible for opening the restore modal. + */ + openModalImmediately?: boolean +} + +/** + * Hook to determine if the wallet needs to be restored and what type of restore is needed. + * If a restore is needed, the relevant modal will be opened. + */ +export function useWalletRestore(params?: Props): { + walletNeedsRestore: boolean + openWalletRestoreModal: () => void + walletRestoreType: WalletRestoreType +} { + const shouldRestoreSeedPhraseFF = useFeatureFlag(FeatureFlags.EnableRestoreSeedPhrase) + const { openModalImmediately } = params ?? {} + const openedOnce = useRef(false) + + const [walletRestoreType, setWalletRestoreType] = useState(WalletRestoreType.None) + const walletNeedsRestore = walletRestoreType !== WalletRestoreType.None + const mnemonicIdFromLocalState = useSignerAccounts()[0]?.mnemonicId + + const openWalletRestoreModal = useCallback((): void => { + switch (walletRestoreType) { + case WalletRestoreType.NewDevice: + navigate(ModalName.RestoreWallet, { restoreType: WalletRestoreType.NewDevice }) + break + case WalletRestoreType.SeedPhrase: + openedOnce.current = true + navigate(ModalName.RestoreWallet, { restoreType: WalletRestoreType.SeedPhrase }) + break + case WalletRestoreType.None: + break + } + }, [walletRestoreType]) + + useEffect(() => { + checkWalletNeedsRestore(mnemonicIdFromLocalState, shouldRestoreSeedPhraseFF) + .then((result) => { + setWalletRestoreType(result) + }) + .catch((error) => logger.error(error, { tags: { file: 'wallet/hooks', function: 'useWalletRestore' } })) + }, [mnemonicIdFromLocalState, shouldRestoreSeedPhraseFF]) + + useFocusEffect( + useCallback(() => { + if (openModalImmediately && walletNeedsRestore && !openedOnce.current) { + openWalletRestoreModal() + } + }, [openModalImmediately, openWalletRestoreModal, walletNeedsRestore]), + ) + + return { walletNeedsRestore, openWalletRestoreModal, walletRestoreType } +} + +/** + * Helper to determine if the wallet needs to be restore and what + * type of restore is needed. + * + * @param mnemonicId - The mnemonic ID to check. This is from our local state but not necessarily in the keyring. + * @param shouldRestoreSeedPhraseFF - Whether the seed phrase restore is enabled + * + * @returns The type of restore needed + * + * @private exported for testing + */ +export const checkWalletNeedsRestore = async ( + mnemonicId: string | undefined, + shouldRestoreSeedPhraseFF: boolean, +): Promise => { + if (!mnemonicId) { + return WalletRestoreType.None + } + const addressesWithPrivateKeys = await Keyring.getAddressesForStoredPrivateKeys() + const mnemonicIdExists = await mnemonicIdExistsInKeyring(mnemonicId) + const walletNeedsRestoreDevice = addressesWithPrivateKeys.length === 0 && !mnemonicIdExists + const walletNeedsRestoreSeedPhrase = + shouldRestoreSeedPhraseFF && addressesWithPrivateKeys.length > 0 && !mnemonicIdExists + + if (walletNeedsRestoreDevice) { + return WalletRestoreType.NewDevice + } else if (walletNeedsRestoreSeedPhrase) { + return WalletRestoreType.SeedPhrase + } else { + return WalletRestoreType.None + } +} + +/** + * Checks if a mnemonic ID exists in the native keyring + * + * @param mnemonicId - The mnemonic ID to check + * @returns True if the mnemonic ID exists in the keyring, false otherwise + */ +const mnemonicIdExistsInKeyring = async (mnemonicId: string | undefined): Promise => { + if (!mnemonicId) { + return false + } + const keyringMnemonicIds = await Keyring.getMnemonicIds() + return keyringMnemonicIds.find((id) => id === mnemonicId) !== undefined +} diff --git a/apps/mobile/src/features/walletConnect/WalletConnect.ts b/apps/mobile/src/features/walletConnect/WalletConnect.ts new file mode 100644 index 00000000..6aaf1e79 --- /dev/null +++ b/apps/mobile/src/features/walletConnect/WalletConnect.ts @@ -0,0 +1,12 @@ +/* oxlint-disable typescript/no-unsafe-return */ +import { NativeModules } from 'react-native' +import { isAndroid } from 'utilities/src/platform' + +const { RNWalletConnect, RedirectToSourceApp } = NativeModules + +export const returnToPreviousApp = async (): Promise => { + if (isAndroid) { + return RedirectToSourceApp.moveAppToBackground() + } + return RNWalletConnect.returnToPreviousApp() +} diff --git a/apps/mobile/src/features/walletConnect/api.ts b/apps/mobile/src/features/walletConnect/api.ts new file mode 100644 index 00000000..28e32454 --- /dev/null +++ b/apps/mobile/src/features/walletConnect/api.ts @@ -0,0 +1,45 @@ +import { getOneSignalPushToken } from 'src/features/notifications/Onesignal' +import { config } from 'uniswap/src/config' +import { isTestEnv } from 'utilities/src/environment/env' +import { logger } from 'utilities/src/logger/logger' +import { isAndroid } from 'utilities/src/platform' + +const WC_HOSTED_PUSH_SERVER_URL = `https://echo.walletconnect.com/${config.walletConnectProjectId}` + +/** + * Registers client and device push token with hosted WalletConnect 2.0 Echo Server. + * The echo server listens to incoming signing requests and delivers push notifications via APNS. + * See https://docs.walletconnect.com/2.0/specs/servers/echo/spec + * + * @param clientId WalletConnect 2.0 clientId + */ +export async function registerWCClientForPushNotifications(clientId: string): Promise { + try { + const pushToken = await getOneSignalPushToken() + if (!pushToken) { + return + } + + // WC requests we use the `fcm` push server for Android and the `apns` server for prod iOS + // and `apns-sandbox` for dev iOS + const pushServer = isAndroid ? 'fcm' : __DEV__ ? 'apns-sandbox' : 'apns' + const request = { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + client_id: clientId, + type: pushServer, + token: pushToken, + }), + } + + await fetch(`${WC_HOSTED_PUSH_SERVER_URL}/clients`, request) + } catch (error) { + // Shouldn't log if this is a jest run to avoid logging after a test completes + if (!isTestEnv()) { + logger.error(error, { + tags: { file: 'walletConnectApi', function: 'registerWCv2ClientForPushNotifications' }, + }) + } + } +} diff --git a/apps/mobile/src/features/walletConnect/batchedTransactionSaga.ts b/apps/mobile/src/features/walletConnect/batchedTransactionSaga.ts new file mode 100644 index 00000000..ae825ac1 --- /dev/null +++ b/apps/mobile/src/features/walletConnect/batchedTransactionSaga.ts @@ -0,0 +1,255 @@ +import { TradingApi } from '@universe/api' +import { FeatureFlags, getFeatureFlag } from '@universe/gating' +import { getInternalError, getSdkError } from '@walletconnect/utils' +import { navigate } from 'src/app/navigation/rootNavigation' +import { wcWeb3Wallet } from 'src/features/walletConnect/walletConnectClient' +import { addRequest, WalletSendCallsRequest } from 'src/features/walletConnect/walletConnectSlice' +import { call, put, select } from 'typed-redux-saga' +import { UNISWAP_DELEGATION_ADDRESS } from 'uniswap/src/constants/addresses' +import { checkWalletDelegation, TradingApiClient } from 'uniswap/src/data/apiClients/tradingApi/TradingApiClient' +import { UniverseChainId } from 'uniswap/src/features/chains/types' +import { Platform } from 'uniswap/src/features/platforms/types/Platform' +import { getEnabledChainIdsSaga } from 'uniswap/src/features/settings/saga' +import { ModalName } from 'uniswap/src/features/telemetry/constants' +import { logger } from 'utilities/src/logger/logger' +import { getCallsStatusHelper } from 'wallet/src/features/batchedTransactions/eip5792Utils' +import { + getCapabilitiesForDelegationStatus, + transformCallsToTransactionRequests, +} from 'wallet/src/features/batchedTransactions/utils' +import { selectHasShownEip5792Nudge } from 'wallet/src/features/behaviorHistory/selectors' +import { setHasShown5792Nudge } from 'wallet/src/features/behaviorHistory/slice' +import { selectHasSmartWalletConsent } from 'wallet/src/features/wallet/selectors' + +/** + * Checks if EIP-5792 methods are enabled via feature flag + * @returns Boolean indicating if EIP-5792 methods are enabled + */ +function isEip5792MethodsEnabled(): boolean { + return getFeatureFlag(FeatureFlags.Eip5792Methods) +} + +/** + * Responds to a WalletConnect session request with an error + * @param topic WalletConnect session topic + * @param requestId ID of the request to respond to + * @param error Error object containing message and code + */ +function* respondWithError({ + topic, + requestId, + error, +}: { + topic: string + requestId: number + error: { message: string; code: number } +}) { + yield* call([wcWeb3Wallet, wcWeb3Wallet.respondSessionRequest], { + topic, + response: { + id: requestId, + jsonrpc: '2.0', + error, + }, + }) +} + +/** + * Handles the WalletConnect request to get the status of a batch of calls + * @param topic WalletConnect session topic + * @param requestId ID of the request + * @param batchId ID of the batch to check status for + * @param accountAddress Address of the account making the request + */ +export function* handleGetCallsStatus({ + topic, + requestId, + batchId, + accountAddress, +}: { + topic: string + requestId: number + batchId: string + accountAddress: string +}) { + const eip5792MethodsEnabled = isEip5792MethodsEnabled() + + if (!eip5792MethodsEnabled) { + yield* respondWithError({ topic, requestId, error: getSdkError('WC_METHOD_UNSUPPORTED') }) + return + } + + const { data, error } = yield* call(getCallsStatusHelper, batchId, accountAddress) + + if (error || !data) { + yield* respondWithError({ topic, requestId, error: getInternalError('MISSING_OR_INVALID', error) }) + return + } + + yield* call([wcWeb3Wallet, wcWeb3Wallet.respondSessionRequest], { + topic, + response: { + id: requestId, + jsonrpc: '2.0', + result: data, + }, + }) +} + +/** + * Handles the WalletConnect request to send a batch of calls + * @param topic WalletConnect session topic + * @param requestId ID of the request + * @param request The WalletSendCallsRequest object containing call data + */ +export function* handleSendCalls({ + topic, + requestId, + request, +}: { + topic: string + requestId: number + request: WalletSendCallsRequest +}) { + const eip5792MethodsEnabled = isEip5792MethodsEnabled() + + if (!eip5792MethodsEnabled) { + yield* respondWithError({ topic, requestId, error: getSdkError('WC_METHOD_UNSUPPORTED') }) + return + } + + try { + const { requestId: encodedRequestId, encoded: encodedTransaction } = yield* call( + TradingApiClient.fetchWalletEncoding7702, + { + calls: transformCallsToTransactionRequests({ + calls: request.calls, + chainId: request.chainId, + accountAddress: request.account, + }), + smartContractDelegationAddress: UNISWAP_DELEGATION_ADDRESS, + walletAddress: request.account, + }, + ) + + const requestWithEncodedTransaction = { + ...request, + encodedRequestId, + encodedTransaction, + } + + yield* put(addRequest(requestWithEncodedTransaction)) + } catch (error) { + logger.error(error, { + tags: { file: 'batchTransactionSaga', function: 'handleSendCalls' }, + }) + yield* respondWithError({ topic, requestId, error: getSdkError('USER_REJECTED') }) + } +} + +/** + * Handles the WalletConnect request to get capabilities for an account + * @param topic WalletConnect session topic + * @param requestId ID of the request + * @param accountAddress Address of the connected account + * @param requestedAccount Address of the account capabilities are being requested for + * @param chainIdsFromRequest Chain IDs from the request + * @param dappName Name of the dapp + * @param dappIconUrl Icon URL of the dapp + */ +export function* handleGetCapabilities({ + topic, + requestId, + accountAddress, + requestedAccount, + chainIdsFromRequest, + dappUrl, + dappName, + dappIconUrl, +}: { + topic: string + requestId: number + accountAddress: string + requestedAccount: string + chainIdsFromRequest?: UniverseChainId[] + dappUrl: string + dappName?: string + dappIconUrl?: string +}) { + const eip5792MethodsEnabled = isEip5792MethodsEnabled() + + if (!eip5792MethodsEnabled) { + yield* respondWithError({ topic, requestId, error: getSdkError('WC_METHOD_UNSUPPORTED') }) + return + } + + if (requestedAccount.toLowerCase() !== accountAddress.toLowerCase()) { + yield* respondWithError({ topic, requestId, error: getSdkError('UNAUTHORIZED_METHOD') }) + return + } + + const hasSmartWalletConsent = yield* select(selectHasSmartWalletConsent, accountAddress) + const hasShownNudge = yield* select(selectHasShownEip5792Nudge, accountAddress, dappUrl) + + const { chains: enabledChains } = yield* call(getEnabledChainIdsSaga, Platform.EVM) + + const chainIds = (chainIdsFromRequest ?? enabledChains).map((chainId) => chainId.valueOf()) + + let delegationStatusResponse: TradingApi.WalletCheckDelegationResponseBody | undefined + + let hasNoExistingDelegations = true + + try { + delegationStatusResponse = yield* call(checkWalletDelegation, { + walletAddresses: [accountAddress], + chainIds: chainIds.map((chain) => chain.valueOf()), + }) + + const detailsMap = delegationStatusResponse.delegationDetails[accountAddress] + + if (detailsMap) { + const hasAtLeastOneDelegation = Object.values(detailsMap).some( + (details) => !!details.currentDelegationAddress && !details.isWalletDelegatedToUniswap, + ) + + hasNoExistingDelegations = !hasAtLeastOneDelegation + } + } catch (error) { + logger.error(error, { + tags: { file: 'dappRequestSaga', function: 'handleGetCapabilities' }, + extra: { accountAddress }, + }) + } + + if (!hasSmartWalletConsent && !hasShownNudge && hasNoExistingDelegations) { + // Update the state to mark that we've shown the nudge + yield* put( + setHasShown5792Nudge({ + walletAddress: accountAddress, + dappUrl, + }), + ) + + yield* call(navigate, ModalName.SmartWalletNudge, { + dappInfo: { + icon: dappIconUrl, + name: dappName, + }, + }) + } + + const capabilities = yield* call( + getCapabilitiesForDelegationStatus, + delegationStatusResponse?.delegationDetails[accountAddress], + hasSmartWalletConsent, + ) + + yield* call([wcWeb3Wallet, wcWeb3Wallet.respondSessionRequest], { + topic, + response: { + id: requestId, + jsonrpc: '2.0', + result: capabilities, + }, + }) +} diff --git a/apps/mobile/src/features/walletConnect/fetchDappDetails.ts b/apps/mobile/src/features/walletConnect/fetchDappDetails.ts new file mode 100644 index 00000000..1e8faf08 --- /dev/null +++ b/apps/mobile/src/features/walletConnect/fetchDappDetails.ts @@ -0,0 +1,24 @@ +import { WalletConnectState } from 'src/features/walletConnect/walletConnectSlice' +import { logger } from 'utilities/src/logger/logger' + +export function fetchDappDetails( + topic: string, + currentState: Readonly, +): { dappIcon: string | null; dappName: string } { + try { + const sessions = currentState.sessions + + if (sessions[topic]) { + const wcSession = sessions[topic] + return { + dappIcon: wcSession.dappRequestInfo.icon || null, + dappName: wcSession.dappRequestInfo.name || '', + } + } + } catch (error) { + const message = error instanceof Error ? error.message : 'Error retrieving session data' + logger.warn('walletConnect/saga.ts', 'createDappNotification', message) + } + + return { dappIcon: null, dappName: '' } +} diff --git a/apps/mobile/src/features/walletConnect/saga.test.ts b/apps/mobile/src/features/walletConnect/saga.test.ts new file mode 100644 index 00000000..7fc5fc13 --- /dev/null +++ b/apps/mobile/src/features/walletConnect/saga.test.ts @@ -0,0 +1,399 @@ +import { WalletKitTypes } from '@reown/walletkit' +import { ProposalTypes, Verify } from '@walletconnect/types' +import { buildApprovedNamespaces, populateAuthPayload } from '@walletconnect/utils' +import { expectSaga } from 'redux-saga-test-plan' +import { handleSessionAuthenticate, handleSessionProposal } from 'src/features/walletConnect/saga' +import { parseVerifyStatus } from 'src/features/walletConnect/utils' +import { wcWeb3Wallet } from 'src/features/walletConnect/walletConnectClient' +import { addPendingSession } from 'src/features/walletConnect/walletConnectSlice' +import { UniverseChainId } from 'uniswap/src/features/chains/types' +import { DappRequestInfo, DappRequestType, EthEvent } from 'uniswap/src/types/walletConnect' +import { DappVerificationStatus } from 'wallet/src/features/dappRequests/types' +import { selectActiveAccountAddress } from 'wallet/src/features/wallet/selectors' + +// Mock for WalletConnect utils +jest.mock('@walletconnect/utils', () => ({ + ...jest.requireActual('@walletconnect/utils'), + buildApprovedNamespaces: jest.fn(), + getSdkError: jest.fn(() => 'mocked-error'), + populateAuthPayload: jest.fn(), + parseVerifyStatus: jest.fn(), +})) + +// Mock dependencies +jest.mock('./walletConnectClient', () => ({ + wcWeb3Wallet: { + rejectSession: jest.fn(), + formatAuthMessage: jest.fn(), + }, +})) + +jest.mock('react-native', () => ({ + Alert: { + alert: jest.fn(), + }, +})) + +// Mock i18n +jest.mock('uniswap/src/i18n', () => ({ + t: jest.fn((key) => key), +})) + +// Mock parseVerifyStatus from utils +jest.mock('src/features/walletConnect/utils', () => ({ + ...jest.requireActual('src/features/walletConnect/utils'), + parseVerifyStatus: jest.fn(), +})) + +describe('WalletConnect Saga', () => { + describe('handleSessionProposal', () => { + it('uses verifyContext.verified.origin as URL when available', () => { + // Create a mock verification context with origin URL different from dapp URL + const mockVerifyContext: Verify.Context = { + verified: { + verifyUrl: 'https://verify.walletconnect.com', + validation: 'VALID', + origin: 'https://verified-origin.com', // Different from dapp.url + }, + } + + // Create a valid proposal with eip155 namespace + const mockProposal = { + id: 789, + proposer: { + publicKey: 'test-public-key', + metadata: { + name: 'Test Dapp', + description: 'Test Dapp Description', + url: 'https://original-dapp.com', // This should NOT be used when verified origin is available + icons: ['https://original-dapp.com/icon.png'], + }, + }, + relays: [], + optionalNamespaces: { + eip155: { + chains: ['eip155:1'], + methods: ['eth_signTransaction'], + events: [], + }, + }, + pairingTopic: 'test-pairing-topic', + expiryTimestamp: Date.now() + 1000 * 60 * 5, + verifyContext: mockVerifyContext, + } as unknown as ProposalTypes.Struct & { verifyContext?: Verify.Context } + + const activeAccountAddress = '0x1234567890abcdef' + + // Mock namespaces that would be returned by buildApprovedNamespaces + const mockNamespaces = { + eip155: { + accounts: [`eip155:1:${activeAccountAddress}`], + chains: ['eip155:1'], + methods: ['eth_signTransaction'], + events: [EthEvent.AccountsChanged, EthEvent.ChainChanged], + }, + } + + // Mock the buildApprovedNamespaces function to return our mock namespaces + const buildApprovedNamespacesMock = buildApprovedNamespaces as jest.Mock + buildApprovedNamespacesMock.mockReturnValue(mockNamespaces) + + // Mock parseVerifyStatus to return VERIFIED for this test + const parseVerifyStatusMock = parseVerifyStatus as jest.Mock + parseVerifyStatusMock.mockReturnValue('VERIFIED') + + // Create properly typed dappRequestInfo + const dappRequestInfo: DappRequestInfo = { + name: 'Test Dapp', + url: 'https://verified-origin.com', // Should use verified origin, NOT dapp.url + icon: 'https://original-dapp.com/icon.png', + requestType: DappRequestType.WalletConnectSessionRequest, + } + + const expectedPendingSession = { + wcSession: { + id: '789', + proposalNamespaces: mockNamespaces, + chains: [UniverseChainId.Mainnet], + dappRequestInfo, + verifyStatus: DappVerificationStatus.Verified, + }, + } + + return expectSaga(handleSessionProposal, mockProposal) + .provide({ + select({ selector }, next) { + if (selector === selectActiveAccountAddress) { + return activeAccountAddress + } + return next() + }, + }) + .withState({ + userSettings: {}, + wallet: { + accounts: { + [activeAccountAddress]: { address: activeAccountAddress }, + }, + }, + }) + .put(addPendingSession(expectedPendingSession)) + .run() + }) + + it('dispatches addPendingSession with correct parameters for valid proposal', () => { + // Create a mock verification context for the verified status + const mockVerifyContext: Verify.Context = { + verified: { + verifyUrl: 'https://verify.walletconnect.com', + validation: 'VALID', + origin: 'https://valid-dapp.com', + }, + } + + // Create a valid proposal with eip155 namespace + const mockProposal = { + id: 456, + proposer: { + publicKey: 'test-public-key', + metadata: { + name: 'Valid Dapp', + description: 'Valid Dapp Description', + url: 'https://valid-dapp.com', + icons: ['https://valid-dapp.com/icon.png'], + }, + }, + relays: [], + optionalNamespaces: { + eip155: { + chains: ['eip155:1'], + methods: ['eth_signTransaction'], + events: [], + }, + }, + pairingTopic: 'valid-pairing-topic', + expiryTimestamp: Date.now() + 1000 * 60 * 5, + verifyContext: mockVerifyContext, + } as unknown as ProposalTypes.Struct & { verifyContext?: Verify.Context } + + const activeAccountAddress = '0x1234567890abcdef' + + // Mock namespaces that would be returned by buildApprovedNamespaces + const mockNamespaces = { + eip155: { + accounts: [`eip155:1:${activeAccountAddress}`], + chains: ['eip155:1'], + methods: ['eth_signTransaction'], + events: [EthEvent.AccountsChanged, EthEvent.ChainChanged], + }, + } + + // Mock the buildApprovedNamespaces function to return our mock namespaces + const buildApprovedNamespacesMock = buildApprovedNamespaces as jest.Mock + buildApprovedNamespacesMock.mockReturnValue(mockNamespaces) + + // Mock parseVerifyStatus to return VERIFIED for this test + const parseVerifyStatusMock = parseVerifyStatus as jest.Mock + parseVerifyStatusMock.mockReturnValue('VERIFIED') + + // Create properly typed dappRequestInfo + const dappRequestInfo: DappRequestInfo = { + name: 'Valid Dapp', + url: 'https://valid-dapp.com', + icon: 'https://valid-dapp.com/icon.png', + requestType: DappRequestType.WalletConnectSessionRequest, + } + + const expectedPendingSession = { + wcSession: { + id: '456', + proposalNamespaces: mockNamespaces, + chains: [UniverseChainId.Mainnet], + dappRequestInfo, + verifyStatus: DappVerificationStatus.Verified, + }, + } + + return expectSaga(handleSessionProposal, mockProposal) + .provide({ + select({ selector }, next) { + if (selector === selectActiveAccountAddress) { + return activeAccountAddress + } + // For any other selectors that might access wallet state + return next() + }, + }) + .withState({ + userSettings: {}, + wallet: { + accounts: { + [activeAccountAddress]: { address: activeAccountAddress }, + }, + }, + }) + .put(addPendingSession(expectedPendingSession)) + .run() + }) + + it('falls back to dapp.url when verifyContext.verified.origin is not available', () => { + // Create a proposal WITHOUT verifyContext.verified.origin + const mockProposal = { + id: 999, + proposer: { + publicKey: 'test-public-key', + metadata: { + name: 'Fallback Dapp', + description: 'Fallback Dapp Description', + url: 'https://fallback-dapp.com', + icons: ['https://fallback-dapp.com/icon.png'], + }, + }, + relays: [], + optionalNamespaces: { + eip155: { + chains: ['eip155:1'], + methods: ['eth_signTransaction'], + events: [], + }, + }, + pairingTopic: 'fallback-pairing-topic', + expiryTimestamp: Date.now() + 1000 * 60 * 5, + // No verifyContext provided + } as unknown as ProposalTypes.Struct & { verifyContext?: Verify.Context } + + const activeAccountAddress = '0x1234567890abcdef' + + // Mock namespaces that would be returned by buildApprovedNamespaces + const mockNamespaces = { + eip155: { + accounts: [`eip155:1:${activeAccountAddress}`], + chains: ['eip155:1'], + methods: ['eth_signTransaction'], + events: [EthEvent.AccountsChanged, EthEvent.ChainChanged], + }, + } + + // Mock the buildApprovedNamespaces function to return our mock namespaces + const buildApprovedNamespacesMock = buildApprovedNamespaces as jest.Mock + buildApprovedNamespacesMock.mockReturnValue(mockNamespaces) + + // Mock parseVerifyStatus to return UNVERIFIED when no verifyContext + const parseVerifyStatusMock = parseVerifyStatus as jest.Mock + parseVerifyStatusMock.mockReturnValue('UNVERIFIED') + + // Create properly typed dappRequestInfo - should use dapp.url as fallback + const dappRequestInfo: DappRequestInfo = { + name: 'Fallback Dapp', + url: 'https://fallback-dapp.com', // Should fallback to dapp.url + icon: 'https://fallback-dapp.com/icon.png', + requestType: DappRequestType.WalletConnectSessionRequest, + } + + const expectedPendingSession = { + wcSession: { + id: '999', + proposalNamespaces: mockNamespaces, + chains: [UniverseChainId.Mainnet], + dappRequestInfo, + verifyStatus: DappVerificationStatus.Unverified, + }, + } + + return expectSaga(handleSessionProposal, mockProposal) + .provide({ + select({ selector }, next) { + if (selector === selectActiveAccountAddress) { + return activeAccountAddress + } + return next() + }, + }) + .withState({ + userSettings: {}, + wallet: { + accounts: { + [activeAccountAddress]: { address: activeAccountAddress }, + }, + }, + }) + .put(addPendingSession(expectedPendingSession)) + .run() + }) + }) + + describe('handleSessionAuthenticate', () => { + it('processes authentication request', async () => { + // Create a mock authentication request + const mockAuthenticate = { + id: 789, + params: { + requester: { + metadata: { + name: 'Auth Dapp', + description: 'Auth Dapp Description', + url: 'https://auth-dapp.com', + icons: ['https://auth-dapp.com/icon.png'], + }, + }, + authPayload: { + chains: ['eip155:1', 'eip155:137', 'solana:1'], // Include both supported and unsupported chains + domain: 'auth-dapp.com', + aud: 'https://auth-dapp.com/login', + nonce: '1234567890', + type: 'eip4361', + }, + }, + } as unknown as WalletKitTypes.SessionAuthenticate + + // User's active account + const activeAccountAddress = '0x1234567890abcdef' + + // Expected formatted chains after filtering non-eip155 chains + const formattedEip155Chains = ['eip155:1', 'eip155:137'] + + // Mock populated auth payload with version and iat to satisfy type requirements + const mockPopulatedAuthPayload = { + chains: formattedEip155Chains, + domain: 'auth-dapp.com', + aud: 'https://auth-dapp.com/login', + nonce: '1234567890', + type: 'eip4361', + methods: ['eth_signTransaction', 'eth_sign', 'personal_sign'], + version: '1', + iat: '2023-01-01T00:00:00Z', + } + + // Set up mock for populateAuthPayload + const populateAuthPayloadMock = populateAuthPayload as jest.Mock + populateAuthPayloadMock.mockReturnValue(mockPopulatedAuthPayload) + + // Mock auth message + const mockAuthMessage = 'SIWE Message: Auth request from auth-dapp.com (nonce: 1234567890)' + + // Mock formatAuthMessage + wcWeb3Wallet.formatAuthMessage = jest.fn().mockReturnValue(mockAuthMessage) + + // Run the saga and verify action is dispatched + await expectSaga(handleSessionAuthenticate, mockAuthenticate) + .provide({ + select({ selector }, next) { + if (selector === selectActiveAccountAddress) { + return activeAccountAddress + } + return next() + }, + }) + .withState({ + userSettings: {}, + wallet: { + accounts: { + [activeAccountAddress]: { address: activeAccountAddress }, + }, + }, + }) + .put.actionType('walletConnect/addRequest') + .run() + }) + }) +}) diff --git a/apps/mobile/src/features/walletConnect/saga.ts b/apps/mobile/src/features/walletConnect/saga.ts new file mode 100644 index 00000000..9ae5c263 --- /dev/null +++ b/apps/mobile/src/features/walletConnect/saga.ts @@ -0,0 +1,631 @@ +/* oxlint-disable max-lines */ +import { AnyAction } from '@reduxjs/toolkit' +import { WalletKitTypes } from '@reown/walletkit' +import { FeatureFlags, getFeatureFlag } from '@universe/gating' +import { PendingRequestTypes, ProposalTypes, SessionTypes, Verify } from '@walletconnect/types' +import { buildApprovedNamespaces, getSdkError, populateAuthPayload } from '@walletconnect/utils' +import { Alert } from 'react-native' +import { EventChannel, eventChannel } from 'redux-saga' +import { MobileState } from 'src/app/mobileReducer' +import { + handleGetCallsStatus, + handleGetCapabilities, + handleSendCalls, +} from 'src/features/walletConnect/batchedTransactionSaga' +import { fetchDappDetails } from 'src/features/walletConnect/fetchDappDetails' +import { selectAllSessions } from 'src/features/walletConnect/selectors' +import { + getAccountAddressFromEIP155String, + getChainIdFromEIP155String, + getSupportedWalletConnectChains, + parseGetCallsStatusRequest, + parseGetCapabilitiesRequest, + parseSendCallsRequest, + parseSignRequest, + parseTransactionRequest, + parseVerifyStatus, +} from 'src/features/walletConnect/utils' +import { initializeWeb3Wallet, wcWeb3Wallet } from 'src/features/walletConnect/walletConnectClient' +import { + addPendingSession, + addRequest, + addSession, + removeSession, + replaceSession, + SignRequest, + setHasPendingSessionError, +} from 'src/features/walletConnect/walletConnectSlice' +import { call, fork, put, select, take } from 'typed-redux-saga' +import { UniverseChainId } from 'uniswap/src/features/chains/types' +import { getChainLabel } from 'uniswap/src/features/chains/utils' +import { EthMethod } from 'uniswap/src/features/dappRequests/types' +import { isSelfCallWithData } from 'uniswap/src/features/dappRequests/utils' +import { pushNotification } from 'uniswap/src/features/notifications/slice/slice' +import { AppNotificationType } from 'uniswap/src/features/notifications/slice/types' +import { Platform } from 'uniswap/src/features/platforms/types/Platform' +import { getEnabledChainIdsSaga } from 'uniswap/src/features/settings/saga' +import i18n from 'uniswap/src/i18n' +import { DappRequestType, EthEvent, WalletConnectEvent } from 'uniswap/src/types/walletConnect' +import { areAddressesEqual } from 'uniswap/src/utils/addresses' +import { logger } from 'utilities/src/logger/logger' +import { ONE_SECOND_MS } from 'utilities/src/time/time' +import { + selectAccounts, + selectActiveAccountAddress, + selectSignerMnemonicAccounts, +} from 'wallet/src/features/wallet/selectors' +import { setAccountAsActive } from 'wallet/src/features/wallet/slice' + +const WC_SUPPORTED_METHODS = [ + EthMethod.EthSign, + EthMethod.EthSendTransaction, + EthMethod.PersonalSign, + EthMethod.SignTypedData, + EthMethod.SignTypedDataV4, + EthMethod.WalletGetCapabilities, + EthMethod.WalletSendCalls, + EthMethod.WalletGetCallsStatus, +] + +function createWalletConnectChannel(): EventChannel { + return eventChannel((emit) => { + /* + * Handle incoming `session_proposal` events that contain the dapp attempting to pair + * and the proposal namespaces (chains, methods, events) + */ + const sessionProposalHandler = async (proposalEvent: WalletKitTypes.SessionProposal): Promise => { + const { params: proposal, verifyContext } = proposalEvent + emit({ type: 'session_proposal', proposal: { ...proposal, verifyContext } }) + } + + const sessionRequestHandler = async (request: WalletKitTypes.SessionRequest): Promise => { + emit({ type: 'session_request', request }) + } + + const sessionDeleteHandler = async (session: WalletKitTypes.SessionDelete): Promise => { + emit({ type: 'session_delete', session }) + } + + const sessionAuthenticateHandler = async (authenticate: WalletKitTypes.SessionAuthenticate): Promise => { + emit({ type: 'session_authenticate', authenticate }) + } + + wcWeb3Wallet.on('session_proposal', sessionProposalHandler) + wcWeb3Wallet.on('session_request', sessionRequestHandler) + wcWeb3Wallet.on('session_delete', sessionDeleteHandler) + wcWeb3Wallet.on('session_authenticate', sessionAuthenticateHandler) + + const unsubscribe = (): void => { + wcWeb3Wallet.off('session_proposal', sessionProposalHandler) + wcWeb3Wallet.off('session_request', sessionRequestHandler) + wcWeb3Wallet.off('session_delete', sessionDeleteHandler) + wcWeb3Wallet.off('session_authenticate', sessionAuthenticateHandler) + } + + return unsubscribe + }) +} + +function* watchWalletConnectEvents() { + const wcChannel = yield* call(createWalletConnectChannel) + + while (true) { + try { + const event = yield* take(wcChannel) + if (event.type === 'session_proposal') { + yield* call(handleSessionProposal, event['proposal']) + } else if (event.type === 'session_request') { + yield* call(handleSessionRequest, event['request']) + } else if (event.type === 'session_authenticate') { + yield* call(handleSessionAuthenticate, event['authenticate']) + } else if (event.type === 'session_delete') { + yield* call(handleSessionDelete, event['session']) + } + } catch (error) { + logger.error(error, { + tags: { file: 'walletConnect/saga', function: 'watchWalletConnectEvents' }, + }) + } + } +} + +function showAlert(title: string, message: string): Promise { + return new Promise((resolve) => { + Alert.alert(title, message, [ + { + text: 'OK', + onPress: () => resolve(true), + }, + ]) + }) +} + +function* cancelErrorSession({ + dappName, + chainLabels, + proposalId, +}: { + dappName: string + chainLabels: string + proposalId: number +}) { + yield* call([wcWeb3Wallet, wcWeb3Wallet.rejectSession], { + id: proposalId, + reason: getSdkError('UNSUPPORTED_CHAINS'), + }) + + yield* call( + showAlert, + i18n.t('walletConnect.error.connection.title'), + i18n.t('walletConnect.error.connection.message', { + chainNames: chainLabels, + dappName, + }), + ) + + // Set error state to cancel loading state in WalletConnectModal UI + yield* put(setHasPendingSessionError(true)) + + // Allow users to rescan again + yield* put(setHasPendingSessionError(false)) +} + +export function* handleSessionProposal(proposal: ProposalTypes.Struct & { verifyContext?: Verify.Context }) { + const { + id, + proposer: { metadata: dapp }, + } = proposal + + const { chains: enabledChainIds } = yield* call(getEnabledChainIdsSaga, Platform.EVM) + + const namespaceCheck = proposal.optionalNamespaces + const firstNamespace = Object.keys(namespaceCheck)[0] + + if (firstNamespace && firstNamespace !== 'eip155') { + const chainLabels = enabledChainIds.map(getChainLabel).join(', ') + yield* cancelErrorSession({ dappName: dapp.name, chainLabels, proposalId: proposal.id }) + return + } + + const activeSignerAccounts = yield* select(selectSignerMnemonicAccounts) + const activeSignerAccountAddresses = activeSignerAccounts.map((account) => account.address) + + try { + const supportedEip155Chains = enabledChainIds.map((chainId) => `eip155:${chainId}`) + + const accounts = supportedEip155Chains.flatMap((chain) => + activeSignerAccountAddresses.map((account) => `${chain}:${account}`), + ) + + const namespaces = buildApprovedNamespaces({ + proposal, + supportedNamespaces: { + eip155: { + chains: supportedEip155Chains, + methods: WC_SUPPORTED_METHODS, + events: [EthEvent.AccountsChanged, EthEvent.ChainChanged], + accounts, + }, + }, + }) + + // If the dapp only requested unsupported chains in optionalNamespaces (with no requiredNamespaces), + // buildApprovedNamespaces returns empty namespaces. Reject and show an error. + if (Object.keys(namespaces).length === 0) { + throw new Error('Dapp requested only unsupported chains') + } + + // Extract chains from approved namespaces to show in UI for pending session + const proposalChainIds: UniverseChainId[] = [] + Object.entries(namespaces).forEach(([key, namespace]) => { + const { chains } = namespace + // EVM chain(s) are specified in either `eip155:CHAIN` or chains array + const eip155Chains = key.includes(':') ? [key] : chains + proposalChainIds.push(...(getSupportedWalletConnectChains(eip155Chains) ?? [])) + }) + + const verifyStatus = parseVerifyStatus(proposal.verifyContext) + + yield* put( + addPendingSession({ + wcSession: { + id: id.toString(), + proposalNamespaces: namespaces, + chains: proposalChainIds, + verifyStatus, + dappRequestInfo: { + name: dapp.name, + url: proposal.verifyContext?.verified.origin ?? dapp.url, + icon: dapp.icons[0] ?? null, + requestType: DappRequestType.WalletConnectSessionRequest, + }, + }, + }), + ) + } catch (e) { + const chainLabels = enabledChainIds.map(getChainLabel).join(', ') + + yield* cancelErrorSession({ dappName: dapp.name, chainLabels, proposalId: proposal.id }) + + logger.debug( + 'WalletConnectSaga', + 'sessionProposalHandler', + 'Rejected session proposal due to invalid proposal namespaces: ', + e, + ) + } +} + +function getAccountAddressFromWCSession(requestSession: SessionTypes.Struct) { + const namespaces = Object.values(requestSession.namespaces) + const eip155Account = namespaces[0]?.accounts[0] + return eip155Account ? getAccountAddressFromEIP155String(eip155Account) : undefined +} + +const eip5792Methods = [EthMethod.WalletGetCallsStatus, EthMethod.WalletSendCalls, EthMethod.WalletGetCapabilities].map( + (m) => m.valueOf(), +) + +/** + * Handles WalletConnect authentication requests, which are used for one-click sign in + * via WalletConnect's implementation of SIWE and ReCaps. + * + * @see https://docs.reown.com/walletkit/android/one-click-auth + * + * We only sign and broadcast a single signature for the first chain — the minimum required for a valid authenticated session. + * If a dapp wants to authenticate across multiple chains, it must request additional signatures separately. + * This tradeoff simplifies the user experience and remains aligned with the WalletConnect specification. + */ +export function* handleSessionAuthenticate(authenticate: WalletKitTypes.SessionAuthenticate) { + const { chains: enabledChainIds } = yield* call(getEnabledChainIdsSaga, Platform.EVM) + + // Filter non wallet supported chains from auth payload, in eip155 format + const formattedEip155Chains = authenticate.params.authPayload.chains.filter((chain) => + enabledChainIds.some((id) => chain === `eip155:${id}`), + ) + + const authPayload = populateAuthPayload({ + authPayload: authenticate.params.authPayload, + chains: formattedEip155Chains, + methods: WC_SUPPORTED_METHODS, + }) + + const activeAccountAddress = yield* select(selectActiveAccountAddress) + + if (!activeAccountAddress) { + throw new Error('WalletConnect 1-Click Auth request has no active account') + } + + // To avoid multiple signature modals, we only sign for the first supported chain. + // If a dapp wants to authenticate across multiple chains, it must request additional signatures separately. + const chainForSigning = formattedEip155Chains[0] ? getChainIdFromEIP155String(formattedEip155Chains[0]) : undefined + + if (!chainForSigning) { + throw new Error('WalletConnect 1-Click Auth request has invalid supported chain: ' + formattedEip155Chains[0]) + } + + const message = wcWeb3Wallet.formatAuthMessage({ + request: authPayload, + iss: `eip155:${chainForSigning}:${activeAccountAddress}`, + }) + + const request: SignRequest = { + type: EthMethod.EthSign, + message, + rawMessage: message, + sessionId: authenticate.id.toString(), + internalId: `${chainForSigning}:${authenticate.id.toString()}`, + chainId: chainForSigning, + account: activeAccountAddress, + dappRequestInfo: { + name: authenticate.params.requester.metadata.name, + url: authenticate.params.requester.metadata.url, + icon: authenticate.params.requester.metadata.icons[0] ?? null, + requestType: DappRequestType.WalletConnectAuthenticationRequest, + authPayload, + }, + } + + yield* put(addRequest(request)) +} + +function* handleSessionRequest(sessionRequest: PendingRequestTypes.Struct) { + const { topic, params, id } = sessionRequest + const { request: wcRequest, chainId: wcChainId } = params + const { method, params: requestParams } = wcRequest + + const chainId = getChainIdFromEIP155String(wcChainId) + const requestSession = wcWeb3Wallet.engine.signClient.session.get(topic) + const accountAddress = getAccountAddressFromWCSession(requestSession) + const dapp = requestSession.peer.metadata + + if (!chainId) { + throw new Error('WalletConnect 2.0 session request has invalid chainId') + } + + if (!accountAddress) { + throw new Error('WalletConnect 2.0 session has no eip155 account') + } + + if (eip5792Methods.includes(method)) { + const eip5792MethodsEnabled = getFeatureFlag(FeatureFlags.Eip5792Methods) + if (!eip5792MethodsEnabled) { + yield* call([wcWeb3Wallet, wcWeb3Wallet.respondSessionRequest], { + topic, + response: { + id, + jsonrpc: '2.0', + error: getSdkError('WC_METHOD_UNSUPPORTED'), + }, + }) + return + } + } + + switch (method) { + case EthMethod.EthSign: + case EthMethod.PersonalSign: + case EthMethod.SignTypedData: + case EthMethod.SignTypedDataV4: { + const request = parseSignRequest({ + method, + topic, + internalId: id, + chainId, + dapp, + requestParams, + }) + yield* put(addRequest(request)) + break + } + case EthMethod.EthSendTransaction: { + const request = parseTransactionRequest({ + method, + topic, + internalId: id, + chainId, + dapp, + requestParams, + }) + // Validate for self-call with data + if ( + isSelfCallWithData({ + from: request.transaction.from, + to: request.transaction.to, + data: request.transaction.data, + }) + ) { + yield* call([wcWeb3Wallet, wcWeb3Wallet.respondSessionRequest], { + topic, + response: { + id, + jsonrpc: '2.0', + error: getSdkError('USER_REJECTED', 'Self-calls with data are not supported'), + }, + }) + return + } + yield* put(addRequest(request)) + break + } + case EthMethod.WalletSendCalls: { + const request = parseSendCallsRequest({ + topic, + internalId: id, + chainId, + dapp, + requestParams, + account: accountAddress, + }) + // Validate for self-call with data in any of the calls + const hasSelfCall = request.calls.some((batchCall) => + isSelfCallWithData({ from: request.account, to: batchCall.to, data: batchCall.data }), + ) + if (hasSelfCall) { + yield* call([wcWeb3Wallet, wcWeb3Wallet.respondSessionRequest], { + topic, + response: { + id, + jsonrpc: '2.0', + error: getSdkError('USER_REJECTED', 'Self-calls with data are not supported'), + }, + }) + return + } + yield* call(handleSendCalls, { topic, requestId: id, request }) + break + } + case EthMethod.WalletGetCallsStatus: { + const { id: batchId } = parseGetCallsStatusRequest({ + topic, + internalId: id, + chainId, + dapp, + requestParams, + account: accountAddress, + }) + yield* call(handleGetCallsStatus, { topic, requestId: id, batchId, accountAddress }) + break + } + case EthMethod.WalletGetCapabilities: { + const { + account, + chainIds, + dappRequestInfo: { url: dappUrl }, + } = parseGetCapabilitiesRequest({ method, topic, internalId: id, dapp, requestParams }) + yield* call(handleGetCapabilities, { + topic, + requestId: id, + accountAddress, + requestedAccount: account, + chainIdsFromRequest: chainIds, + dappName: dapp.name, + dappUrl, + dappIconUrl: dapp.icons[0], + }) + break + } + default: + // Reject request for an invalid method + logger.warn('WalletConnectSaga', 'sessionRequestHandler', `Session request method is unsupported: ${method}`) + yield* call([wcWeb3Wallet, wcWeb3Wallet.respondSessionRequest], { + topic, + response: { + id, + jsonrpc: '2.0', + error: getSdkError('WC_METHOD_UNSUPPORTED'), + }, + }) + } +} + +function* handleSessionDelete(event: WalletKitTypes.SessionDelete) { + const { topic } = event + + const currentState = yield* select((state: MobileState) => state.walletConnect) + + const { dappName, dappIcon } = fetchDappDetails(topic, currentState) + + yield* put( + pushNotification({ + type: AppNotificationType.WalletConnect, + event: WalletConnectEvent.Disconnected, + dappName, + imageUrl: dappIcon, + hideDelay: 3 * ONE_SECOND_MS, + }), + ) + + yield* put(removeSession({ sessionId: topic })) +} + +function* populateActiveSessions() { + // Fetch all active sessions and add to store + const sessions = wcWeb3Wallet.getActiveSessions() + + const accounts = yield* select(selectAccounts) + + for (const session of Object.values(sessions)) { + // Get account address connected to the session from first namespace + const namespaces = Object.values(session.namespaces) + const eip155Account = namespaces[0]?.accounts[0] + if (!eip155Account) { + continue + } + + const accountAddress = getAccountAddressFromEIP155String(eip155Account) + + if (!accountAddress) { + continue + } + + // Verify account address for session exists in wallet's accounts + const matchingAccount = Object.values(accounts).find( + (account) => account.address.toLowerCase() === accountAddress.toLowerCase(), + ) + if (!matchingAccount) { + continue + } + + // Get all chains for session namespaces, supporting `eip155:CHAIN_ID` and `eip155` namespace formats + const chains: UniverseChainId[] = [] + Object.entries(session.namespaces).forEach(([key, namespace]) => { + const eip155Chains = key.includes(':') ? [key] : namespace.chains + chains.push(...(getSupportedWalletConnectChains(eip155Chains) ?? [])) + }) + + yield* put( + addSession({ + wcSession: { + id: session.topic, + dappRequestInfo: { + name: session.peer.metadata.name, + url: session.peer.metadata.url, + icon: session.peer.metadata.icons[0] ?? null, + requestType: DappRequestType.WalletConnectSessionRequest, + }, + chains, + namespaces: session.namespaces, + activeAccount: accountAddress, + }, + }), + ) + } +} + +// Load any existing pending session proposals from the WC connection +function* fetchPendingSessionProposals() { + const pendingSessionProposals = wcWeb3Wallet.getPendingSessionProposals() + for (const proposal of Object.values(pendingSessionProposals)) { + yield* call(handleSessionProposal, proposal) + } +} + +// Load any existing pending session requests from the WC connection +function* fetchPendingSessionRequests() { + const pendingSessionRequests = wcWeb3Wallet.getPendingSessionRequests() + for (const sessionRequest of Object.values(pendingSessionRequests)) { + yield* call(handleSessionRequest, sessionRequest) + } +} + +/** + * Monitor wallet account changes and update WC sessions accordingly for sessions that are approved with multiple accounts. + * + * This allows connected Dapps to stay in sync with the currently active wallet account across all supported chains. + * + * Account approvals are per chain, and included as part of the wc session namespaces. + */ +function* monitorAccountChanges() { + while (true) { + const action = yield* take(setAccountAsActive) + const newActiveAccountAddress = action.payload + + const allSessions = yield* select(selectAllSessions) + + for (const session of Object.values(allSessions)) { + const accounts = session.namespaces['eip155']?.accounts + + // Update all sessions if new active account is included in the session namespacess + const isNewAccountApprovedInNamespace = accounts?.some((eip155String) => { + const parsedAddress = getAccountAddressFromEIP155String(eip155String) + // TODO(WALL-7065): Update to support solana + return areAddressesEqual({ + addressInput1: { address: parsedAddress, platform: Platform.EVM }, + addressInput2: { address: newActiveAccountAddress, platform: Platform.EVM }, + }) + }) + + if (!isNewAccountApprovedInNamespace) { + continue + } + + // Update WC sessions across all supported chains with the new active account + const chains = session.namespaces['eip155']?.chains ?? [] + yield* call(function* () { + for (const chainId of chains) { + yield* call([wcWeb3Wallet, wcWeb3Wallet.emitSessionEvent], { + topic: session.id, + chainId, + event: { + name: EthEvent.AccountsChanged, + data: [newActiveAccountAddress], + }, + }) + } + }) + + // Update the active account in store + yield* put(replaceSession({ wcSession: { ...session, activeAccount: newActiveAccountAddress } })) + } + } +} + +export function* walletConnectSaga() { + yield* call(initializeWeb3Wallet) + yield* call(populateActiveSessions) + yield* fork(fetchPendingSessionProposals) + yield* fork(fetchPendingSessionRequests) + yield* fork(watchWalletConnectEvents) + yield* fork(monitorAccountChanges) +} diff --git a/apps/mobile/src/features/walletConnect/selectors.ts b/apps/mobile/src/features/walletConnect/selectors.ts new file mode 100644 index 00000000..99cfeb70 --- /dev/null +++ b/apps/mobile/src/features/walletConnect/selectors.ts @@ -0,0 +1,41 @@ +import { createSelector, Selector } from '@reduxjs/toolkit' +import { MobileState } from 'src/app/mobileReducer' +import { + WalletConnectPendingSession, + WalletConnectSession, + WalletConnectSigningRequest, +} from 'src/features/walletConnect/walletConnectSlice' + +export const makeSelectSessions = (): Selector]> => + createSelector( + (state: MobileState) => state.walletConnect.sessions, + (_: MobileState, address: Maybe
) => address, + (sessions, address) => { + if (!address) { + return undefined + } + + // Filter sessions by active account address + return Object.values(sessions).filter((session) => session.activeAccount === address) + }, + ) + +export const selectAllSessions = (state: MobileState): Record => { + return state.walletConnect.sessions +} + +export const selectPendingRequests = (state: MobileState): WalletConnectSigningRequest[] => { + return state.walletConnect.pendingRequests +} + +export const selectPendingSession = (state: MobileState): WalletConnectPendingSession | null => { + return state.walletConnect.pendingSession +} + +export const selectDidOpenFromDeepLink = (state: MobileState): boolean => { + return state.walletConnect.didOpenFromDeepLink ?? false +} + +export const selectHasPendingSessionError = (state: MobileState): boolean => { + return state.walletConnect.hasPendingSessionError ?? false +} diff --git a/apps/mobile/src/features/walletConnect/signWcRequestSaga.ts b/apps/mobile/src/features/walletConnect/signWcRequestSaga.ts new file mode 100644 index 00000000..dbd33e9f --- /dev/null +++ b/apps/mobile/src/features/walletConnect/signWcRequestSaga.ts @@ -0,0 +1,248 @@ +/* oxlint-disable complexity */ +import { buildAuthObject, getSdkError } from '@walletconnect/utils' +import { providers } from 'ethers' +import { wcWeb3Wallet } from 'src/features/walletConnect/walletConnectClient' +import { + TransactionRequest, + UwuLinkErc20Request, + WalletSendCallsEncodedRequest, +} from 'src/features/walletConnect/walletConnectSlice' +import { call, put } from 'typed-redux-saga' +import { AssetType } from 'uniswap/src/entities/assets' +import { SignerMnemonicAccountMeta } from 'uniswap/src/features/accounts/types' +import { UniverseChainId } from 'uniswap/src/features/chains/types' +import { EthMethod, EthSignMethod } from 'uniswap/src/features/dappRequests/types' +import { pushNotification } from 'uniswap/src/features/notifications/slice/slice' +import { AppNotificationType } from 'uniswap/src/features/notifications/slice/types' +import { Platform } from 'uniswap/src/features/platforms/types/Platform' +import { getEnabledChainIdsSaga } from 'uniswap/src/features/settings/saga' +import { TransactionOriginType, TransactionType } from 'uniswap/src/features/transactions/types/transactionDetails' +import { DappRequestInfo, DappRequestType, UwULinkMethod, WalletConnectEvent } from 'uniswap/src/types/walletConnect' +import { createSaga } from 'uniswap/src/utils/saga' +import { logger } from 'utilities/src/logger/logger' +import { addBatchedTransaction } from 'wallet/src/features/batchedTransactions/slice' +import { SendCallsResult } from 'wallet/src/features/dappRequests/types' +import { + ExecuteTransactionParams, + executeTransaction, +} from 'wallet/src/features/transactions/executeTransaction/executeTransactionSaga' +import { Account } from 'wallet/src/features/wallet/accounts/types' +import { getSignerManager } from 'wallet/src/features/wallet/context' +import { signMessage, signTypedDataMessage } from 'wallet/src/features/wallet/signing/signing' + +type SignMessageParams = { + sessionId: string + requestInternalId: string + message: string + account: Account + method: EthSignMethod + dappRequestInfo: DappRequestInfo + chainId: UniverseChainId +} + +type SignTransactionParams = { + sessionId: string + requestInternalId: string + transaction: providers.TransactionRequest + account: SignerMnemonicAccountMeta + method: EthMethod.EthSendTransaction | EthMethod.WalletSendCalls + dappRequestInfo: DappRequestInfo + chainId: UniverseChainId + request: TransactionRequest | UwuLinkErc20Request | WalletSendCallsEncodedRequest +} + +function* signWcRequest(params: SignMessageParams | SignTransactionParams) { + const { sessionId, requestInternalId, account, method, chainId } = params + const { defaultChainId } = yield* getEnabledChainIdsSaga(Platform.EVM) + try { + const signerManager = yield* call(getSignerManager) + let result: string | SendCallsResult = '' + if (method === EthMethod.PersonalSign || method === EthMethod.EthSign) { + // For personal_sign, pass signAsString=true to keep the message as a string for proper EIP-191 hashing + result = yield* call(signMessage, { + message: params.message, + account, + signerManager, + signAsString: method === EthMethod.PersonalSign, + }) + + // TODO: add `isCheckIn` type to uwulink request info so that this can be generalized + if ( + params.dappRequestInfo.requestType === DappRequestType.UwULink && + params.dappRequestInfo.name === 'Uniswap Cafe' + ) { + yield* put( + pushNotification({ + type: AppNotificationType.Success, + title: 'Checked in', + }), + ) + } + } else if (method === EthMethod.SignTypedData || method === EthMethod.SignTypedDataV4) { + result = yield* call(signTypedDataMessage, { message: params.message, account, signerManager }) + } else if (method === EthMethod.EthSendTransaction && params.request.type === UwULinkMethod.Erc20Send) { + const txParams: ExecuteTransactionParams = { + chainId: params.transaction.chainId || defaultChainId, + account, + options: { + request: params.transaction, + }, + typeInfo: { + type: TransactionType.Send, + assetType: AssetType.Currency, + recipient: params.request.recipient.address, + tokenAddress: params.request.tokenAddress, + currencyAmountRaw: params.request.amount, + }, + transactionOriginType: TransactionOriginType.External, + } + const { transactionHash } = yield* call(executeTransaction, txParams) + result = transactionHash + } else if (method === EthMethod.EthSendTransaction) { + const txParams: ExecuteTransactionParams = { + chainId: params.transaction.chainId || defaultChainId, + account, + options: { + request: params.transaction, + }, + typeInfo: { + type: TransactionType.WCConfirm, + dappRequestInfo: params.dappRequestInfo, + }, + transactionOriginType: TransactionOriginType.External, + } + const { transactionHash } = yield* call(executeTransaction, txParams) + result = transactionHash + + // Trigger a pending transaction notification after we send the transaction to chain + yield* put( + pushNotification({ + type: AppNotificationType.TransactionPending, + chainId: txParams.chainId, + }), + ) + // oxlint-disable-next-line typescript/no-unnecessary-condition + } else if (method === EthMethod.WalletSendCalls && params.request.type === EthMethod.WalletSendCalls) { + const txParams: ExecuteTransactionParams = { + chainId: params.request.chainId, + account, + options: { + request: params.transaction, + }, + typeInfo: { + type: TransactionType.WCConfirm, + dappRequestInfo: params.dappRequestInfo, + }, + transactionOriginType: TransactionOriginType.External, + } + + const { transactionHash } = yield* call(executeTransaction, txParams) + result = { + id: params.request.id, + capabilities: { + caip345: { + caip2: `eip155:${params.request.chainId}`, + transactionHashes: [transactionHash], + }, + }, + } + + // Store the batch transaction in Redux + yield* put( + addBatchedTransaction({ + batchId: params.request.id, + txHashes: [transactionHash], + requestId: params.request.encodedRequestId, + chainId: params.request.chainId, + }), + ) + + // Trigger a pending transaction notification after we send the transaction to chain + yield* put( + pushNotification({ + type: AppNotificationType.TransactionPending, + chainId: txParams.chainId, + }), + ) + } + + if (params.dappRequestInfo.requestType === DappRequestType.WalletConnectAuthenticationRequest) { + const iss = `eip155:${chainId}:${account.address}` + + // Check if signature is a string, if not throw an error + if (typeof result !== 'string') { + throw new Error('Expected signature to be a string in WalletConnectAuthenticationRequest') + } + + const auth = buildAuthObject( + params.dappRequestInfo.authPayload, + { + t: 'eip191', + s: result, + }, + iss, + ) + yield* call(wcWeb3Wallet.approveSessionAuthenticate, { + id: Number(sessionId), + auths: [auth], + }) + } else if (params.dappRequestInfo.requestType === DappRequestType.WalletConnectSessionRequest) { + yield* call(wcWeb3Wallet.respondSessionRequest, { + topic: sessionId, + response: { + id: Number(requestInternalId), + jsonrpc: '2.0', + result, + }, + }) + // oxlint-disable-next-line typescript/no-unnecessary-condition + } else if (params.dappRequestInfo.requestType === DappRequestType.UwULink && params.dappRequestInfo.webhook) { + fetch(params.dappRequestInfo.webhook, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ method: params.method, response: result, chainId }), + // TODO: consider adding analytics to track UwuLink usage + }).catch((error) => + logger.error(error, { + tags: { file: 'walletConnect/saga', function: 'signWcRequest/uwulink' }, + }), + ) + } + } catch (error) { + if (params.dappRequestInfo.requestType === DappRequestType.WalletConnectSessionRequest) { + yield* call(wcWeb3Wallet.respondSessionRequest, { + topic: sessionId, + response: { + id: Number(requestInternalId), + jsonrpc: '2.0', + error: { code: 5000, message: `Signing error: ${error}` }, + }, + }) + } else if (params.dappRequestInfo.requestType === DappRequestType.WalletConnectAuthenticationRequest) { + yield* call(wcWeb3Wallet.rejectSessionAuthenticate, { + id: Number(sessionId), + reason: getSdkError('USER_REJECTED'), + }) + } + + yield* put( + pushNotification({ + type: AppNotificationType.WalletConnect, + event: WalletConnectEvent.TransactionFailed, + dappName: params.dappRequestInfo.name, + imageUrl: params.dappRequestInfo.icon ?? null, + chainId, + address: account.address, + }), + ) + logger.error(error, { tags: { file: 'walletConnect/saga', function: 'signWcRequest' } }) + } +} + +export const { wrappedSaga: signWcRequestSaga, actions: signWcRequestActions } = createSaga( + signWcRequest, + 'signWalletConnect', +) diff --git a/apps/mobile/src/features/walletConnect/useWalletConnect.ts b/apps/mobile/src/features/walletConnect/useWalletConnect.ts new file mode 100644 index 00000000..ec305c80 --- /dev/null +++ b/apps/mobile/src/features/walletConnect/useWalletConnect.ts @@ -0,0 +1,37 @@ +import { useMemo } from 'react' +import { useSelector } from 'react-redux' +import { MobileState } from 'src/app/mobileReducer' +import { AppModalState } from 'src/features/modals/ModalsState' +import { selectModalState } from 'src/features/modals/selectModalState' +import { + makeSelectSessions, + selectHasPendingSessionError, + selectPendingRequests, + selectPendingSession, +} from 'src/features/walletConnect/selectors' +import { + WalletConnectPendingSession, + WalletConnectSession, + WalletConnectSigningRequest, +} from 'src/features/walletConnect/walletConnectSlice' +import { ScannerModalState } from 'uniswap/src/components/ReceiveQRCode/constants' +import { ModalName } from 'uniswap/src/features/telemetry/constants' + +interface WalletConnect { + sessions: WalletConnectSession[] + pendingRequests: WalletConnectSigningRequest[] + modalState: AppModalState + pendingSession: WalletConnectPendingSession | null + hasPendingSessionError: boolean +} + +export function useWalletConnect(address: Maybe): WalletConnect { + const selectSessions = useMemo(() => makeSelectSessions(), []) + const sessions = useSelector((state: MobileState) => selectSessions(state, address)) ?? [] + const pendingRequests = useSelector(selectPendingRequests) + const modalState = useSelector(selectModalState(ModalName.WalletConnectScan)) + const pendingSession = useSelector(selectPendingSession) + const hasPendingSessionError = useSelector(selectHasPendingSessionError) + + return { sessions, pendingRequests, modalState, pendingSession, hasPendingSessionError } +} diff --git a/apps/mobile/src/features/walletConnect/utils.test.ts b/apps/mobile/src/features/walletConnect/utils.test.ts new file mode 100644 index 00000000..327dc35d --- /dev/null +++ b/apps/mobile/src/features/walletConnect/utils.test.ts @@ -0,0 +1,586 @@ +import { utils } from 'ethers' +import { + convertCapabilitiesToScopedProperties, + decodeMessage, + getAccountAddressFromEIP155String, + getChainIdFromEIP155String, + getSupportedWalletConnectChains, + parseGetCallsStatusRequest, + parseGetCapabilitiesRequest, + parseSendCallsRequest, + parseSignRequest, + parseTransactionRequest, +} from 'src/features/walletConnect/utils' +import { UniverseChainId } from 'uniswap/src/features/chains/types' +import { EthMethod } from 'uniswap/src/features/dappRequests/types' +import { DappRequestType } from 'uniswap/src/types/walletConnect' + +const EIP155_MAINNET = 'eip155:1' +const EIP155_POLYGON = 'eip155:137' +const EIP155_OPTIMISM = 'eip155:10' +const EIP155_FANTOM_UNSUPPORTED = 'eip155:250' + +const TEST_ADDRESS = '0xdFb84E543C39ACa3c6a39ea4e3B6c40eE7d2EBdA' + +describe(getAccountAddressFromEIP155String, () => { + it('handles valid eip155 mainnet address', () => { + expect(getAccountAddressFromEIP155String(`${EIP155_MAINNET}:${TEST_ADDRESS}`)).toBe(TEST_ADDRESS) + }) + + it('handles valid eip155 polygon address', () => { + expect(getAccountAddressFromEIP155String(`${EIP155_POLYGON}:${TEST_ADDRESS}`)).toBe(TEST_ADDRESS) + }) + + it('handles invalid eip155 address', () => { + expect(getAccountAddressFromEIP155String(TEST_ADDRESS)).toBeNull() + }) +}) + +describe(getSupportedWalletConnectChains, () => { + it('handles list of valid chains', () => { + expect(getSupportedWalletConnectChains([EIP155_MAINNET, EIP155_POLYGON, EIP155_OPTIMISM])).toEqual([ + UniverseChainId.Mainnet, + UniverseChainId.Polygon, + UniverseChainId.Optimism, + ]) + }) + + it('handles list of valid chains including an invalid chain', () => { + expect(getSupportedWalletConnectChains([EIP155_MAINNET, EIP155_POLYGON, EIP155_FANTOM_UNSUPPORTED])).toEqual([ + UniverseChainId.Mainnet, + UniverseChainId.Polygon, + ]) + }) +}) + +describe(getChainIdFromEIP155String, () => { + it('handles valid eip155 mainnet address', () => { + expect(getChainIdFromEIP155String(EIP155_MAINNET)).toBe(UniverseChainId.Mainnet) + }) + + it('handles valid eip155 optimism address', () => { + expect(getChainIdFromEIP155String(EIP155_OPTIMISM)).toBe(UniverseChainId.Optimism) + }) + + it('handles invalid eip155 address', () => { + expect(getChainIdFromEIP155String(EIP155_FANTOM_UNSUPPORTED)).toBeNull() + }) +}) + +describe('isHexString', () => { + test('should return true for valid hex string', () => { + const validHex = '0x5468697320697320612074657374206d657373616765' + expect(utils.isHexString(validHex)).toBe(true) + }) + + test('should return false for invalid hex string', () => { + const invalidHex = '546869732069732G20612074657374206d657373616765' // Invalid hex + expect(utils.isHexString(invalidHex)).toBe(false) + }) + + test('should return false for plain text', () => { + const plainText = 'This is a plain text message' + expect(utils.isHexString(plainText)).toBe(false) + }) +}) + +describe('decodeMessage', () => { + test('should decode hex-encoded message', () => { + const hexMessage = '0x5468697320697320612074657374206d657373616765' + const expectedMessage = 'This is a test message' + const result = decodeMessage(hexMessage) + expect(result).toBe(expectedMessage) + }) + + test('should return plain text message unchanged', () => { + const plainText = 'This is a plain text message' + const result = decodeMessage(plainText) + expect(result).toBe(plainText) + }) +}) + +describe(parseGetCapabilitiesRequest, () => { + const mockTopic = 'test-topic' + const mockInternalId = 123 + const mockDapp = { + name: 'Test Dapp', + description: 'Test Dapp Description', + url: 'https://test.com', + icons: ['https://test.com/icon.png'], + } + + it('handles request with address only', () => { + const result = parseGetCapabilitiesRequest({ + method: EthMethod.WalletGetCapabilities, + topic: mockTopic, + internalId: mockInternalId, + dapp: mockDapp, + requestParams: [TEST_ADDRESS], + }) + + expect(result).toEqual({ + type: EthMethod.WalletGetCapabilities, + sessionId: mockTopic, + internalId: String(mockInternalId), + account: TEST_ADDRESS, + chainIds: undefined, + isLinkModeSupported: false, + dappRequestInfo: { + name: mockDapp.name, + url: mockDapp.url, + icon: mockDapp.icons[0], + requestType: DappRequestType.WalletConnectSessionRequest, + }, + }) + }) + + it('handles request with address and chain IDs', () => { + const chainIds = [UniverseChainId.Mainnet, UniverseChainId.Polygon] + const result = parseGetCapabilitiesRequest({ + method: EthMethod.WalletGetCapabilities, + topic: mockTopic, + internalId: mockInternalId, + dapp: mockDapp, + requestParams: [TEST_ADDRESS, chainIds.map((c) => `0x${c.toString(16)}`)], + }) + + expect(result).toEqual({ + type: EthMethod.WalletGetCapabilities, + sessionId: mockTopic, + internalId: String(mockInternalId), + account: TEST_ADDRESS, + chainIds, + isLinkModeSupported: false, + dappRequestInfo: { + name: mockDapp.name, + url: mockDapp.url, + icon: mockDapp.icons[0], + requestType: DappRequestType.WalletConnectSessionRequest, + }, + }) + }) +}) + +describe(parseSignRequest, () => { + const mockTopic = 'test-topic' + const mockInternalId = 123 + const mockChainId = UniverseChainId.Mainnet + const mockDapp = { + name: 'Test Dapp', + description: 'Test Dapp Description', + url: 'https://test.com', + icons: ['https://test.com/icon.png'], + } + + it('parses personal_sign request correctly (standard hex-encoded message)', () => { + const message = '0x48656c6c6f20576f726c64' // "Hello World" in hex + const params = [message, TEST_ADDRESS] + + const result = parseSignRequest({ + method: EthMethod.PersonalSign, + topic: mockTopic, + internalId: mockInternalId, + chainId: mockChainId, + dapp: mockDapp, + requestParams: params, + }) + + expect(result).toEqual({ + type: EthMethod.PersonalSign, + sessionId: mockTopic, + internalId: String(mockInternalId), + account: TEST_ADDRESS, + chainId: mockChainId, + rawMessage: message, + message: 'Hello World', + isLinkModeSupported: false, + dappRequestInfo: { + name: mockDapp.name, + url: mockDapp.url, + icon: mockDapp.icons[0], + requestType: DappRequestType.WalletConnectSessionRequest, + }, + }) + }) + + it('parses eth_sign request correctly', () => { + const message = '0x48656c6c6f20576f726c64' // "Hello World" in hex + const params = [TEST_ADDRESS, message] + + const result = parseSignRequest({ + method: EthMethod.EthSign, + topic: mockTopic, + internalId: mockInternalId, + chainId: mockChainId, + dapp: mockDapp, + requestParams: params, + }) + + expect(result).toEqual({ + type: EthMethod.EthSign, + sessionId: mockTopic, + internalId: String(mockInternalId), + account: TEST_ADDRESS, + chainId: mockChainId, + rawMessage: message, + message: 'Hello World', + isLinkModeSupported: false, + dappRequestInfo: { + name: mockDapp.name, + url: mockDapp.url, + icon: mockDapp.icons[0], + requestType: DappRequestType.WalletConnectSessionRequest, + }, + }) + }) + + it('parses eth_signTypedData request correctly', () => { + const typedData = '{"types":{"EIP712Domain":[]},"domain":{},"primaryType":"Mail","message":{}}' + const params = [TEST_ADDRESS, typedData] + + const result = parseSignRequest({ + method: EthMethod.SignTypedData, + topic: mockTopic, + internalId: mockInternalId, + chainId: mockChainId, + dapp: mockDapp, + requestParams: params, + }) + + expect(result).toEqual({ + type: EthMethod.SignTypedData, + sessionId: mockTopic, + internalId: String(mockInternalId), + account: TEST_ADDRESS, + chainId: mockChainId, + rawMessage: typedData, + message: null, + isLinkModeSupported: false, + dappRequestInfo: { + name: mockDapp.name, + url: mockDapp.url, + icon: mockDapp.icons[0], + requestType: DappRequestType.WalletConnectSessionRequest, + }, + }) + }) +}) + +describe(parseTransactionRequest, () => { + const mockTopic = 'test-topic' + const mockInternalId = 123 + const mockChainId = UniverseChainId.Mainnet + const mockDapp = { + name: 'Test Dapp', + description: 'Test Dapp Description', + url: 'https://test.com', + icons: ['https://test.com/icon.png'], + } + + it('parses eth_sendTransaction request correctly', () => { + const txParams = { + from: TEST_ADDRESS, + to: '0x1234567890123456789012345678901234567890', + data: '0x', + gasLimit: '0x5208', // 21000 in hex + value: '0x0', + gasPrice: '0x4a817c800', // This should be omitted in the result + nonce: '0x1', // This should be omitted in the result + } + + const result = parseTransactionRequest({ + method: EthMethod.EthSendTransaction, + topic: mockTopic, + internalId: mockInternalId, + chainId: mockChainId, + dapp: mockDapp, + requestParams: [txParams], + }) + + expect(result).toEqual({ + type: EthMethod.EthSendTransaction, + sessionId: mockTopic, + internalId: String(mockInternalId), + account: TEST_ADDRESS, + chainId: mockChainId, + isLinkModeSupported: false, + transaction: { + from: TEST_ADDRESS, + to: '0x1234567890123456789012345678901234567890', + data: '0x', + gasLimit: '0x5208', + value: '0x0', + // gasPrice and nonce should be omitted + }, + dappRequestInfo: { + name: mockDapp.name, + url: mockDapp.url, + icon: mockDapp.icons[0], + requestType: DappRequestType.WalletConnectSessionRequest, + }, + }) + }) +}) + +describe(parseSendCallsRequest, () => { + const mockTopic = 'test-topic' + const mockInternalId = 123 + const mockChainId = UniverseChainId.Mainnet + const mockDapp = { + name: 'Test Dapp', + description: 'Test Dapp Description', + url: 'https://test.com', + icons: ['https://test.com/icon.png'], + } + + it('parses wallet_sendCalls request with from address', () => { + const sendCallsParams = { + from: TEST_ADDRESS, + calls: [ + { + to: '0x1234567890123456789012345678901234567890', + data: '0xabcdef', + value: '0x0', + }, + ], + chainId: '0x01', + id: 'test-batch-id', + version: '2.0.0', + capabilities: { + eip155: { + methods: ['eth_sendTransaction'], + }, + }, + } + + const result = parseSendCallsRequest({ + topic: mockTopic, + internalId: mockInternalId, + chainId: mockChainId, + dapp: mockDapp, + requestParams: [sendCallsParams], + account: 'fallback-address', + }) + + expect(result).toEqual({ + type: EthMethod.WalletSendCalls, + sessionId: mockTopic, + internalId: String(mockInternalId), + account: TEST_ADDRESS, + chainId: mockChainId, + calls: sendCallsParams.calls, + capabilities: sendCallsParams.capabilities, + id: sendCallsParams.id, + version: sendCallsParams.version, + isLinkModeSupported: false, + dappRequestInfo: { + name: mockDapp.name, + url: mockDapp.url, + icon: mockDapp.icons[0], + requestType: DappRequestType.WalletConnectSessionRequest, + }, + }) + }) + + it('uses fallback address when from is not provided', () => { + const sendCallsParams = { + chainId: '0x01', + calls: [ + { + to: '0x1234567890123456789012345678901234567890', + data: '0xabcdef', + value: '0x0', + }, + ], + version: '2.0.0', + } + + const fallbackAddress = '0xfallbackaddress' + const result = parseSendCallsRequest({ + topic: mockTopic, + internalId: mockInternalId, + chainId: mockChainId, + dapp: mockDapp, + requestParams: [sendCallsParams], + account: fallbackAddress, + }) + + expect(result.account).toBe(fallbackAddress) + expect(result.id).toBeTruthy() // Should generate a mock ID + }) +}) + +describe('personal_sign signAsString flag behavior', () => { + it('should handle hex-encoded UTF-8 messages for personal_sign', () => { + const hexEncodedMessage = '0x48656c6c6f20576f726c64' // "Hello World" + const params = [hexEncodedMessage, TEST_ADDRESS] + + const result = parseSignRequest({ + method: EthMethod.PersonalSign, + topic: 'test-topic', + internalId: 123, + chainId: UniverseChainId.Mainnet, + dapp: { + name: 'Test Dapp', + description: 'Test Description', + url: 'https://test.com', + icons: ['https://test.com/icon.png'], + }, + requestParams: params, + }) + + // rawMessage stays as hex for signing + expect(result.rawMessage).toBe(hexEncodedMessage) + // message is decoded for UI display + expect(result.message).toBe('Hello World') + }) + + it('should handle plain text messages for personal_sign', () => { + const plainText = 'Sign this message to authenticate' + const params = [plainText, TEST_ADDRESS] + + const result = parseSignRequest({ + method: EthMethod.PersonalSign, + topic: 'test-topic', + internalId: 123, + chainId: UniverseChainId.Mainnet, + dapp: { + name: 'Test Dapp', + description: 'Test Description', + url: 'https://test.com', + icons: ['https://test.com/icon.png'], + }, + requestParams: params, + }) + + // Plain text stays unchanged in both fields + expect(result.rawMessage).toBe(plainText) + expect(result.message).toBe(plainText) + }) +}) + +describe(parseGetCallsStatusRequest, () => { + const mockTopic = 'test-topic' + const mockInternalId = 123 + const mockChainId = UniverseChainId.Mainnet + const mockDapp = { + name: 'Test Dapp', + description: 'Test Dapp Description', + url: 'https://test.com', + icons: ['https://test.com/icon.png'], + } + + it('parses wallet_getCallsStatus request correctly', () => { + const requestId = 'test-batch-id' + const account = TEST_ADDRESS + + const result = parseGetCallsStatusRequest({ + topic: mockTopic, + internalId: mockInternalId, + chainId: mockChainId, + dapp: mockDapp, + requestParams: [requestId], + account, + }) + + expect(result).toEqual({ + type: EthMethod.WalletGetCallsStatus, + sessionId: mockTopic, + internalId: String(mockInternalId), + account, + chainId: mockChainId, + id: requestId, + isLinkModeSupported: false, + dappRequestInfo: { + name: mockDapp.name, + url: mockDapp.url, + icon: mockDapp.icons[0], + requestType: DappRequestType.WalletConnectSessionRequest, + }, + }) + }) +}) + +describe(convertCapabilitiesToScopedProperties, () => { + it('converts single chain capability to CAIP-2 format', () => { + const capabilities = { + '0xa': { + atomic: { status: 'supported' }, + }, + } + + const result = convertCapabilitiesToScopedProperties(capabilities) + + expect(result).toEqual({ + 'eip155:10': { + atomic: { status: 'supported' }, + }, + }) + }) + + it('converts multiple chain capabilities to CAIP-2 format', () => { + const capabilities = { + '0x1': { + atomic: { status: 'supported' }, + }, + '0x89': { + atomic: { status: 'unsupported' }, + }, + '0xa': { + atomic: { status: 'supported' }, + paymasterService: { supported: true }, + }, + } + + const result = convertCapabilitiesToScopedProperties(capabilities) + + expect(result).toEqual({ + 'eip155:1': { + atomic: { status: 'supported' }, + }, + 'eip155:137': { + atomic: { status: 'unsupported' }, + }, + 'eip155:10': { + atomic: { status: 'supported' }, + paymasterService: { supported: true }, + }, + }) + }) + + it('returns empty object when given empty capabilities', () => { + const capabilities = {} + + const result = convertCapabilitiesToScopedProperties(capabilities) + + expect(result).toEqual({}) + }) + + it('handles invalid hex chain IDs that cause errors', () => { + const capabilities = { + '0x1': { + atomic: { status: 'supported' }, + }, + 'invalid-hex': { + atomic: { status: 'unsupported' }, + }, + '0x89': { + atomic: { status: 'supported' }, + }, + } + + const result = convertCapabilitiesToScopedProperties(capabilities) + + // hexToNumber returns NaN for invalid hex, which should be excluded + // The function continues execution despite the invalid chain ID + expect(result).toHaveProperty('eip155:1') + expect(result).toHaveProperty('eip155:137') + expect(result).not.toHaveProperty('eip155:NaN') + expect(result['eip155:1']).toEqual({ + atomic: { status: 'supported' }, + }) + expect(result['eip155:137']).toEqual({ + atomic: { status: 'supported' }, + }) + }) +}) diff --git a/apps/mobile/src/features/walletConnect/utils.ts b/apps/mobile/src/features/walletConnect/utils.ts new file mode 100644 index 00000000..5b8f1aa6 --- /dev/null +++ b/apps/mobile/src/features/walletConnect/utils.ts @@ -0,0 +1,422 @@ +import { WalletKitTypes } from '@reown/walletkit' +import { PairingTypes, ProposalTypes, SessionTypes, SignClientTypes, Verify } from '@walletconnect/types' +import { utils } from 'ethers' +import { wcWeb3Wallet } from 'src/features/walletConnect/walletConnectClient' +import { + SignRequest, + TransactionRequest, + WalletGetCallsStatusRequest, + WalletGetCapabilitiesRequest, + WalletSendCallsRequest, +} from 'src/features/walletConnect/walletConnectSlice' +import { UniverseChainId } from 'uniswap/src/features/chains/types' +import { toSupportedChainId } from 'uniswap/src/features/chains/utils' +import { EthMethod, EthSignMethod, WalletConnectEthMethod } from 'uniswap/src/features/dappRequests/types' +import { DappRequestInfo, DappRequestType } from 'uniswap/src/types/walletConnect' +import { hexToNumber } from 'utilities/src/addresses/hex' +import { logger } from 'utilities/src/logger/logger' +import { generateBatchId } from 'wallet/src/features/batchedTransactions/utils' +import { + Capability, + DappVerificationStatus, + GetCallsStatusParams, + SendCallsParams, +} from 'wallet/src/features/dappRequests/types' + +/** + * Construct WalletConnect 2.0 session namespaces to complete a new pairing. Used when approving a new pairing request. + * Assumes each namespace has been validated and is supported by the app with `validateProposalNamespaces()`. + * + * @param {Address} account address of account to complete WalletConnect pairing request + * @param {ProposalTypes.OptionalNamespaces} proposalNamespaces validated proposal namespaces that specify all supported chains, methods, events for the session + * @return {SessionTypes.Namespaces} session namespaces specifying which accounts, chains, methods, events to complete the pairing + */ +export function getSessionNamespaces( + accounts: Address[], + proposalNamespaces: ProposalTypes.OptionalNamespaces, +): SessionTypes.Namespaces { + // Below inspired from https://github.com/WalletConnect/web-examples/blob/main/wallets/react-wallet-v2/src/views/SessionProposalModal.tsx#L63 + const namespaces: SessionTypes.Namespaces = {} + + Object.entries(proposalNamespaces).forEach(([nameSpaceId, namespace]) => { + const { chains, events, methods } = namespace + + const formattedAccounts = !chains + ? accounts.map((account) => `${nameSpaceId}:${account}`) + : accounts.flatMap((account) => chains.map((chain) => `${chain}:${account}`)) + + namespaces[nameSpaceId] = { + accounts: formattedAccounts, + events, + methods, + chains, + } + }) + + return namespaces +} + +/** + * Convert list of chains from a WalletConnect namespace to a list of supported ChainIds + * @param {string[]} chains list of chain strings as received from WalletConnect (ex. "eip155:1") + * @returns {UniverseChainId[]} list of supported ChainIds + */ +export const getSupportedWalletConnectChains = (chains?: string[]): UniverseChainId[] | undefined => { + if (!chains) { + return undefined + } + + return chains.map((chain) => getChainIdFromEIP155String(chain)).filter((c): c is UniverseChainId => Boolean(c)) +} + +/** + * Convert chain from `eip155:[CHAIN_ID]` format to supported ChainId. + * Returns null if chain doesn't match correct `eip155:` format or is an unsupported chain. + */ +export const getChainIdFromEIP155String = (chain: string): UniverseChainId | null => { + const chainStr = chain.startsWith('eip155:') ? chain.split(':')[1] : undefined + return toSupportedChainId(chainStr) +} + +/** + * Convert account from `eip155:[CHAIN_ID]:[ADDRESS]` format to account address. + * Returns null if string doesn't match correct `eip155:chainId:address` forma. + */ +export const getAccountAddressFromEIP155String = (account: string): Address | null => { + const address = account.startsWith('eip155:') ? account.split(':')[2] : undefined + if (!address) { + return null + } + return address +} + +/** + * Creates a base WalletConnect request object with common properties + * + * @param method The request method type + * @param topic WalletConnect session ID + * @param internalId WalletConnect request ID + * @param account Account address + * @param chainId Chain ID for the request + * @param dapp Dapp metadata + * @returns Base request object with common properties + */ +function createBaseRequest({ + method, + topic, + internalId, + account, + dapp, +}: { + method: T + topic: string + internalId: number + account: Address + dapp: SignClientTypes.Metadata +}): { + type: T + sessionId: string + internalId: string + account: Address + isLinkModeSupported: boolean + dappRequestInfo: DappRequestInfo +} { + return { + type: method, + sessionId: topic, + internalId: String(internalId), + account, + isLinkModeSupported: Boolean(dapp.redirect?.linkMode), + dappRequestInfo: { + name: dapp.name, + url: dapp.url, + icon: dapp.icons[0] ?? null, + requestType: DappRequestType.WalletConnectSessionRequest, + }, + } +} + +/** + * Formats SignRequest object from WalletConnect 2.0 request parameters + * + * @param {EthSignMethod} method type of method to sign + * @param {string} topic id for the WalletConnect session + * @param {number} internalId id for the WalletConnect signature request + * @param {ChainId} chainId chain the signature is being requested on + * @param {SignClientTypes.Metadata} dapp metadata for the dapp requesting the signature + * @param {WalletKitTypes.SessionRequest['params']['request']['params']} requestParams parameters of the request + * @returns {{Address, SignRequest}} address of the account receiving the request and formatted SignRequest object + */ +export function parseSignRequest({ + method, + topic, + internalId, + chainId, + dapp, + requestParams, +}: { + method: EthSignMethod + topic: string + internalId: number + chainId: UniverseChainId + dapp: SignClientTypes.Metadata + requestParams: WalletKitTypes.SessionRequest['params']['request']['params'] +}): SignRequest { + const { address, rawMessage, message } = getAddressAndMessageToSign(method, requestParams) + return { + ...createBaseRequest({ method, topic, internalId, account: address, dapp }), + chainId, + rawMessage, + message, + } +} + +/** + * Formats TransactionRequest object from WalletConnect 2.0 request parameters. + * Only supports `eth_sendTransaction` request, `eth_signTransaction` is intentionally + * unsupported since it is difficult to support to nonce calculation and tracking. + * + * @param {EthMethod.EthSendTransaction} method type of method to sign (only support `eth_signTransaction`) + * @param {string} topic id for the WalletConnect session + * @param {number} internalId id for the WalletConnect transaction request + * @param {UniverseChainId} chainId chain the signature is being requested on + * @param {SignClientTypes.Metadata} dapp metadata for the dapp requesting the transaction + * @param {WalletKitTypes.SessionRequest['params']['request']['params']} requestParams parameters of the request + * @returns {{Address, TransactionRequest}} address of the account receiving the request and formatted TransactionRequest object + */ +export function parseTransactionRequest({ + method, + topic, + internalId, + chainId, + dapp, + requestParams, +}: { + method: EthMethod.EthSendTransaction + topic: string + internalId: number + chainId: UniverseChainId + dapp: SignClientTypes.Metadata + requestParams: WalletKitTypes.SessionRequest['params']['request']['params'] +}): TransactionRequest { + // Omit gasPrice and nonce in tx sent from dapp since it is calculated later + const { from, to, data, gasLimit, value } = requestParams[0] + + return { + ...createBaseRequest({ method, topic, internalId, account: from, dapp }), + chainId, + transaction: { + to, + from, + value, + data, + gasLimit, + }, + } +} + +/** + * Formats WalletCapabilitiesRequest object from parameters + * + * @param {EthMethod.WalletGetCapabilities} method type of method + * @param {string} topic id for the WalletConnect session + * @param {number} internalId id for the WalletConnect request + * @param {SignClientTypes.Metadata} dapp metadata for the dapp requesting capabilities + * @param {[string, string[]?]} requestParams parameters of the request [Wallet Address, [Chain IDs]?] + * @returns {WalletGetCapabilitiesRequest} formatted request object + */ +export const parseGetCapabilitiesRequest = ({ + method, + topic, + internalId, + dapp, + requestParams, +}: { + method: EthMethod.WalletGetCapabilities + topic: string + internalId: number + dapp: SignClientTypes.Metadata + requestParams: [string, string[]?] +}): WalletGetCapabilitiesRequest => { + const [address, chainIds] = requestParams + const parsedChainIds = chainIds + ?.map((chainId) => toSupportedChainId(hexToNumber(chainId))) + .filter((c): c is UniverseChainId => Boolean(c)) + + return { + ...createBaseRequest({ method, topic, internalId, account: address, dapp }), // 0 as chainId since it's not specific to a chain + chainIds: parsedChainIds, + account: address, + } +} + +export function parseSendCallsRequest({ + topic, + internalId, + chainId, + dapp, + requestParams, + account, +}: { + topic: string + internalId: number + chainId: number + dapp: SignClientTypes.Metadata + requestParams: [SendCallsParams] + account: Address +}): WalletSendCallsRequest { + const sendCallsParam = requestParams[0] + const requestId = sendCallsParam.id || generateBatchId() + return { + ...createBaseRequest({ + method: EthMethod.WalletSendCalls, + topic, + internalId, + account: sendCallsParam.from ?? account, + dapp, + }), + chainId, + calls: sendCallsParam.calls, + capabilities: sendCallsParam.capabilities || {}, + account: sendCallsParam.from ?? account, + id: requestId, + version: sendCallsParam.version, + } +} + +export function parseGetCallsStatusRequest({ + topic, + internalId, + chainId, + dapp, + requestParams, + account, +}: { + topic: string + internalId: number + chainId: number + dapp: SignClientTypes.Metadata + requestParams: [GetCallsStatusParams] + account: Address +}): WalletGetCallsStatusRequest { + const requestId = requestParams[0] + return { + ...createBaseRequest({ method: EthMethod.WalletGetCallsStatus, topic, internalId, account, dapp }), + chainId, + id: requestId, + } +} + +export function decodeMessage(value: string): string { + try { + if (utils.isHexString(value)) { + const decoded = utils.toUtf8String(value) + if (decoded.trim()) { + return decoded + } + } + + return value + } catch { + return value + } +} + +/** + * Gets the address receiving the request, raw message, decoded message to sign based on the EthSignMethod. + * `personal_sign` params are ordered as [message, account] + * `eth_sign` params are ordered as [account, message] + * `signTypedData` params are ordered as [account, message] + * See https://docs.walletconnect.com/2.0/advanced/rpc-reference/ethereum-rpc#personal_sign + */ +// oxlint-disable-next-line consistent-return +function getAddressAndMessageToSign( + ethMethod: EthSignMethod, + params: WalletKitTypes.SessionRequest['params']['request']['params'], +): { address: string; rawMessage: string; message: string | null } { + switch (ethMethod) { + case EthMethod.PersonalSign: + return { address: params[1], rawMessage: params[0], message: decodeMessage(params[0]) } + case EthMethod.EthSign: + return { address: params[0], rawMessage: params[1], message: utils.toUtf8String(params[1]) } + case EthMethod.SignTypedData: + case EthMethod.SignTypedDataV4: + return { address: params[0], rawMessage: params[1], message: null } + } +} + +export async function pairWithWalletConnectURI(uri: string): Promise { + try { + return await wcWeb3Wallet.pair({ uri }) + } catch (error) { + return Promise.reject(error instanceof Error ? error.message : '') + } +} + +/** + * Formats safety level based on the verify context from a wallet connect proposal or sesison request. + * + * See https://docs.reown.com/walletkit/ios/verify + */ +export function parseVerifyStatus(verifyContext?: Verify.Context): DappVerificationStatus { + if (!verifyContext) { + return DappVerificationStatus.Unverified + } + + const { verified } = verifyContext + + // Must check for isScam first, since valid URLs can still be scams + if (verified.validation === 'INVALID' || verified.isScam) { + return DappVerificationStatus.Threat + } + + if (verified.validation === 'VALID') { + return DappVerificationStatus.Verified + } + + // Default to unverified status to enforce stricter warning if verification information is empty + // Also covers 'UNKNOWN' case + return DappVerificationStatus.Unverified +} + +/** + * Converts capability objects from hex chainId format to WalletConnect-compatible scopedProperties format (CAIP-2). + * Used when advertising EIP-5792 capabilities during session approval. + * + * @param capabilities - Record with hex chainId keys (e.g., "0xa") and Capability values + * @returns scopedProperties with CAIP-2 keys (e.g., "eip155:10") or empty object if no capabilities + * + * @example + * convertCapabilitiesToScopedProperties({ + * "0xa": { atomic: { status: "supported" } }, + * "0x89": { atomic: { status: "unsupported" } } + * }) + * // Returns: + * // { + * // "eip155:10": { atomic: { status: "supported" } }, + * // "eip155:137": { atomic: { status: "unsupported" } } + * // } + */ +export function convertCapabilitiesToScopedProperties( + capabilities: Record, +): Record> { + const scopedProperties: Record> = {} + + for (const [hexChainId, capability] of Object.entries(capabilities)) { + try { + const chainId = hexToNumber(hexChainId) + if (isNaN(chainId)) { + continue + } + const caip2Key = `eip155:${chainId}` + scopedProperties[caip2Key] = capability + } catch (error) { + logger.error(error, { + tags: { function: 'convertCapabilitiesToScopedProperties', file: 'walletConnect/utils.ts' }, + }) + continue + } + } + + return scopedProperties +} diff --git a/apps/mobile/src/features/walletConnect/walletConnectClient.ts b/apps/mobile/src/features/walletConnect/walletConnectClient.ts new file mode 100644 index 00000000..095c186c --- /dev/null +++ b/apps/mobile/src/features/walletConnect/walletConnectClient.ts @@ -0,0 +1,67 @@ +import '@walletconnect/react-native-compat' +import { IWalletKit, WalletKit } from '@reown/walletkit' +import { Core } from '@walletconnect/core' +import { registerWCClientForPushNotifications } from 'src/features/walletConnect/api' +import { config } from 'uniswap/src/config' +import { isBetaEnv, isDevEnv } from 'utilities/src/environment/env' +import { logger } from 'utilities/src/logger/logger' + +// Export the wallet instance that will be initialized +export let wcWeb3Wallet: IWalletKit + +const PROJECT_ID = { + dev: config.walletConnectProjectIdDev, + beta: config.walletConnectProjectIdBeta, + default: config.walletConnectProjectId, +} + +let wcWeb3WalletReadyResolve: () => void +let wcWeb3WalletReadyReject: (e: unknown) => void +const wcWeb3WalletReady = new Promise((resolve, reject) => { + wcWeb3WalletReadyResolve = resolve + wcWeb3WalletReadyReject = reject +}) +export const waitForWcWeb3WalletIsReady = (): Promise => wcWeb3WalletReady + +function getProjectId(): string { + if (isDevEnv()) { + return PROJECT_ID.dev + } + if (isBetaEnv()) { + return PROJECT_ID.beta + } + return PROJECT_ID.default +} + +export async function initializeWeb3Wallet(): Promise { + try { + const wcCore = new Core({ + projectId: getProjectId(), + }) + + wcWeb3Wallet = await WalletKit.init({ + core: wcCore, + metadata: { + name: 'Uniswap Wallet', + description: + 'Built by the most trusted team in DeFi, Uniswap Wallet allows you to maintain full custody and control of your assets.', + url: 'https://uniswap.org/app', + icons: ['https://gateway.pinata.cloud/ipfs/QmR1hYqhDMoyvJtwrQ6f1kVyfEKyK65XH3nbCimXBMkHJg'], + redirect: { + native: 'uniswap://', + universal: 'https://uniswap.org/app', + linkMode: true, + }, + }, + }) + + const clientId = await wcWeb3Wallet.engine.signClient.core.crypto.getClientId() + await registerWCClientForPushNotifications(clientId) + wcWeb3WalletReadyResolve() + } catch (e) { + logger.error(e, { + tags: { file: 'walletConnect/walletConnectClient', function: 'initializeWeb3Wallet' }, + }) + wcWeb3WalletReadyReject(e) + } +} diff --git a/apps/mobile/src/features/walletConnect/walletConnectSlice.ts b/apps/mobile/src/features/walletConnect/walletConnectSlice.ts new file mode 100644 index 00000000..6d92fec7 --- /dev/null +++ b/apps/mobile/src/features/walletConnect/walletConnectSlice.ts @@ -0,0 +1,199 @@ +import { createSlice, type PayloadAction } from '@reduxjs/toolkit' +import { type ProposalTypes, type SessionTypes } from '@walletconnect/types' +import { type UniverseChainId } from 'uniswap/src/features/chains/types' +import { EthMethod, type EthSignMethod } from 'uniswap/src/features/dappRequests/types' +import { type DappRequestInfo, type EthTransaction, UwULinkMethod } from 'uniswap/src/types/walletConnect' +import { logger } from 'utilities/src/logger/logger' +import { type Call, type Capability, type DappVerificationStatus } from 'wallet/src/features/dappRequests/types' + +export type WalletConnectPendingSession = { + id: string + chains: UniverseChainId[] + dappRequestInfo: DappRequestInfo + proposalNamespaces: ProposalTypes.OptionalNamespaces + verifyStatus: DappVerificationStatus +} + +export type WalletConnectSession = { + id: string + chains: UniverseChainId[] + dappRequestInfo: DappRequestInfo + namespaces: SessionTypes.Namespaces + + /** + * WC session namespaces can contain approvals for multiple accounts. The active account represents the account that the dapp + * is tracking as the active account based on session events (approve session, change account, etc). + */ + activeAccount: string + + /** + * EIP-5792 capabilities for this session, stored in hex chainId format. + * Contains atomic batch support status per chain. + * Only populated if EIP-5792 feature flag was enabled during session approval. + */ + capabilities?: Record +} + +interface BaseRequest { + sessionId: string + internalId: string + account: string + dappRequestInfo: DappRequestInfo + chainId: UniverseChainId + isLinkModeSupported?: boolean +} + +export interface SignRequest extends BaseRequest { + type: EthSignMethod + message: string | null + rawMessage: string +} + +export interface TransactionRequest extends BaseRequest { + type: EthMethod.EthSendTransaction + transaction: EthTransaction +} + +export interface WalletGetCapabilitiesRequest extends Omit { + type: EthMethod.WalletGetCapabilities + account: Address // Wallet address + chainIds?: UniverseChainId[] // Optional array of chain IDs +} + +export interface WalletSendCallsRequest extends BaseRequest { + calls: Call[] + capabilities: Record + id: string + type: EthMethod.WalletSendCalls + version: string +} + +export interface WalletSendCallsEncodedRequest extends WalletSendCallsRequest { + encodedTransaction: EthTransaction + encodedRequestId: string +} + +export interface WalletGetCallsStatusRequest extends BaseRequest { + id: string + type: EthMethod.WalletGetCallsStatus +} + +export interface UwuLinkErc20Request extends BaseRequest { + type: UwULinkMethod.Erc20Send + recipient: { + address: string + name: string + logo?: { + dark?: string + light?: string + } + } + tokenAddress: string + amount: string + isStablecoin: boolean + transaction: EthTransaction // the formatted transaction, prepared by the wallet +} + +export type WalletConnectSigningRequest = + | SignRequest + | TransactionRequest + | UwuLinkErc20Request + | WalletSendCallsEncodedRequest + +type PersonalSignRequest = SignRequest & { + type: EthMethod.PersonalSign | EthMethod.EthSign +} + +export const isTransactionRequest = (request: WalletConnectSigningRequest): request is TransactionRequest => + request.type === EthMethod.EthSendTransaction || request.type === UwULinkMethod.Erc20Send + +export const isPersonalSignRequest = (request: WalletConnectSigningRequest): request is PersonalSignRequest => + request.type === EthMethod.PersonalSign || request.type === EthMethod.EthSign + +export const isBatchedTransactionRequest = ( + request: WalletConnectSigningRequest, +): request is WalletSendCallsEncodedRequest => request.type === EthMethod.WalletSendCalls + +export interface WalletConnectState { + sessions: { + [sessionId: string]: WalletConnectSession + } + pendingSession: WalletConnectPendingSession | null + pendingRequests: WalletConnectSigningRequest[] + didOpenFromDeepLink?: boolean + hasPendingSessionError?: boolean +} + +export const initialWalletConnectState: Readonly = { + sessions: {}, + pendingSession: null, + pendingRequests: [], +} + +const slice = createSlice({ + name: 'walletConnect', + initialState: initialWalletConnectState, + reducers: { + addSession: (state, action: PayloadAction<{ wcSession: WalletConnectSession }>) => { + const { wcSession } = action.payload + state.sessions[wcSession.id] = wcSession + state.pendingSession = null + }, + + replaceSession: (state, action: PayloadAction<{ wcSession: WalletConnectSession }>) => { + const { wcSession } = action.payload + state.sessions[wcSession.id] = wcSession + }, + + removeSession: (state, action: PayloadAction<{ sessionId: string }>) => { + const { sessionId } = action.payload + + if (!state.sessions[sessionId]) { + logger.warn('walletConnect/walletConnectSlice.ts', 'removeSession', `Session ${sessionId} doesnt exist`) + } + + delete state.sessions[sessionId] + }, + + addPendingSession: (state, action: PayloadAction<{ wcSession: WalletConnectPendingSession }>) => { + const { wcSession } = action.payload + state.pendingSession = wcSession + }, + + removePendingSession: (state) => { + state.pendingSession = null + }, + + addRequest: (state, action: PayloadAction) => { + state.pendingRequests.push(action.payload) + }, + + removeRequest: (state, action: PayloadAction<{ requestInternalId: string; account: string }>) => { + const { requestInternalId } = action.payload + state.pendingRequests = state.pendingRequests.filter((req) => req.internalId !== requestInternalId) + }, + + setDidOpenFromDeepLink: (state, action: PayloadAction) => { + state.didOpenFromDeepLink = action.payload + }, + + setHasPendingSessionError: (state, action: PayloadAction) => { + state.hasPendingSessionError = action.payload + }, + resetWalletConnect: () => initialWalletConnectState, + }, +}) + +export const { + addSession, + replaceSession, + removeSession, + addPendingSession, + removePendingSession, + addRequest, + removeRequest, + setDidOpenFromDeepLink, + setHasPendingSessionError, + resetWalletConnect, +} = slice.actions +export const { reducer: walletConnectReducer } = slice diff --git a/apps/mobile/src/features/widgets/widgets.ts b/apps/mobile/src/features/widgets/widgets.ts new file mode 100644 index 00000000..fcee0357 --- /dev/null +++ b/apps/mobile/src/features/widgets/widgets.ts @@ -0,0 +1,127 @@ +import { NativeModules } from 'react-native' +import { getItem, reloadAllTimelines, setItem } from 'react-native-widgetkit' +import { getBuildVariant } from 'src/utils/version' +import { AccountType } from 'uniswap/src/features/accounts/types' +import { currencyIdToContractInput } from 'uniswap/src/features/dataApi/utils/currencyIdToContractInput' +import { MobileEventName } from 'uniswap/src/features/telemetry/constants' +import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { CurrencyId } from 'uniswap/src/types/currency' +import { WidgetEvent } from 'uniswap/src/types/widgets' +import { isAndroid } from 'utilities/src/platform' +// oxlint-disable-next-line no-restricted-imports -- Required for analytics initialization +import { analytics } from 'utilities/src/telemetry/analytics/analytics' +import { Account } from 'wallet/src/features/wallet/accounts/types' + +const APP_GROUP = 'group.com.uniswap.widgets' +const KEY_WIDGET_EVENTS = getBuildVariant() + '.widgets.configuration.events' +const KEY_WIDGET_CACHE = getBuildVariant() + '.widgets.configuration.cache' +const KEY_WIDGETS_FAVORITE = getBuildVariant() + '.widgets.favorites' +const KEY_WIDGETS_ACCOUNTS = getBuildVariant() + '.widgets.accounts' +const KEY_WIDGETS_I18N = getBuildVariant() + '.widgets.i18n' + +const { RNWidgets } = NativeModules + +type WidgetEventsData = { + events: WidgetEvent[] +} + +type WidgetCacheData = { + configuration: WidgetConfiguration[] +} + +type WidgetConfiguration = { + kind: string + family: string +} + +type WidgetI18nSettings = { + locale: string + currency: string +} + +async function setUserDefaults(data: object, key: string): Promise { + const dataJSON = JSON.stringify(data) + await setItem(key, dataJSON, APP_GROUP) + reloadAllTimelines() +} + +export const setFavoritesUserDefaults = (currencyIds: CurrencyId[]): void => { + const favorites: Array<{ address: Maybe; chain: string }> = [] + currencyIds.forEach((currencyId: CurrencyId) => { + const contractInput = currencyIdToContractInput(currencyId) + favorites.push({ address: contractInput.address, chain: contractInput.chain }) + }) + const data = { + favorites, + } + setUserDefaults(data, KEY_WIDGETS_FAVORITE).catch(() => undefined) +} + +export const setAccountAddressesUserDefaults = (accounts: Account[]): void => { + const userDefaultAccounts: Array<{ address: string; name: Maybe; isSigner: boolean }> = accounts.map( + (account: Account) => { + return { + address: account.address, + name: account.name, + isSigner: account.type === AccountType.SignerMnemonic, + } + }, + ) + const data = { + accounts: userDefaultAccounts, + } + setUserDefaults(data, KEY_WIDGETS_ACCOUNTS).catch(() => undefined) +} + +export const setI18NUserDefaults = (i18nSettings: WidgetI18nSettings): void => { + setUserDefaults(i18nSettings, KEY_WIDGETS_I18N).catch(() => undefined) +} + +// handles edge case where there is a widget left in the cache, +// but no configured widgets, and no widgets to call getTimeline() in order to update the cache +// and send out the last removed event +async function handleLastRemovalEvents(): Promise { + const areWidgetsInstalled = await hasWidgetsInstalled() + if (!areWidgetsInstalled) { + const widgetCacheJSONString = await getItem(KEY_WIDGET_CACHE, APP_GROUP) + if (!widgetCacheJSONString) { + return + } + const widgetCache: WidgetCacheData = JSON.parse(widgetCacheJSONString) + widgetCache.configuration.forEach((widget) => { + sendAnalyticsEvent(MobileEventName.WidgetConfigurationUpdated, { + kind: widget.kind, + family: widget.family, + change: 'removed', + }) + }) + await setUserDefaults({ configuration: [] }, KEY_WIDGET_CACHE) + } +} + +export async function processWidgetEvents(): Promise { + reloadAllTimelines() + await handleLastRemovalEvents() + const widgetEventsJSONString = await getItem(KEY_WIDGET_EVENTS, APP_GROUP) + + if (!widgetEventsJSONString) { + return + } + const widgetEvents: WidgetEventsData = JSON.parse(widgetEventsJSONString) + widgetEvents.events.forEach((widget) => { + sendAnalyticsEvent(MobileEventName.WidgetConfigurationUpdated, widget) + }) + + if (widgetEvents.events.length > 0) { + analytics.flushEvents() + await setUserDefaults({ events: [] }, KEY_WIDGET_EVENTS) + } +} + +async function hasWidgetsInstalled(): Promise { + if (isAndroid) { + return false + } + // oxlint-disable-next-line typescript/no-unsafe-return + return await RNWidgets.hasWidgetsInstalled() +} diff --git a/apps/mobile/src/index.ts b/apps/mobile/src/index.ts new file mode 100644 index 00000000..57e62a1e --- /dev/null +++ b/apps/mobile/src/index.ts @@ -0,0 +1,4 @@ +// oxlint-disable-next-line typescript/triple-slash-reference +/// + +export {} diff --git a/apps/mobile/src/logbox.js b/apps/mobile/src/logbox.js new file mode 100644 index 00000000..12bd936f --- /dev/null +++ b/apps/mobile/src/logbox.js @@ -0,0 +1,22 @@ +import { LogBox } from 'react-native' + +if (process.env.IS_E2E_TEST === 'true') { + LogBox.ignoreAllLogs() +} else { + // Ignore errors coming from AnimatedComponent, either from React Native itself or possibly an animation lib + // https://github.com/facebook/react-native/issues/22186 + LogBox.ignoreLogs([ + 'Warning: Using UNSAFE_componentWillMount', + 'Warning: Using UNSAFE_componentWillReceiveProps', + // https://github.com/software-mansion/react-native-gesture-handler/issues/1036 + 'Warning: findNodeHandle', + // https://github.com/d3/d3-interpolate/issues/99 + 'Require cycle', + 'logException:ApolloClient [GraphQL Error]:', + 'logException:ApolloClient [Network Error]:', + // Ignore since it's difficult to filter out just these styles and they are often shared styles + 'FlashList only supports padding related props and backgroundColor in contentContainerStyle.', + // https://docs.swmansion.com/react-native-reanimated/docs/guides/troubleshooting#reduced-motion-setting-is-enabled-on-this-device + '[Reanimated] Reduced motion setting is enabled on this device.', + ]) +} diff --git a/apps/mobile/src/notification-service/MobileNotificationService.ts b/apps/mobile/src/notification-service/MobileNotificationService.ts new file mode 100644 index 00000000..5b61d230 --- /dev/null +++ b/apps/mobile/src/notification-service/MobileNotificationService.ts @@ -0,0 +1,208 @@ +import { queryOptions } from '@tanstack/react-query' +import { PlatformType } from '@uniswap/client-notification-service/dist/uniswap/notificationservice/v1/api_pb' +import { + createFetchClient, + createNotificationsApiClient, + getEntryGatewayUrl, + provideSessionService, + SharedQueryClient, +} from '@universe/api' +import { getIsSessionServiceEnabled } from '@universe/gating' +import { + createApiNotificationTracker, + createBaseNotificationProcessor, + createNotificationService, + createPollingNotificationDataSource, + createReactiveDataSource, + getNotificationQueryOptions, + type NotificationService, +} from '@universe/notifications' +import { Appearance } from 'react-native' +import DeviceInfo from 'react-native-device-info' +import { MobileState } from 'src/app/mobileReducer' +import { store } from 'src/app/store' +import { createMobileStorageAdapter } from 'src/notification-service/createMobileStorageAdapter' +import { createForceUpgradeNotificationDataSource } from 'src/notification-service/data-sources/createForceUpgradeNotificationDataSource' +import { createMobileLegacyBannersNotificationDataSource } from 'src/notification-service/data-sources/createMobileLegacyBannersNotificationDataSource' +import { createOfflineCondition } from 'src/notification-service/data-sources/reactive/offlineCondition' +import { handleNotificationNavigation } from 'src/notification-service/handleNotificationNavigation' +import { createMobileNotificationRenderer } from 'src/notification-service/notification-renderer/createMobileNotificationRenderer' +import { mobileNotificationStore } from 'src/notification-service/notification-renderer/notificationStore' +import { getNotificationTelemetry } from 'src/notification-service/notification-telemetry/getNotificationTelemetry' +import { createMobileLocalTriggerDataSource } from 'src/notification-service/triggers/createMobileLocalTriggerDataSource' +import { getPortfolioQuery } from 'uniswap/src/data/rest/getPortfolio' +import { AppearanceSettingType } from 'uniswap/src/features/appearance/slice' +import { mapLocaleToBackendLocale } from 'uniswap/src/features/language/constants' +import { getLocale } from 'uniswap/src/features/language/navigatorLocale' +import { selectCurrentLanguage } from 'uniswap/src/features/settings/selectors' +import { isDevEnv } from 'utilities/src/environment/env' +import { REQUEST_SOURCE } from 'utilities/src/platform/requestSource' +import { ReactQueryCacheKey } from 'utilities/src/reactQuery/cache' +import { type QueryOptionsResult } from 'utilities/src/reactQuery/queryOptions' +import { ONE_MINUTE_MS, ONE_SECOND_MS } from 'utilities/src/time/time' +import { selectActiveAccountAddress } from 'wallet/src/features/wallet/selectors' + +/** + * Creates the notification service with all necessary dependencies + */ +function provideMobileNotificationService(ctx: { getIsApiDataSourceEnabled: () => boolean }): NotificationService { + const notifApiBaseUrl = getEntryGatewayUrl() + + const fetchClient = createFetchClient({ + baseUrl: notifApiBaseUrl, + getHeaders: () => { + const currentLanguage = selectCurrentLanguage(store.getState()) + const locale = getLocale(currentLanguage) + const backendLocale = mapLocaleToBackendLocale(locale) + + const version = DeviceInfo.getVersion() + const semver = version.split('.').length === 2 ? `${version}.0` : version + + return { + 'Content-Type': 'application/json', + 'x-request-source': REQUEST_SOURCE, + 'x-uniswap-locale': backendLocale, + 'x-app-version': semver, + } + }, + getSessionService: () => + provideSessionService({ getBaseUrl: () => getEntryGatewayUrl(), getIsSessionServiceEnabled }), + }) + + const apiClient = createNotificationsApiClient({ + fetchClient, + getApiPathPrefix: () => '', // Empty prefix if the full path is in the base URL + }) + + const notificationQueryOptions = getNotificationQueryOptions({ + apiClient, + getPlatformType: () => PlatformType.MOBILE, + pollIntervalMs: 2 * ONE_MINUTE_MS, + }) + + const backendDataSource = createPollingNotificationDataSource({ + queryClient: SharedQueryClient, + queryOptions: notificationQueryOptions, + }) + + const tracker = createApiNotificationTracker({ + notificationsApiClient: apiClient, + queryClient: SharedQueryClient, + storage: createMobileStorageAdapter(), + }) + + const bannersDataSource = createMobileLegacyBannersNotificationDataSource({ + tracker, + pollIntervalMs: 10 * ONE_SECOND_MS, + // oxlint-disable-next-line typescript/no-unsafe-return + getState: (): MobileState => store.getState(), + getIsDarkMode: (): boolean => { + const state = store.getState() + const appearanceSetting = state.appearanceSettings?.selectedAppearanceSettings ?? AppearanceSettingType.System + if (appearanceSetting === AppearanceSettingType.Dark) { + return true + } + if (appearanceSetting === AppearanceSettingType.Light) { + return false + } + // System theme - check device appearance + return Appearance.getColorScheme() === 'dark' + }, + }) + + const processor = createBaseNotificationProcessor(tracker) + + const renderer = createMobileNotificationRenderer({ + store: mobileNotificationStore, + }) + + const telemetry = getNotificationTelemetry() + + const forceUpgradeDataSource = createForceUpgradeNotificationDataSource({ + pollIntervalMs: 30000, // Check every 30 seconds + }) + + /** + * Fetches the portfolio value for the active account. + * Used by local triggers that need to check portfolio-based conditions. + */ + const getPortfolioValue = async (): Promise => { + const state = store.getState() + const evmAddress = selectActiveAccountAddress(state) + + if (!evmAddress) { + return 0 + } + + const queryOpts = getPortfolioQuery({ input: { evmAddress } }) + const portfolioData = await SharedQueryClient.fetchQuery(queryOpts) + return portfolioData?.portfolio?.totalValueUsd ?? 0 + } + + const localTriggersDataSource = createMobileLocalTriggerDataSource({ + // oxlint-disable-next-line typescript/no-unsafe-return + getState: (): MobileState => store.getState(), + dispatch: store.dispatch, + tracker, + getPortfolioValue, + pollIntervalMs: 5000, + }) + + // Reactive data source for offline banner - reacts immediately to network state changes + // Note: Disabled in __DEV__ mode since the simulator has unreliable network state + // https://github.com/react-native-netinfo/react-native-netinfo/issues/7 + const offlineDataSource = + isDevEnv() || __DEV__ + ? undefined + : createReactiveDataSource({ + condition: createOfflineCondition({ + // oxlint-disable-next-line typescript/no-unsafe-return + getState: (): MobileState => store.getState(), + }), + tracker, + source: 'system_alerts', + logFileTag: 'offlineCondition', + }) + + const dataSources = ctx.getIsApiDataSourceEnabled() + ? [ + backendDataSource, + bannersDataSource, + forceUpgradeDataSource, + localTriggersDataSource, + ...(offlineDataSource ? [offlineDataSource] : []), + ] + : [ + bannersDataSource, + forceUpgradeDataSource, + localTriggersDataSource, + ...(offlineDataSource ? [offlineDataSource] : []), + ] + + const notificationService = createNotificationService({ + dataSources, + tracker, + processor, + renderer, + telemetry, + onNavigate: handleNotificationNavigation, + }) + + return notificationService +} + +/** + * Query options factory for the notification service + */ +export function getNotificationServiceQueryOptions(ctx: { + getIsEnabled: () => boolean + getIsApiDataSourceEnabled: () => boolean +}): QueryOptionsResult { + return queryOptions({ + queryKey: [ReactQueryCacheKey.NotificationService], + queryFn: () => provideMobileNotificationService(ctx), + enabled: ctx.getIsEnabled(), + staleTime: Infinity, // Never refetch while mounted + gcTime: 0, // Don't persist in cache - NotificationService has methods that can't be serialized + }) +} diff --git a/apps/mobile/src/notification-service/MobileNotificationServiceManager.tsx b/apps/mobile/src/notification-service/MobileNotificationServiceManager.tsx new file mode 100644 index 00000000..9ea0fe93 --- /dev/null +++ b/apps/mobile/src/notification-service/MobileNotificationServiceManager.tsx @@ -0,0 +1,80 @@ +import { useQuery } from '@tanstack/react-query' +import { FeatureFlags, useFeatureFlag } from '@universe/gating' +import { getIsNotificationServiceLocalOverrideEnabled } from '@universe/notifications' +import React, { useEffect } from 'react' +import { getNotificationServiceQueryOptions } from 'src/notification-service/MobileNotificationService' +import { NotificationContainer } from 'src/notification-service/notification-renderer/NotificationContainer' +import { getLogger } from 'utilities/src/logger/logger' + +/** + * Manages the lifecycle of the notification service on mobile. + * + * This component: + * - Creates the notification service via React Query + * - Initializes the service on mount + * - Destroys the service on unmount + * - Renders the NotificationContainer for displaying notifications + * + * Usage: + * Replace the legacy OnboardingIntroCardStack with this component in HomeScreen + * when the NotificationService feature flag is enabled. + */ +export function MobileNotificationServiceManager({ + isLoading = false, +}: { + isLoading?: boolean +}): React.JSX.Element | null { + const isNotificationServiceEnabledFlag = useFeatureFlag(FeatureFlags.NotificationService) + const isNotificationServiceEnabled = + getIsNotificationServiceLocalOverrideEnabled() || isNotificationServiceEnabledFlag + const isApiDataSourceEnabledFlag = useFeatureFlag(FeatureFlags.NotificationApiDataSource) + + const { data: notificationService } = useQuery( + getNotificationServiceQueryOptions({ + getIsEnabled: () => isNotificationServiceEnabled, + getIsApiDataSourceEnabled: () => isApiDataSourceEnabledFlag, + }), + ) + + useEffect(() => { + if (!notificationService) { + return undefined + } + + notificationService.initialize().catch((error) => { + getLogger().error(error, { + tags: { file: 'MobileNotificationServiceManager', function: 'initialize' }, + extra: { message: 'Failed to initialize notification service' }, + }) + }) + + return () => { + notificationService.destroy() + } + }, [notificationService]) + + useEffect(() => { + if (isLoading || !notificationService) { + return + } + + notificationService.refresh().catch((error) => { + getLogger().error(error, { + tags: { file: 'MobileNotificationServiceManager', function: 'refresh' }, + extra: { message: 'Failed to refresh notifications after profile load' }, + }) + }) + }, [isLoading, notificationService]) + + if (!isNotificationServiceEnabled || !notificationService) { + return null + } + + return ( + + ) +} diff --git a/apps/mobile/src/notification-service/createMobileStorageAdapter.test.ts b/apps/mobile/src/notification-service/createMobileStorageAdapter.test.ts new file mode 100644 index 00000000..cc84d021 --- /dev/null +++ b/apps/mobile/src/notification-service/createMobileStorageAdapter.test.ts @@ -0,0 +1,289 @@ +export {} + +const mockMMKV = { + getString: jest.fn(), + set: jest.fn(), +} + +jest.mock('react-native-mmkv', () => ({ + MMKV: jest.fn().mockImplementation(() => mockMMKV), +})) + +const mockLoggerError = jest.fn() +jest.mock('utilities/src/logger/logger', () => ({ + getLogger: (): { error: jest.Mock } => ({ + error: mockLoggerError, + }), +})) + +describe('createMobileStorageAdapter', () => { + let createMobileStorageAdapter: typeof import('./createMobileStorageAdapter').createMobileStorageAdapter + + beforeEach(() => { + jest.clearAllMocks() + jest.resetModules() + jest.doMock('react-native-mmkv', () => ({ + MMKV: jest.fn().mockImplementation(() => mockMMKV), + })) + jest.doMock('utilities/src/logger/logger', () => ({ + getLogger: (): { error: jest.Mock } => ({ + error: mockLoggerError, + }), + })) + createMobileStorageAdapter = require('./createMobileStorageAdapter').createMobileStorageAdapter + }) + + describe('has', () => { + it('returns true when notification ID exists in storage', async () => { + mockMMKV.getString.mockReturnValue( + JSON.stringify({ + 'notif-1': { timestamp: 1000 }, + }), + ) + + const adapter = createMobileStorageAdapter() + const result = await adapter.has('notif-1') + + expect(result).toBe(true) + }) + + it('returns false when notification ID does not exist in storage', async () => { + mockMMKV.getString.mockReturnValue( + JSON.stringify({ + 'notif-1': { timestamp: 1000 }, + }), + ) + + const adapter = createMobileStorageAdapter() + const result = await adapter.has('notif-2') + + expect(result).toBe(false) + }) + + it('returns false when storage is empty', async () => { + mockMMKV.getString.mockReturnValue(null) + + const adapter = createMobileStorageAdapter() + const result = await adapter.has('notif-1') + + expect(result).toBe(false) + }) + + it('returns false and logs error when JSON parsing fails', async () => { + mockMMKV.getString.mockReturnValue('invalid json') + + const adapter = createMobileStorageAdapter() + const result = await adapter.has('notif-1') + + expect(result).toBe(false) + expect(mockLoggerError).toHaveBeenCalled() + }) + + it('returns false and logs error when schema validation fails', async () => { + mockMMKV.getString.mockReturnValue( + JSON.stringify({ + 'notif-1': { invalidField: 'wrong' }, + }), + ) + + const adapter = createMobileStorageAdapter() + const result = await adapter.has('notif-1') + + expect(result).toBe(false) + expect(mockLoggerError).toHaveBeenCalled() + }) + }) + + describe('add', () => { + it('adds notification ID with provided timestamp', async () => { + mockMMKV.getString.mockReturnValue(JSON.stringify({})) + + const adapter = createMobileStorageAdapter() + await adapter.add('notif-1', { timestamp: 1234567890 }) + + expect(mockMMKV.set).toHaveBeenCalledWith( + 'uniswap_notifications_processed', + JSON.stringify({ 'notif-1': { timestamp: 1234567890 } }), + ) + }) + + it('adds notification ID with current timestamp when no metadata provided', async () => { + mockMMKV.getString.mockReturnValue(JSON.stringify({})) + const mockNow = 1700000000000 + jest.spyOn(Date, 'now').mockReturnValue(mockNow) + + const adapter = createMobileStorageAdapter() + await adapter.add('notif-1') + + expect(mockMMKV.set).toHaveBeenCalledWith( + 'uniswap_notifications_processed', + JSON.stringify({ 'notif-1': { timestamp: mockNow } }), + ) + + jest.restoreAllMocks() + }) + + it('preserves existing entries when adding new notification', async () => { + mockMMKV.getString.mockReturnValue( + JSON.stringify({ + 'notif-1': { timestamp: 1000 }, + }), + ) + + const adapter = createMobileStorageAdapter() + await adapter.add('notif-2', { timestamp: 2000 }) + + expect(mockMMKV.set).toHaveBeenCalledWith( + 'uniswap_notifications_processed', + JSON.stringify({ + 'notif-1': { timestamp: 1000 }, + 'notif-2': { timestamp: 2000 }, + }), + ) + }) + + it('overwrites existing entry with same notification ID', async () => { + mockMMKV.getString.mockReturnValue( + JSON.stringify({ + 'notif-1': { timestamp: 1000 }, + }), + ) + + const adapter = createMobileStorageAdapter() + await adapter.add('notif-1', { timestamp: 2000 }) + + expect(mockMMKV.set).toHaveBeenCalledWith( + 'uniswap_notifications_processed', + JSON.stringify({ + 'notif-1': { timestamp: 2000 }, + }), + ) + }) + + it('logs error when save fails', async () => { + mockMMKV.getString.mockReturnValue(JSON.stringify({})) + mockMMKV.set.mockImplementation(() => { + throw new Error('Storage full') + }) + + const adapter = createMobileStorageAdapter() + await adapter.add('notif-1', { timestamp: 1000 }) + + expect(mockLoggerError).toHaveBeenCalled() + }) + }) + + describe('getAll', () => { + it('returns all notification IDs as a Set', async () => { + mockMMKV.getString.mockReturnValue( + JSON.stringify({ + 'notif-1': { timestamp: 1000 }, + 'notif-2': { timestamp: 2000 }, + 'notif-3': { timestamp: 3000 }, + }), + ) + + const adapter = createMobileStorageAdapter() + const result = await adapter.getAll() + + expect(result).toEqual(new Set(['notif-1', 'notif-2', 'notif-3'])) + }) + + it('returns empty Set when storage is empty', async () => { + mockMMKV.getString.mockReturnValue(null) + + const adapter = createMobileStorageAdapter() + const result = await adapter.getAll() + + expect(result).toEqual(new Set()) + }) + + it('returns empty Set when storage contains invalid data', async () => { + mockMMKV.getString.mockReturnValue('not valid json') + + const adapter = createMobileStorageAdapter() + const result = await adapter.getAll() + + expect(result).toEqual(new Set()) + expect(mockLoggerError).toHaveBeenCalled() + }) + }) + + describe('deleteOlderThan', () => { + it('removes entries older than specified timestamp', async () => { + mockMMKV.getString.mockReturnValue( + JSON.stringify({ + 'notif-old': { timestamp: 1000 }, + 'notif-new': { timestamp: 3000 }, + }), + ) + + const adapter = createMobileStorageAdapter() + await adapter.deleteOlderThan(2000) + + expect(mockMMKV.set).toHaveBeenCalledWith( + 'uniswap_notifications_processed', + JSON.stringify({ + 'notif-new': { timestamp: 3000 }, + }), + ) + }) + + it('removes entries with timestamp equal to threshold', async () => { + mockMMKV.getString.mockReturnValue( + JSON.stringify({ + 'notif-exact': { timestamp: 2000 }, + 'notif-old': { timestamp: 1999 }, + }), + ) + + const adapter = createMobileStorageAdapter() + await adapter.deleteOlderThan(2000) + + expect(mockMMKV.set).toHaveBeenCalledWith('uniswap_notifications_processed', JSON.stringify({})) + }) + + it('keeps entries with timestamp greater than threshold', async () => { + mockMMKV.getString.mockReturnValue( + JSON.stringify({ + 'notif-1': { timestamp: 2001 }, + 'notif-2': { timestamp: 3000 }, + }), + ) + + const adapter = createMobileStorageAdapter() + await adapter.deleteOlderThan(2000) + + expect(mockMMKV.set).toHaveBeenCalledWith( + 'uniswap_notifications_processed', + JSON.stringify({ + 'notif-1': { timestamp: 2001 }, + 'notif-2': { timestamp: 3000 }, + }), + ) + }) + + it('handles empty storage gracefully', async () => { + mockMMKV.getString.mockReturnValue(null) + + const adapter = createMobileStorageAdapter() + await adapter.deleteOlderThan(2000) + + expect(mockMMKV.set).toHaveBeenCalledWith('uniswap_notifications_processed', JSON.stringify({})) + }) + + it('removes all entries when all are older than threshold', async () => { + mockMMKV.getString.mockReturnValue( + JSON.stringify({ + 'notif-1': { timestamp: 1000 }, + 'notif-2': { timestamp: 1500 }, + }), + ) + + const adapter = createMobileStorageAdapter() + await adapter.deleteOlderThan(2000) + + expect(mockMMKV.set).toHaveBeenCalledWith('uniswap_notifications_processed', JSON.stringify({})) + }) + }) +}) diff --git a/apps/mobile/src/notification-service/createMobileStorageAdapter.ts b/apps/mobile/src/notification-service/createMobileStorageAdapter.ts new file mode 100644 index 00000000..b2ee2c92 --- /dev/null +++ b/apps/mobile/src/notification-service/createMobileStorageAdapter.ts @@ -0,0 +1,100 @@ +import type { ApiNotificationTrackerContext } from '@universe/notifications' +import { MMKV } from 'react-native-mmkv' +import { getLogger } from 'utilities/src/logger/logger' +import { z } from 'zod' + +const NOTIFICATION_STORAGE_KEY = 'uniswap_notifications_processed' + +const NotificationStorageSchema = z.record( + z.string(), + z.object({ + timestamp: z.number(), + }), +) + +type NotificationStorage = z.infer + +// Create a dedicated MMKV instance for notifications +// This keeps notification data separate from Redux persist storage +const notificationStorage = new MMKV({ id: 'notifications' }) + +/** + * Parses and validates notification storage data from MMKV + * @param functionName - Name of the calling function for error logging + * @returns Validated storage data or empty object if parsing fails + */ +function parseNotificationStorage(functionName: string): NotificationStorage { + try { + const stored = notificationStorage.getString(NOTIFICATION_STORAGE_KEY) + + if (!stored) { + return {} + } + + const parsed = NotificationStorageSchema.safeParse(JSON.parse(stored)) + if (!parsed.success) { + getLogger().error(parsed.error, { + tags: { file: 'createMobileStorageAdapter', function: functionName }, + }) + return {} + } + return parsed.data + } catch (error) { + getLogger().error(error, { + tags: { file: 'createMobileStorageAdapter', function: functionName }, + }) + return {} + } +} + +/** + * Saves notification storage data to MMKV + * @param data - The data to save + * @param functionName - Name of the calling function for error logging + */ +function saveNotificationStorage(data: NotificationStorage, functionName: string): void { + try { + notificationStorage.set(NOTIFICATION_STORAGE_KEY, JSON.stringify(data)) + } catch (error) { + getLogger().error(error, { + tags: { file: 'createMobileStorageAdapter', function: functionName }, + }) + } +} + +/** + * Creates an MMKV storage adapter that implements the storage interface + * required by the API notification tracker. + * + * This adapter stores notification IDs with timestamps to enable: + * - Offline tracking (when API calls fail) + * - Deduplication (avoid sending duplicate ACKs) + * - Cleanup of old entries + */ +export function createMobileStorageAdapter(): NonNullable { + return { + has: async (notificationId: string): Promise => { + const processedIds = parseNotificationStorage('has') + return notificationId in processedIds + }, + + add: async (notificationId: string, metadata?: { timestamp: number }): Promise => { + const processedIds = parseNotificationStorage('add') + processedIds[notificationId] = { timestamp: metadata?.timestamp ?? Date.now() } + saveNotificationStorage(processedIds, 'add') + }, + + getAll: async (): Promise> => { + const processedIds = parseNotificationStorage('getAll') + return new Set(Object.keys(processedIds)) + }, + + deleteOlderThan: async (timestamp: number): Promise => { + const processedIds = parseNotificationStorage('deleteOlderThan') + const filtered = Object.fromEntries( + Object.entries(processedIds).filter(([, value]) => value.timestamp > timestamp), + ) + saveNotificationStorage(filtered, 'deleteOlderThan') + }, + } +} diff --git a/apps/mobile/src/notification-service/data-sources/banners/fundWalletBanner.ts b/apps/mobile/src/notification-service/data-sources/banners/fundWalletBanner.ts new file mode 100644 index 00000000..575ac427 --- /dev/null +++ b/apps/mobile/src/notification-service/data-sources/banners/fundWalletBanner.ts @@ -0,0 +1,72 @@ +import { + Background, + Content, + Notification, + NotificationVersion, + OnClick, +} from '@uniswap/client-notification-service/dist/uniswap/notificationservice/v1/api_pb' +import { BackgroundType, ContentStyle, type InAppNotification, OnClickAction } from '@universe/api' +import type { MobileState } from 'src/app/mobileReducer' +import { BannerId, MOBILE_NAV_PREFIX } from 'src/notification-service/data-sources/banners/types' +import { AccountType } from 'uniswap/src/features/accounts/types' +import { ModalName } from 'uniswap/src/features/telemetry/constants' +import i18n from 'uniswap/src/i18n' +import { selectHasBalanceOrActivityForAddress } from 'wallet/src/features/wallet/selectors' + +/** + * Check if Fund Wallet banner should be shown. + * Shows when: + * - Active account is a signer account (not view-only) + * - Wallet has no balance or activity (empty wallet state) + */ +export async function checkFundWalletBanner(getState: () => MobileState): Promise { + const state = getState() + const activeAddress = state.wallet.activeAccountAddress + if (!activeAddress) { + return null + } + + const activeAccount = state.wallet.accounts[activeAddress] + if (!activeAccount || activeAccount.type !== AccountType.SignerMnemonic) { + return null + } + + // Check if wallet has balance or activity - if it does, don't show the fund wallet card + const hasBalanceOrActivity = selectHasBalanceOrActivityForAddress(state, activeAddress) + if (hasBalanceOrActivity !== false) { + return null + } + + return createFundWalletBanner() +} + +/** + * Create Fund Wallet banner notification + */ +function createFundWalletBanner(): InAppNotification { + const fundWalletLink = `${MOBILE_NAV_PREFIX}modal/${ModalName.FundWallet}` + + return new Notification({ + id: BannerId.FundWallet, + content: new Content({ + version: NotificationVersion.V0, + style: ContentStyle.LOWER_LEFT_BANNER, + title: i18n.t('onboarding.home.intro.fund.title'), + subtitle: i18n.t('onboarding.home.intro.fund.description'), + background: new Background({ + backgroundType: BackgroundType.UNSPECIFIED, + backgroundOnClick: new OnClick({ + // No ACK here - required notifications should reappear until the user funds their wallet + // The notification will stop showing once hasBalanceOrActivity becomes true + onClick: [OnClickAction.EXTERNAL_LINK], + onClickLink: fundWalletLink, + }), + }), + // No onDismissClick - required cards cannot be dismissed + buttons: [], + iconLink: 'custom:coin-$accent1', + // Encode cardType in extra field for IntroCard rendering + extra: JSON.stringify({ cardType: 'required', graphicType: 'icon' }), + }), + }) +} diff --git a/apps/mobile/src/notification-service/data-sources/banners/noAppFeesBanner.ts b/apps/mobile/src/notification-service/data-sources/banners/noAppFeesBanner.ts new file mode 100644 index 00000000..c93b4aff --- /dev/null +++ b/apps/mobile/src/notification-service/data-sources/banners/noAppFeesBanner.ts @@ -0,0 +1,60 @@ +import { + Background, + Content, + Notification, + NotificationVersion, + OnClick, +} from '@uniswap/client-notification-service/dist/uniswap/notificationservice/v1/api_pb' +import { BackgroundType, ContentStyle, type InAppNotification, OnClickAction } from '@universe/api' +import { FeatureFlags, getFeatureFlag } from '@universe/gating' +import { BannerId } from 'src/notification-service/data-sources/banners/types' +import { + NO_FEES_ICON, + NO_UNISWAP_INTERFACE_FEES_BANNER_DARK, + NO_UNISWAP_INTERFACE_FEES_BANNER_LIGHT, +} from 'ui/src/assets' +import { uniswapUrls } from 'uniswap/src/constants/urls' +import i18n from 'uniswap/src/i18n' + +/** + * Check if No App Fees banner should be shown based on feature flag. + * The processor will filter based on tracked state.onNavigate + */ +export async function checkNoAppFeesBanner(isDarkMode: boolean): Promise { + const isEnabled = getFeatureFlag(FeatureFlags.NoUniswapInterfaceFees) + + if (!isEnabled) { + return null + } + + return createNoAppFeesBanner(isDarkMode) +} + +/** + * Create No App Fees banner notification + */ +function createNoAppFeesBanner(isDarkMode: boolean): InAppNotification { + return new Notification({ + id: BannerId.NoAppFees, + content: new Content({ + version: NotificationVersion.V0, + style: ContentStyle.LOWER_LEFT_BANNER, + title: i18n.t('notification.noAppFees.title'), + subtitle: i18n.t('notification.noAppFees.subtitle'), + iconLink: NO_FEES_ICON, + background: new Background({ + backgroundType: BackgroundType.IMAGE, + link: isDarkMode ? NO_UNISWAP_INTERFACE_FEES_BANNER_DARK : NO_UNISWAP_INTERFACE_FEES_BANNER_LIGHT, + backgroundOnClick: new OnClick({ + onClick: [OnClickAction.EXTERNAL_LINK, OnClickAction.DISMISS, OnClickAction.ACK], + onClickLink: uniswapUrls.helpArticleUrls.swapFeeInfo, + }), + }), + onDismissClick: new OnClick({ + onClick: [OnClickAction.DISMISS, OnClickAction.ACK], + }), + buttons: [], + extra: JSON.stringify({ cardType: 'dismissible', graphicType: 'gradient' }), + }), + }) +} diff --git a/apps/mobile/src/notification-service/data-sources/banners/pushNotificationsBanner.ts b/apps/mobile/src/notification-service/data-sources/banners/pushNotificationsBanner.ts new file mode 100644 index 00000000..1f67bf13 --- /dev/null +++ b/apps/mobile/src/notification-service/data-sources/banners/pushNotificationsBanner.ts @@ -0,0 +1,73 @@ +import { + Background, + Content, + Notification, + NotificationVersion, + OnClick, +} from '@uniswap/client-notification-service/dist/uniswap/notificationservice/v1/api_pb' +import { BackgroundType, ContentStyle, type InAppNotification, OnClickAction } from '@universe/api' +import { FeatureFlags, getFeatureFlag } from '@universe/gating' +import { checkNotifications, type PermissionStatus } from 'react-native-permissions' +import { BannerId } from 'src/notification-service/data-sources/banners/types' +import { PUSH_NOTIFICATIONS_CARD_BANNER } from 'ui/src/assets' +import { ModalName } from 'uniswap/src/features/telemetry/constants' +import i18n from 'uniswap/src/i18n' + +async function getPushPermissionStatus(): Promise { + const { status } = await checkNotifications() + return status +} + +/** + * Check if Push Notifications banner should be shown. + * Shows when: + * - NotificationOnboardingCard feature flag is enabled + * - Push notifications are NOT already granted + */ +export async function checkPushNotificationsBanner(): Promise { + const isEnabled = getFeatureFlag(FeatureFlags.NotificationOnboardingCard) + + if (!isEnabled) { + return null + } + + const permissionStatus = await getPushPermissionStatus() + + // Only show if notifications are not granted + if (permissionStatus === 'granted') { + return null + } + + return createPushNotificationsBanner() +} + +/** + * Create Push Notifications banner notification + */ +function createPushNotificationsBanner(): InAppNotification { + // Mobile navigation: open the notifications OS settings modal + const notificationsLink = `mobile://modal/${ModalName.NotificationsOSSettings}` + + return new Notification({ + id: BannerId.PushNotifications, + content: new Content({ + version: NotificationVersion.V0, + style: ContentStyle.LOWER_LEFT_BANNER, + title: i18n.t('onboarding.home.intro.pushNotifications.title'), + subtitle: i18n.t('onboarding.home.intro.pushNotifications.description'), + background: new Background({ + backgroundType: BackgroundType.IMAGE, + link: PUSH_NOTIFICATIONS_CARD_BANNER, + backgroundOnClick: new OnClick({ + onClick: [OnClickAction.EXTERNAL_LINK, OnClickAction.DISMISS, OnClickAction.ACK], + onClickLink: notificationsLink, + }), + }), + onDismissClick: new OnClick({ + onClick: [OnClickAction.DISMISS, OnClickAction.ACK], + }), + buttons: [], + extra: JSON.stringify({ cardType: 'dismissible', graphicType: 'image' }), + }), + }) +} diff --git a/apps/mobile/src/notification-service/data-sources/banners/recoveryBackupBanner.ts b/apps/mobile/src/notification-service/data-sources/banners/recoveryBackupBanner.ts new file mode 100644 index 00000000..75fe0258 --- /dev/null +++ b/apps/mobile/src/notification-service/data-sources/banners/recoveryBackupBanner.ts @@ -0,0 +1,64 @@ +import { + Background, + Content, + Notification, + NotificationVersion, + OnClick, +} from '@uniswap/client-notification-service/dist/uniswap/notificationservice/v1/api_pb' +import { BackgroundType, ContentStyle, type InAppNotification, OnClickAction } from '@universe/api' +import type { MobileState } from 'src/app/mobileReducer' +import { BannerId, MOBILE_NAV_PREFIX } from 'src/notification-service/data-sources/banners/types' +import { AccountType } from 'uniswap/src/features/accounts/types' +import i18n from 'uniswap/src/i18n' +import { hasExternalBackup } from 'wallet/src/features/wallet/accounts/utils' + +/** + * Check if recovery backup reminder should be shown. + */ +export async function checkRecoveryBackup(getState: () => MobileState): Promise { + const state = getState() + const activeAccount = state.wallet.accounts[state.wallet.activeAccountAddress ?? ''] + + if (!activeAccount || activeAccount.type !== AccountType.SignerMnemonic) { + return null + } + + const hasBackup = hasExternalBackup(activeAccount) + if (hasBackup) { + return null + } + + return createRecoveryBackupBanner() +} + +/** + * Create recovery backup banner notification + */ +function createRecoveryBackupBanner(): InAppNotification { + // Mobile navigation: navigate to "Choose your backup method" screen + const backupLink = `${MOBILE_NAV_PREFIX}backup` + + return new Notification({ + id: BannerId.RecoveryBackup, + content: new Content({ + version: NotificationVersion.V0, + style: ContentStyle.LOWER_LEFT_BANNER, + title: i18n.t('onboarding.home.intro.backup.title'), + subtitle: i18n.t('onboarding.home.intro.backup.description.mobile'), + background: new Background({ + backgroundType: BackgroundType.UNSPECIFIED, + backgroundOnClick: new OnClick({ + // No ACK here - required notifications should reappear until the user completes the backup + // The notification will stop showing once hasExternalBackup() returns true + onClick: [OnClickAction.EXTERNAL_LINK, OnClickAction.DISMISS], + onClickLink: backupLink, + }), + }), + // No onDismissClick - required cards cannot be dismissed + buttons: [], + iconLink: 'custom:shield-check-$accent1', + // Encode cardType in extra field for IntroCard rendering + extra: JSON.stringify({ cardType: 'required', graphicType: 'icon' }), + }), + }) +} diff --git a/apps/mobile/src/notification-service/data-sources/banners/types.ts b/apps/mobile/src/notification-service/data-sources/banners/types.ts new file mode 100644 index 00000000..9d79cc45 --- /dev/null +++ b/apps/mobile/src/notification-service/data-sources/banners/types.ts @@ -0,0 +1,15 @@ +// Navigation link prefixes for mobile +// These are parsed by the navigation handler in MobileNotificationService +export const MOBILE_NAV_PREFIX = 'mobile://' +export const UNITAG_NAV_PREFIX = 'unitag://' + +// Using 'local:' prefix to indicate these are client-only notifications +// This prevents the API tracker from sending AckNotification calls to the backend +export enum BannerId { + NoAppFees = 'local:no_app_fees_banner', + FundWallet = 'local:fund_wallet_banner', + + PushNotifications = 'local:push_notifications_banner', + RecoveryBackup = 'local:recovery_backup_banner', + UnitagClaim = 'local:unitag_claim_banner', +} diff --git a/apps/mobile/src/notification-service/data-sources/banners/unitagClaimBanner.ts b/apps/mobile/src/notification-service/data-sources/banners/unitagClaimBanner.ts new file mode 100644 index 00000000..f5f29642 --- /dev/null +++ b/apps/mobile/src/notification-service/data-sources/banners/unitagClaimBanner.ts @@ -0,0 +1,90 @@ +import { + Background, + Content, + Notification, + NotificationVersion, + OnClick, +} from '@uniswap/client-notification-service/dist/uniswap/notificationservice/v1/api_pb' +import { BackgroundType, ContentStyle, type InAppNotification, OnClickAction, SharedQueryClient } from '@universe/api' +import type { MobileState } from 'src/app/mobileReducer' +import { BannerId, UNITAG_NAV_PREFIX } from 'src/notification-service/data-sources/banners/types' +import { AccountType } from 'uniswap/src/features/accounts/types' +import { Platform } from 'uniswap/src/features/platforms/types/Platform' +import { UNITAG_SUFFIX_NO_LEADING_DOT } from 'uniswap/src/features/unitags/constants' +import i18n from 'uniswap/src/i18n' +import { UnitagScreens } from 'uniswap/src/types/screens/mobile' +import { getValidAddress } from 'uniswap/src/utils/addresses' +import { ReactQueryCacheKey } from 'utilities/src/reactQuery/cache' + +/** + * Check if Unitag claim prompt should be shown. + */ +export async function checkUnitagClaim(getState: () => MobileState): Promise { + const state = getState() + const activeAccount = state.wallet.accounts[state.wallet.activeAccountAddress ?? ''] + + if (!activeAccount || activeAccount.type !== AccountType.SignerMnemonic) { + return null + } + + // Check if any account has a unitag by reading from React Query cache + // Unitags are fetched via API and cached, not stored in Redux + const accounts = Object.values(state.wallet.accounts) as Array<{ address?: string }> + const hasAnyUnitag = accounts.some((account) => { + if (!account.address) { + return false + } + // Normalize the address the same way useUnitagsAddressQuery does + // This ensures we match the exact query key format used when caching + const validatedAddress = getValidAddress({ address: account.address, platform: Platform.EVM }) + if (!validatedAddress) { + return false + } + + // Read from React Query cache using the same query key as useUnitagsAddressQuery + const queryKey = [ReactQueryCacheKey.UnitagsApi, 'address', { address: validatedAddress }] + const cachedUnitag = SharedQueryClient.getQueryData<{ username?: string }>(queryKey) + + return !!cachedUnitag?.username + }) + + if (hasAnyUnitag) { + return null + } + + // Note: We can't check canClaimUnitag here since it requires an async query + // The notification will be shown and processor will handle dismissal if already claimed + return createUnitagClaimBanner() +} + +/** + * Create Unitag claim banner notification + */ +function createUnitagClaimBanner(): InAppNotification { + // Mobile navigation: navigate to UnitagStack > ClaimUnitag with entryPoint + const unitagClaimLink = `${UNITAG_NAV_PREFIX}${UnitagScreens.ClaimUnitag}` + + return new Notification({ + id: BannerId.UnitagClaim, + content: new Content({ + version: NotificationVersion.V0, + style: ContentStyle.LOWER_LEFT_BANNER, + title: i18n.t('onboarding.home.intro.unitag.title', { unitagDomain: UNITAG_SUFFIX_NO_LEADING_DOT }), + subtitle: i18n.t('onboarding.home.intro.unitag.description'), + background: new Background({ + backgroundType: BackgroundType.UNSPECIFIED, + backgroundOnClick: new OnClick({ + onClick: [OnClickAction.EXTERNAL_LINK, OnClickAction.DISMISS, OnClickAction.ACK], + onClickLink: unitagClaimLink, + }), + }), + onDismissClick: new OnClick({ + onClick: [OnClickAction.DISMISS, OnClickAction.ACK], + }), + buttons: [], + iconLink: 'custom:person-$accent1', + // Encode cardType in extra field for IntroCard rendering + extra: JSON.stringify({ cardType: 'dismissible', graphicType: 'icon' }), + }), + }) +} diff --git a/apps/mobile/src/notification-service/data-sources/createForceUpgradeNotificationDataSource.ts b/apps/mobile/src/notification-service/data-sources/createForceUpgradeNotificationDataSource.ts new file mode 100644 index 00000000..c179692a --- /dev/null +++ b/apps/mobile/src/notification-service/data-sources/createForceUpgradeNotificationDataSource.ts @@ -0,0 +1,126 @@ +import { + Background, + Button, + Content, + Notification, + NotificationVersion, + OnClick, +} from '@uniswap/client-notification-service/dist/uniswap/notificationservice/v1/api_pb' +import { BackgroundType, ContentStyle, type InAppNotification, OnClickAction } from '@universe/api' +import { DynamicConfigs, ForceUpgradeConfigKey, type ForceUpgradeStatus, getDynamicConfigValue } from '@universe/gating' +import { createIntervalNotificationDataSource, type NotificationDataSource } from '@universe/notifications' +import { UNISWAP_LOGO } from 'ui/src/assets' +import i18n from 'uniswap/src/i18n' +import { ONE_SECOND_MS } from 'utilities/src/time/time' +import { MOBILE_APP_STORE_LINK } from 'wallet/src/constants/urls' + +// Using 'local:' prefix to indicate these are client-only notifications +// This prevents the API tracker from sending AckNotification calls to the backend +enum ForceUpgradeNotificationId { + Required = 'local:force_upgrade_required_modal', + Recommended = 'local:force_upgrade_recommended_modal', +} + +interface CreateForceUpgradeNotificationDataSourceContext { + pollIntervalMs?: number +} + +/** + * Creates a notification data source specifically for force upgrade prompts. + * + * This data source: + * - Polls Statsig for force upgrade status on a configurable interval + * - Emits a modal notification when upgrade is required or recommended + * - Handles the "required" vs "recommended" distinction via different notification IDs + * + * The notification displays a modal with: + * - Update button: Opens the app store (iOS App Store or Google Play) + * - Backup button (required only): Navigates to seed phrase settings + * - Dismiss capability for recommended upgrades only + * + * Migration path: + * This local data source can be replaced by a backend-controlled data source in the future. + * The notification shape and rendering will remain the same - only the source changes. + */ +export function createForceUpgradeNotificationDataSource( + ctx: CreateForceUpgradeNotificationDataSourceContext, +): NotificationDataSource { + const { pollIntervalMs = 30 * ONE_SECOND_MS } = ctx // Poll every 30 seconds + + const getForceUpgradeStatus = (): ForceUpgradeStatus => { + const status = getDynamicConfigValue({ + config: DynamicConfigs.ForceUpgrade, + key: ForceUpgradeConfigKey.Status, + defaultValue: 'not-required' as ForceUpgradeStatus, + }) + + // `getDynamicConfigValue` is untyped; validate to avoid unsafe returns. + return isForceUpgradeStatus(status) ? status : 'not-required' + } + + const dataSource: NotificationDataSource = createIntervalNotificationDataSource({ + pollIntervalMs, + source: 'force_upgrade', + logFileTag: 'createForceUpgradeNotificationDataSource', + getNotifications: async () => { + const status = getForceUpgradeStatus() + if (status === 'not-required') { + return [] + } + return [createForceUpgradeNotification(status)] + }, + }) + + return dataSource +} + +function isForceUpgradeStatus(value: unknown): value is ForceUpgradeStatus { + return value === 'recommended' || value === 'required' || value === 'not-required' +} + +function createForceUpgradeNotification(status: ForceUpgradeStatus): InAppNotification { + const isRequired = status === 'required' + const notificationId = isRequired ? ForceUpgradeNotificationId.Required : ForceUpgradeNotificationId.Recommended + const updateButtonActions = isRequired + ? [OnClickAction.EXTERNAL_LINK, OnClickAction.DISMISS] + : [OnClickAction.EXTERNAL_LINK, OnClickAction.DISMISS, OnClickAction.ACK] + + // Only the Update button is configured here - the Backup button is handled + // internally by ForceUpgradeNotification component which shows seed phrase inline + const buttons: Button[] = [ + new Button({ + text: i18n.t('forceUpgrade.action.confirm'), + isPrimary: true, + onClick: new OnClick({ + // Required: no ACK (persists until app updated). Recommended: ACK to dismiss permanently + onClick: updateButtonActions, + onClickLink: MOBILE_APP_STORE_LINK, + }), + }), + ] + + return new Notification({ + id: notificationId, + content: new Content({ + version: NotificationVersion.V0, + style: ContentStyle.MODAL, + title: isRequired ? i18n.t('forceUpgrade.title') : i18n.t('forceUpgrade.title.recommendedStatus'), + subtitle: i18n.t('forceUpgrade.description.wallet'), + background: new Background({ + backgroundType: BackgroundType.UNSPECIFIED, + }), + // Required upgrades: no dismiss button. Recommended: dismissible with ACK + onDismissClick: isRequired + ? undefined + : new OnClick({ + onClick: [OnClickAction.DISMISS, OnClickAction.ACK], + }), + buttons, + iconLink: UNISWAP_LOGO, + extra: JSON.stringify({ + cardType: isRequired ? 'required' : 'dismissible', + isForceUpgrade: true, + }), + }), + }) +} diff --git a/apps/mobile/src/notification-service/data-sources/createMobileLegacyBannersNotificationDataSource.ts b/apps/mobile/src/notification-service/data-sources/createMobileLegacyBannersNotificationDataSource.ts new file mode 100644 index 00000000..16b57521 --- /dev/null +++ b/apps/mobile/src/notification-service/data-sources/createMobileLegacyBannersNotificationDataSource.ts @@ -0,0 +1,183 @@ +import { type InAppNotification } from '@universe/api' +import { + createIntervalNotificationDataSource, + type NotificationDataSource, + type NotificationTracker, +} from '@universe/notifications' +import type { MobileState } from 'src/app/mobileReducer' +import { checkFundWalletBanner } from 'src/notification-service/data-sources/banners/fundWalletBanner' +import { checkNoAppFeesBanner } from 'src/notification-service/data-sources/banners/noAppFeesBanner' +import { checkPushNotificationsBanner } from 'src/notification-service/data-sources/banners/pushNotificationsBanner' +import { checkRecoveryBackup } from 'src/notification-service/data-sources/banners/recoveryBackupBanner' +import { BannerId } from 'src/notification-service/data-sources/banners/types' +import { checkUnitagClaim } from 'src/notification-service/data-sources/banners/unitagClaimBanner' +import { logger } from 'utilities/src/logger/logger' +import { + selectHasDismissedNoAppFeesAnnouncement, + selectHasSkippedUnitagPrompt, + selectHasViewedNotificationsCard, +} from 'wallet/src/features/behaviorHistory/selectors' + +interface CreateMobileLegacyBannersNotificationDataSourceContext { + tracker: NotificationTracker + pollIntervalMs?: number + getState: () => MobileState + getIsDarkMode: () => boolean +} + +/** + * Creates a notification data source that converts HomeIntroCardStack cards + * into InAppNotifications compatible with the notification system. + * + * This replaces the legacy OnboardingIntroCardStack with notification system equivalents: + * - No App Fees announcement + * - Fund Wallet (empty wallet state) + * - Recovery backup reminder + * - Unitag claim prompt + * - Push Notifications prompt + * - Bridged Assets V1/V2 banners + * + * **Migration Logic:** + * This data source checks for legacy dismissal state in Redux and automatically + * migrates it to the notification system on first run. + */ +export function createMobileLegacyBannersNotificationDataSource( + ctx: CreateMobileLegacyBannersNotificationDataSourceContext, +): NotificationDataSource { + const { tracker, pollIntervalMs = 5000, getState, getIsDarkMode } = ctx + let hasMigratedLegacyState = false + + /** + * Migrates legacy dismissal state from Redux to the notification system. + * This runs once on the first poll to ensure users who dismissed cards in the old system + * don't see them again. + */ + const migrateLegacyDismissalState = async (): Promise => { + if (hasMigratedLegacyState) { + return + } + + try { + const state = getState() + + // Migrate Unitag skip state + const hasSkippedUnitag = selectHasSkippedUnitagPrompt(state) + if (hasSkippedUnitag) { + logger.info( + 'createMobileLegacyBannersNotificationDataSource', + 'migrateLegacyDismissalState', + 'Migrating Unitag skip from legacy Redux state', + ) + await tracker.track(BannerId.UnitagClaim, { timestamp: Date.now() }) + } + + // Migrate No App Fees dismissal + const noAppFeesWasDismissed = selectHasDismissedNoAppFeesAnnouncement(state) + if (noAppFeesWasDismissed) { + logger.info( + 'createMobileLegacyBannersNotificationDataSource', + 'migrateLegacyDismissalState', + 'Migrating No App Fees announcement dismissal from legacy Redux state', + ) + await tracker.track(BannerId.NoAppFees, { timestamp: Date.now() }) + } + + // Migrate Push Notifications card dismissal + const pushNotificationsWasViewed = selectHasViewedNotificationsCard(state) + if (pushNotificationsWasViewed) { + logger.info( + 'createMobileLegacyBannersNotificationDataSource', + 'migrateLegacyDismissalState', + 'Migrating Push Notifications card view from legacy Redux state', + ) + await tracker.track(BannerId.PushNotifications, { timestamp: Date.now() }) + } + + hasMigratedLegacyState = true + } catch (error) { + logger.error(error, { + tags: { + file: 'createMobileLegacyBannersNotificationDataSource', + function: 'migrateLegacyDismissalState', + }, + }) + } + } + + const pollForNotifications = async (): Promise => { + try { + // Run migration on first poll + await migrateLegacyDismissalState() + + const notifications = await fetchNotifications(getState, getIsDarkMode) + return notifications + } catch (error) { + logger.error(error, { + tags: { file: 'createMobileLegacyBannersNotificationDataSource', function: 'pollForNotifications' }, + }) + } + + return [] + } + + const dataSource: NotificationDataSource = createIntervalNotificationDataSource({ + pollIntervalMs, + source: 'legacy_intro_cards', + logFileTag: 'createMobileLegacyBannersNotificationDataSource', + getNotifications: pollForNotifications, + }) + + return dataSource +} + +/** + * Fetches all notifications based on current conditions. + * The processor will handle filtering based on tracked/processed state. + * + * Priority order (matches useSharedIntroCards): + * 1. No App Fees announcement (if enabled) + * 2. Fund Wallet (if empty wallet) + * 3. Recovery backup (if no external backup) + * 4. Unitag claim (if eligible) + * 5. Push Notifications (if not granted) + * 6. Bridged Assets V2 (if enabled) + * 7. Bridged Assets V1 (if enabled) + */ +async function fetchNotifications( + getState: () => MobileState, + getIsDarkMode: () => boolean, +): Promise { + const notifications: InAppNotification[] = [] + + // Priority 1: No App Fees announcement + const noAppFeesNotification = await checkNoAppFeesBanner(getIsDarkMode()) + if (noAppFeesNotification) { + notifications.push(noAppFeesNotification) + } + + // Priority 2: Fund Wallet (empty wallet state) + const fundWalletNotification = await checkFundWalletBanner(getState) + if (fundWalletNotification) { + notifications.push(fundWalletNotification) + } + + // Priority 3: Recovery backup reminder + const backupNotification = await checkRecoveryBackup(getState) + if (backupNotification) { + notifications.push(backupNotification) + } + + // Priority 4: Unitag claim + const unitagNotification = await checkUnitagClaim(getState) + if (unitagNotification) { + notifications.push(unitagNotification) + } + + // Priority 5: Push Notifications + const pushNotificationsNotification = await checkPushNotificationsBanner() + if (pushNotificationsNotification) { + notifications.push(pushNotificationsNotification) + } + + return notifications +} diff --git a/apps/mobile/src/notification-service/data-sources/reactive/offlineCondition.test.ts b/apps/mobile/src/notification-service/data-sources/reactive/offlineCondition.test.ts new file mode 100644 index 00000000..aa32b79c --- /dev/null +++ b/apps/mobile/src/notification-service/data-sources/reactive/offlineCondition.test.ts @@ -0,0 +1,179 @@ +import { type NetInfoState } from '@react-native-community/netinfo' +import { ContentStyle } from '@universe/api' +import { selectSomeModalOpen } from 'src/features/modals/selectSomeModalOpen' +import { + createOfflineCondition, + isOfflineBannerNotification, + OFFLINE_BANNER_NOTIFICATION_ID, +} from 'src/notification-service/data-sources/reactive/offlineCondition' +import { selectFinishedOnboarding } from 'wallet/src/features/wallet/selectors' + +// Mock NetInfo +const mockAddEventListener = jest.fn() +jest.mock('@react-native-community/netinfo', () => ({ + addEventListener: (callback: (state: NetInfoState) => void): (() => void) => mockAddEventListener(callback), + useNetInfo: jest.fn(), +})) + +// Mock selectors +jest.mock('src/features/modals/selectSomeModalOpen') +jest.mock('wallet/src/features/wallet/selectors') + +const mockSelectSomeModalOpen = selectSomeModalOpen as jest.MockedFunction +const mockSelectFinishedOnboarding = selectFinishedOnboarding as jest.MockedFunction + +describe('offlineCondition', () => { + const mockGetState = jest.fn() + + beforeEach(() => { + jest.clearAllMocks() + mockAddEventListener.mockReturnValue(jest.fn()) // Return unsubscribe function + }) + + describe('createOfflineCondition', () => { + it('returns a condition with the correct notification ID', () => { + const condition = createOfflineCondition({ getState: mockGetState }) + + expect(condition.notificationId).toBe(OFFLINE_BANNER_NOTIFICATION_ID) + expect(condition.notificationId.startsWith('local:')).toBe(true) + }) + + describe('subscribe', () => { + it('subscribes to NetInfo and returns unsubscribe function', () => { + const mockUnsubscribe = jest.fn() + mockAddEventListener.mockReturnValue(mockUnsubscribe) + + const condition = createOfflineCondition({ getState: mockGetState }) + const onStateChange = jest.fn() + + const unsubscribe = condition.subscribe(onStateChange) + + expect(mockAddEventListener).toHaveBeenCalledTimes(1) + expect(unsubscribe).toBe(mockUnsubscribe) + }) + + it('calls onStateChange with combined state when network state changes', () => { + mockSelectFinishedOnboarding.mockReturnValue(true) + mockSelectSomeModalOpen.mockReturnValue(false) + + let capturedCallback: (state: NetInfoState) => void = jest.fn() + // oxlint-disable-next-line max-nested-callbacks + mockAddEventListener.mockImplementation((callback: (state: NetInfoState) => void) => { + capturedCallback = callback + return jest.fn() + }) + + const condition = createOfflineCondition({ getState: mockGetState }) + const onStateChange = jest.fn() + + condition.subscribe(onStateChange) + + // Simulate network state change + capturedCallback({ isConnected: false } as NetInfoState) + + expect(onStateChange).toHaveBeenCalledWith({ + isConnected: false, + finishedOnboarding: true, + isModalOpen: false, + }) + }) + }) + + describe('shouldShow', () => { + it('returns true when offline, finished onboarding, and no modal open', () => { + const condition = createOfflineCondition({ getState: mockGetState }) + + expect( + condition.shouldShow({ + isConnected: false, + finishedOnboarding: true, + isModalOpen: false, + }), + ).toBe(true) + }) + + it('returns false when connected', () => { + const condition = createOfflineCondition({ getState: mockGetState }) + + expect( + condition.shouldShow({ + isConnected: true, + finishedOnboarding: true, + isModalOpen: false, + }), + ).toBe(false) + }) + + it('returns false when isConnected is null', () => { + const condition = createOfflineCondition({ getState: mockGetState }) + + expect( + condition.shouldShow({ + isConnected: null, + finishedOnboarding: true, + isModalOpen: false, + }), + ).toBe(false) + }) + + it('returns false when not finished onboarding', () => { + const condition = createOfflineCondition({ getState: mockGetState }) + + expect( + condition.shouldShow({ + isConnected: false, + finishedOnboarding: false, + isModalOpen: false, + }), + ).toBe(false) + }) + + it('returns false when modal is open', () => { + const condition = createOfflineCondition({ getState: mockGetState }) + + expect( + condition.shouldShow({ + isConnected: false, + finishedOnboarding: true, + isModalOpen: true, + }), + ).toBe(false) + }) + }) + + describe('createNotification', () => { + it('returns a notification with the correct ID and style', () => { + const condition = createOfflineCondition({ getState: mockGetState }) + + const notification = condition.createNotification({ + isConnected: false, + finishedOnboarding: true, + isModalOpen: false, + }) + + expect(notification.id).toBe(OFFLINE_BANNER_NOTIFICATION_ID) + expect(notification.content?.style).toBe(ContentStyle.SYSTEM_BANNER) + }) + }) + }) + + describe('isOfflineBannerNotification', () => { + it('returns true for offline banner notification', () => { + const notification = { id: OFFLINE_BANNER_NOTIFICATION_ID } as any + + expect(isOfflineBannerNotification(notification)).toBe(true) + }) + + it('returns false for other notifications', () => { + const notification = { id: 'some-other-notification' } as any + + expect(isOfflineBannerNotification(notification)).toBe(false) + }) + + it('returns false for other local notifications', () => { + const notification = { id: 'local:other_trigger' } as any + + expect(isOfflineBannerNotification(notification)).toBe(false) + }) + }) +}) diff --git a/apps/mobile/src/notification-service/data-sources/reactive/offlineCondition.ts b/apps/mobile/src/notification-service/data-sources/reactive/offlineCondition.ts new file mode 100644 index 00000000..7d78cd8c --- /dev/null +++ b/apps/mobile/src/notification-service/data-sources/reactive/offlineCondition.ts @@ -0,0 +1,98 @@ +import NetInfo, { type NetInfoState } from '@react-native-community/netinfo' +import { + Content, + ContentStyle, + Notification, + OnClick, +} from '@uniswap/client-notification-service/dist/uniswap/notificationservice/v1/api_pb' +import { type InAppNotification, OnClickAction } from '@universe/api' +import { type ReactiveCondition } from '@universe/notifications' +import { type MobileState } from 'src/app/mobileReducer' +import { selectSomeModalOpen } from 'src/features/modals/selectSomeModalOpen' +import { selectFinishedOnboarding } from 'wallet/src/features/wallet/selectors' + +/** + * Unique ID for the offline banner notification. + * Uses 'local:' prefix to distinguish from backend-generated notifications. + */ +export const OFFLINE_BANNER_NOTIFICATION_ID = 'local:session:offline' + +/** + * State tracked by the offline condition. + */ +interface OfflineConditionState { + isConnected: boolean | null + finishedOnboarding: boolean + isModalOpen: boolean +} + +/** + * Context required to create the offline condition. + */ +interface CreateOfflineConditionContext { + /** Function to get the current Redux state */ + getState: () => MobileState +} + +/** + * Creates a reactive condition for the offline banner. + * + * The banner will show when: + * - Network connection is explicitly false (not null) + * - User has finished onboarding + * - No modal is currently open + * - NOT in __DEV__ mode (handled at render level to match existing behavior) + * + * @see OfflineBanner for the original implementation + */ +export function createOfflineCondition(ctx: CreateOfflineConditionContext): ReactiveCondition { + const { getState } = ctx + + return { + notificationId: OFFLINE_BANNER_NOTIFICATION_ID, + + subscribe: (onStateChange: (state: OfflineConditionState) => void): (() => void) => { + // Subscribe to network state changes + const unsubscribe = NetInfo.addEventListener((netInfoState: NetInfoState) => { + const reduxState = getState() + onStateChange({ + isConnected: netInfoState.isConnected, + finishedOnboarding: selectFinishedOnboarding(reduxState) ?? false, + isModalOpen: selectSomeModalOpen(reduxState), + }) + }) + + return unsubscribe + }, + + shouldShow: (state: OfflineConditionState): boolean => { + // Needs to explicitly check for false since isConnected may be null + // Also check that user has finished onboarding and no modal is open + return state.isConnected === false && state.finishedOnboarding && !state.isModalOpen + }, + + createNotification: (_state: OfflineConditionState): InAppNotification => { + return new Notification({ + id: OFFLINE_BANNER_NOTIFICATION_ID, + content: new Content({ + style: ContentStyle.SYSTEM_BANNER, + title: '', // Title is rendered by the custom renderer using i18n + version: 0, + buttons: [], + // System banners can be dismissed but will reappear in future sessions if the condition persists + onDismissClick: new OnClick({ + onClick: [OnClickAction.DISMISS, OnClickAction.ACK], + }), + }), + }) + }, + } +} + +/** + * Type guard to check if a notification is the offline banner notification. + * Used by NotificationContainer to route to the correct renderer. + */ +export function isOfflineBannerNotification(notification: InAppNotification): boolean { + return notification.id === OFFLINE_BANNER_NOTIFICATION_ID +} diff --git a/apps/mobile/src/notification-service/handleNotificationNavigation.test.ts b/apps/mobile/src/notification-service/handleNotificationNavigation.test.ts new file mode 100644 index 00000000..a244ef2c --- /dev/null +++ b/apps/mobile/src/notification-service/handleNotificationNavigation.test.ts @@ -0,0 +1,458 @@ +export {} + +const mockNavigate = jest.fn() +const mockIsReady = jest.fn() + +jest.mock('src/app/navigation/navigationRef', () => ({ + navigationRef: { + navigate: mockNavigate, + isReady: mockIsReady, + }, +})) + +const mockGetState = jest.fn() +jest.mock('src/app/store', () => ({ + store: { + getState: (): unknown => mockGetState(), + }, +})) + +const mockOpenUri = jest.fn() +jest.mock('uniswap/src/utils/linking', () => ({ + openUri: mockOpenUri, +})) + +const mockLoggerWarn = jest.fn() +const mockLoggerError = jest.fn() +jest.mock('utilities/src/logger/logger', () => ({ + getLogger: (): { warn: jest.Mock; error: jest.Mock } => ({ + warn: mockLoggerWarn, + error: mockLoggerError, + }), +})) + +describe('handleNotificationNavigation', () => { + let handleNotificationNavigation: typeof import('./handleNotificationNavigation').handleNotificationNavigation + + beforeEach(() => { + jest.clearAllMocks() + jest.resetModules() + + // Re-setup mocks after resetModules + jest.doMock('src/app/navigation/navigationRef', () => ({ + navigationRef: { + navigate: mockNavigate, + isReady: mockIsReady, + }, + })) + jest.doMock('src/app/store', () => ({ + store: { + getState: (): unknown => mockGetState(), + }, + })) + jest.doMock('uniswap/src/utils/linking', () => ({ + openUri: mockOpenUri, + })) + jest.doMock('utilities/src/logger/logger', () => ({ + getLogger: (): { warn: jest.Mock; error: jest.Mock } => ({ + warn: mockLoggerWarn, + error: mockLoggerError, + }), + })) + + handleNotificationNavigation = require('./handleNotificationNavigation').handleNotificationNavigation + mockIsReady.mockReturnValue(true) + }) + + describe('navigation readiness', () => { + it('returns early and logs warning when navigation is not ready', () => { + mockIsReady.mockReturnValue(false) + + handleNotificationNavigation('https://example.com') + + expect(mockLoggerWarn).toHaveBeenCalledWith( + 'handleNotificationNavigation', + 'handleNotificationNavigation', + 'Navigation not ready', + { url: 'https://example.com' }, + ) + expect(mockNavigate).not.toHaveBeenCalled() + expect(mockOpenUri).not.toHaveBeenCalled() + }) + }) + + describe('mobile:// protocol handling', () => { + describe('swap navigation', () => { + it('navigates to swap with outputChain pre-filling output currency', () => { + handleNotificationNavigation('mobile://swap?outputChain=unichain') + + expect(mockNavigate).toHaveBeenCalledWith('swap-modal', { + input: null, + output: { + address: expect.any(String), + chainId: 130, // Unichain + type: 'currency', + }, + exactCurrencyField: 'input', + exactAmountToken: '', + selectingCurrencyField: 'output', // defaults to output + selectingCurrencyChainId: undefined, + }) + }) + + it('navigates to swap with inputChain pre-filling input currency', () => { + handleNotificationNavigation('mobile://swap?inputChain=arbitrum') + + expect(mockNavigate).toHaveBeenCalledWith('swap-modal', { + input: { + address: expect.any(String), + chainId: 42161, // Arbitrum + type: 'currency', + }, + output: null, + exactCurrencyField: 'input', + exactAmountToken: '', + selectingCurrencyField: 'output', + selectingCurrencyChainId: undefined, + }) + }) + + it('navigates to swap with both inputChain and outputChain', () => { + handleNotificationNavigation('mobile://swap?inputChain=base&outputChain=optimism') + + expect(mockNavigate).toHaveBeenCalledWith('swap-modal', { + input: { + address: expect.any(String), + chainId: 8453, // Base + type: 'currency', + }, + output: { + address: expect.any(String), + chainId: 10, // Optimism + type: 'currency', + }, + exactCurrencyField: 'input', + exactAmountToken: '', + selectingCurrencyField: 'output', + selectingCurrencyChainId: undefined, + }) + }) + + it('navigates to swap with no pre-filled currencies when no params', () => { + handleNotificationNavigation('mobile://swap') + + expect(mockNavigate).toHaveBeenCalledWith('swap-modal', { + input: null, + output: null, + exactCurrencyField: 'input', + exactAmountToken: '', + selectingCurrencyField: 'output', + selectingCurrencyChainId: undefined, + }) + }) + + it('ignores unknown chain names', () => { + handleNotificationNavigation('mobile://swap?inputChain=unknownchain') + + expect(mockNavigate).toHaveBeenCalledWith('swap-modal', { + input: null, // Unknown chain results in null + output: null, + exactCurrencyField: 'input', + exactAmountToken: '', + selectingCurrencyField: 'output', + selectingCurrencyChainId: undefined, + }) + }) + + it('handles case-insensitive chain names', () => { + handleNotificationNavigation('mobile://swap?outputChain=UNICHAIN') + + expect(mockNavigate).toHaveBeenCalledWith('swap-modal', { + input: null, + output: { + address: expect.any(String), + chainId: 130, // Unichain + type: 'currency', + }, + exactCurrencyField: 'input', + exactAmountToken: '', + selectingCurrencyField: 'output', + selectingCurrencyChainId: undefined, + }) + }) + + it('opens input selector when selectingField=input', () => { + handleNotificationNavigation('mobile://swap?outputChain=unichain&selectingField=input') + + expect(mockNavigate).toHaveBeenCalledWith('swap-modal', { + input: null, + output: { + address: expect.any(String), + chainId: 130, // Unichain + type: 'currency', + }, + exactCurrencyField: 'input', + exactAmountToken: '', + selectingCurrencyField: 'input', // Opens input selector + selectingCurrencyChainId: undefined, + }) + }) + + it('filters token selector by selectingChain', () => { + handleNotificationNavigation('mobile://swap?selectingField=input&selectingChain=mainnet') + + expect(mockNavigate).toHaveBeenCalledWith('swap-modal', { + input: null, + output: null, + exactCurrencyField: 'input', + exactAmountToken: '', + selectingCurrencyField: 'input', + selectingCurrencyChainId: 1, // Mainnet filter on selector + }) + }) + + it('supports full bridging flow: outputChain + selectingField=input', () => { + // Bridge to Unichain: OUTPUT is Unichain native, INPUT selector opens for user to pick source + handleNotificationNavigation('mobile://swap?outputChain=unichain&selectingField=input') + + expect(mockNavigate).toHaveBeenCalledWith('swap-modal', { + input: null, + output: { + address: expect.any(String), + chainId: 130, + type: 'currency', + }, + exactCurrencyField: 'input', + exactAmountToken: '', + selectingCurrencyField: 'input', + selectingCurrencyChainId: undefined, + }) + }) + }) + + describe('explore navigation', () => { + it('navigates to explore screen with chain pre-selection', () => { + handleNotificationNavigation('mobile://explore?chain=monad') + + expect(mockNavigate).toHaveBeenCalledWith('explore-modal', { + screen: 'Explore', + params: { chainId: 143 }, // Monad chain ID + }) + }) + + it('navigates to explore screen without chain when no param', () => { + handleNotificationNavigation('mobile://explore') + + expect(mockNavigate).toHaveBeenCalledWith('explore-modal', { + screen: 'Explore', + params: { chainId: undefined }, + }) + }) + + it('handles case-insensitive chain names for explore', () => { + handleNotificationNavigation('mobile://explore?chain=MONAD') + + expect(mockNavigate).toHaveBeenCalledWith('explore-modal', { + screen: 'Explore', + params: { chainId: 143 }, + }) + }) + + it('handles unknown chain names gracefully', () => { + handleNotificationNavigation('mobile://explore?chain=unknownchain') + + expect(mockNavigate).toHaveBeenCalledWith('explore-modal', { + screen: 'Explore', + params: { chainId: undefined }, + }) + }) + }) + + describe('backup navigation', () => { + it('navigates to backup flow with correct params', () => { + handleNotificationNavigation('mobile://backup') + + expect(mockNavigate).toHaveBeenCalledWith('OnboardingStack', { + screen: 'OnboardingBackup', + params: { + importType: 'BackupOnly', + entryPoint: 'BackupCard', + }, + }) + }) + }) + + it('navigates to stack and screen for two-part path', () => { + handleNotificationNavigation('mobile://SettingsStack/SettingsViewSeedPhrase') + + expect(mockNavigate).toHaveBeenCalledWith('SettingsStack', { screen: 'SettingsViewSeedPhrase' }) + }) + + it('navigates to single screen for one-part path', () => { + handleNotificationNavigation('mobile://Home') + + expect(mockNavigate).toHaveBeenCalledWith('Home') + }) + + it('handles paths with trailing slashes', () => { + handleNotificationNavigation('mobile://SettingsStack/Settings/') + + expect(mockNavigate).toHaveBeenCalledWith('SettingsStack', { screen: 'Settings' }) + }) + + it('handles paths with multiple segments (uses first two)', () => { + handleNotificationNavigation('mobile://SettingsStack/Settings/SubScreen') + + expect(mockNavigate).toHaveBeenCalledWith('SettingsStack', { screen: 'Settings' }) + }) + + it('does not navigate for empty path after prefix', () => { + handleNotificationNavigation('mobile://') + + expect(mockNavigate).not.toHaveBeenCalled() + }) + + it('does not call openUri for mobile:// URLs', () => { + handleNotificationNavigation('mobile://SettingsStack/Settings') + + expect(mockOpenUri).not.toHaveBeenCalled() + }) + }) + + describe('unitag:// protocol handling', () => { + const mockActiveAddress = '0x1234567890abcdef1234567890abcdef12345678' + + beforeEach(() => { + mockGetState.mockReturnValue({ + wallet: { + activeAccountAddress: mockActiveAddress, + }, + }) + }) + + it('navigates to UnitagStack with ClaimUnitag screen and params', () => { + handleNotificationNavigation('unitag://ClaimUnitag') + + expect(mockNavigate).toHaveBeenCalledWith('UnitagStack', { + screen: 'ClaimUnitag', + params: { + entryPoint: 'Home', + address: mockActiveAddress, + }, + }) + }) + + it('logs warning and does not navigate when no active address', () => { + mockGetState.mockReturnValue({ + wallet: { + activeAccountAddress: null, + }, + }) + + handleNotificationNavigation('unitag://ClaimUnitag') + + expect(mockLoggerWarn).toHaveBeenCalledWith( + 'handleNotificationNavigation', + 'handleUnitagNavigation', + 'No active address for unitag navigation', + { url: 'unitag://ClaimUnitag' }, + ) + expect(mockNavigate).not.toHaveBeenCalled() + }) + + it('logs warning for unknown unitag screen', () => { + handleNotificationNavigation('unitag://UnknownScreen') + + expect(mockLoggerWarn).toHaveBeenCalledWith( + 'handleNotificationNavigation', + 'handleUnitagNavigation', + 'Unknown unitag screen', + { url: 'unitag://UnknownScreen', screen: 'UnknownScreen' }, + ) + expect(mockNavigate).not.toHaveBeenCalled() + }) + + it('does not call openUri for unitag:// URLs', () => { + handleNotificationNavigation('unitag://ClaimUnitag') + + expect(mockOpenUri).not.toHaveBeenCalled() + }) + }) + + describe('external URL handling', () => { + it('opens https:// URLs in browser', () => { + mockOpenUri.mockResolvedValue(undefined) + + handleNotificationNavigation('https://example.com') + + expect(mockOpenUri).toHaveBeenCalledWith({ uri: 'https://example.com' }) + expect(mockNavigate).not.toHaveBeenCalled() + }) + + it('opens http:// URLs in browser', () => { + mockOpenUri.mockResolvedValue(undefined) + + handleNotificationNavigation('http://example.com') + + expect(mockOpenUri).toHaveBeenCalledWith({ uri: 'http://example.com' }) + }) + + it('opens URLs with query parameters', () => { + mockOpenUri.mockResolvedValue(undefined) + + handleNotificationNavigation('https://example.com/path?foo=bar&baz=qux') + + expect(mockOpenUri).toHaveBeenCalledWith({ uri: 'https://example.com/path?foo=bar&baz=qux' }) + }) + + it('logs error when openUri fails', async () => { + const error = new Error('Failed to open URL') + mockOpenUri.mockRejectedValue(error) + + handleNotificationNavigation('https://example.com') + + // Wait for the promise rejection to be handled + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(mockLoggerError).toHaveBeenCalledWith(error, { + tags: { file: 'handleNotificationNavigation', function: 'handleNotificationNavigation' }, + extra: { url: 'https://example.com' }, + }) + }) + + it('opens Uniswap explore URLs', () => { + mockOpenUri.mockResolvedValue(undefined) + + handleNotificationNavigation('https://app.uniswap.org/explore/tokens/monad') + + expect(mockOpenUri).toHaveBeenCalledWith({ uri: 'https://app.uniswap.org/explore/tokens/monad' }) + }) + }) + + describe('edge cases', () => { + it('handles unknown protocol as external URL', () => { + mockOpenUri.mockResolvedValue(undefined) + + handleNotificationNavigation('custom://something') + + expect(mockOpenUri).toHaveBeenCalledWith({ uri: 'custom://something' }) + }) + + it('handles empty string URL', () => { + mockOpenUri.mockResolvedValue(undefined) + + handleNotificationNavigation('') + + // Empty string is treated as external URL + expect(mockOpenUri).toHaveBeenCalledWith({ uri: '' }) + }) + + it('handles URL with only protocol prefix mobile://', () => { + handleNotificationNavigation('mobile://') + + // Empty path results in empty parts array, so no navigation + expect(mockNavigate).not.toHaveBeenCalled() + expect(mockOpenUri).not.toHaveBeenCalled() + }) + }) +}) diff --git a/apps/mobile/src/notification-service/handleNotificationNavigation.ts b/apps/mobile/src/notification-service/handleNotificationNavigation.ts new file mode 100644 index 00000000..f4d123e1 --- /dev/null +++ b/apps/mobile/src/notification-service/handleNotificationNavigation.ts @@ -0,0 +1,212 @@ +import { navigationRef } from 'src/app/navigation/navigationRef' +import { store } from 'src/app/store' +import { MOBILE_NAV_PREFIX, UNITAG_NAV_PREFIX } from 'src/notification-service/data-sources/banners/types' +import { getNativeAddress } from 'uniswap/src/constants/addresses' +import { AssetType } from 'uniswap/src/entities/assets' +import { UniverseChainId } from 'uniswap/src/features/chains/types' +import { ModalName } from 'uniswap/src/features/telemetry/constants' +import { CurrencyField } from 'uniswap/src/types/currency' +import { ImportType, OnboardingEntryPoint } from 'uniswap/src/types/onboarding' +import { MobileScreens, OnboardingScreens, UnitagScreens } from 'uniswap/src/types/screens/mobile' +import { openUri } from 'uniswap/src/utils/linking' +import { getLogger } from 'utilities/src/logger/logger' + +/** + * Chain name to UniverseChainId mapping for swap navigation + */ +const CHAIN_ID_MAP: Record = { + unichain: UniverseChainId.Unichain, + mainnet: UniverseChainId.Mainnet, + arbitrum: UniverseChainId.ArbitrumOne, + optimism: UniverseChainId.Optimism, + polygon: UniverseChainId.Polygon, + base: UniverseChainId.Base, + monad: UniverseChainId.Monad, +} + +/** + * Handles mobile:// protocol navigation + */ +function handleMobileNavigation(url: string, path: string): void { + // Handle mobile://modal/{ModalName} pattern for direct modal navigation + if (path.startsWith('modal/')) { + const modalName = path.replace('modal/', '') + // oxlint-disable-next-line typescript/no-explicit-any -- Navigation refs need flexible typing for dynamic routes + navigationRef.navigate(modalName as any) + return + } + + // Handle mobile://swap pattern for swap navigation with flexible chain/currency pre-selection + // Supported params: + // - inputChain: Pre-fills INPUT with that chain's native token + // - outputChain: Pre-fills OUTPUT with that chain's native token + // - selectingField: Which field's token selector to open ('input' or 'output') + // - selectingChain: Filter the token selector to this chain + // Only specified params are applied - unspecified fields remain null/undefined + // Matches getNavigateToSwapFlowArgsInitialState for NavigateToSwapFlowWithActions + if (path.startsWith('swap')) { + const urlObj = new URL(url) + const inputChainParam = urlObj.searchParams.get('inputChain') + const outputChainParam = urlObj.searchParams.get('outputChain') + const selectingFieldParam = urlObj.searchParams.get('selectingField') + const selectingChainParam = urlObj.searchParams.get('selectingChain') + + const inputChainId = inputChainParam ? CHAIN_ID_MAP[inputChainParam.toLowerCase()] : undefined + const outputChainId = outputChainParam ? CHAIN_ID_MAP[outputChainParam.toLowerCase()] : undefined + const selectingChainId = selectingChainParam ? CHAIN_ID_MAP[selectingChainParam.toLowerCase()] : undefined + + // Determine which field's selector to open (defaults to output if not specified) + const selectingField = selectingFieldParam?.toLowerCase() === 'input' ? CurrencyField.INPUT : CurrencyField.OUTPUT + + // Build initial state - only set currencies for chains that were explicitly specified + const initialState = { + [CurrencyField.INPUT]: inputChainId + ? { + address: getNativeAddress(inputChainId), + chainId: inputChainId, + type: AssetType.Currency, + } + : null, + [CurrencyField.OUTPUT]: outputChainId + ? { + address: getNativeAddress(outputChainId), + chainId: outputChainId, + type: AssetType.Currency, + } + : null, + exactCurrencyField: CurrencyField.INPUT, + exactAmountToken: '', + selectingCurrencyField: selectingField, + selectingCurrencyChainId: selectingChainId, + } + + // oxlint-disable-next-line typescript/no-explicit-any -- Navigation refs need flexible typing for dynamic routes + navigationRef.navigate(ModalName.Swap as any, initialState) + return + } + + // Handle mobile://explore pattern for explore with chain pre-selection + if (path.startsWith('explore')) { + const urlObj = new URL(url) + const chainParam = urlObj.searchParams.get('chain') + const chainId = chainParam ? CHAIN_ID_MAP[chainParam.toLowerCase()] : undefined + + // oxlint-disable-next-line typescript/no-explicit-any -- Navigation refs need flexible typing for dynamic routes + navigationRef.navigate(ModalName.Explore as any, { + screen: MobileScreens.Explore, + params: { chainId }, + }) + return + } + + // Handle mobile://backup pattern for backup flow navigation + if (path === 'backup') { + // oxlint-disable-next-line typescript/no-explicit-any -- Navigation refs need flexible typing for dynamic routes + navigationRef.navigate(MobileScreens.OnboardingStack as any, { + screen: OnboardingScreens.Backup, + params: { + importType: ImportType.BackupOnly, + entryPoint: OnboardingEntryPoint.BackupCard, + }, + }) + return + } + + // Handle mobile://{Stack}/{Screen} pattern for stack navigation + const parts = path.split('/').filter((p) => p.length > 0) + + // Navigate to the screen based on path segments + // e.g., "SettingsStack/SettingsViewSeedPhrase" → navigate(SettingsStack, { screen: SettingsViewSeedPhrase }) + if (parts.length >= 2) { + const [stack, screen] = parts + // oxlint-disable-next-line typescript/no-explicit-any -- Navigation refs need flexible typing for dynamic routes + const screenParams: any = { screen } + // oxlint-disable-next-line typescript/no-explicit-any -- Navigation refs need flexible typing for dynamic routes + navigationRef.navigate(stack as any, screenParams) + } else if (parts.length === 1) { + // oxlint-disable-next-line typescript/no-explicit-any -- Navigation refs need flexible typing for dynamic routes + navigationRef.navigate(parts[0] as any) + } +} + +/** + * Handles unitag:// protocol navigation for Unitag claim flow + */ +function handleUnitagNavigation(url: string, screen: string): void { + const state = store.getState() + const activeAddress = state.wallet.activeAccountAddress + + if (!activeAddress) { + getLogger().warn( + 'handleNotificationNavigation', + 'handleUnitagNavigation', + 'No active address for unitag navigation', + { + url, + }, + ) + return + } + + if (screen !== UnitagScreens.ClaimUnitag) { + getLogger().warn('handleNotificationNavigation', 'handleUnitagNavigation', 'Unknown unitag screen', { url, screen }) + return + } + + // oxlint-disable-next-line typescript/no-explicit-any -- Navigation refs need flexible typing for dynamic routes + const params: any = { + screen: UnitagScreens.ClaimUnitag, + params: { + entryPoint: MobileScreens.Home, + address: activeAddress, + }, + } + // oxlint-disable-next-line typescript/no-explicit-any -- Navigation refs need flexible typing for dynamic routes + navigationRef.navigate(MobileScreens.UnitagStack as any, params) +} + +/** + * Handles navigation from notification clicks. + * + * Supported URL patterns: + * - mobile://modal/{ModalName} - Direct modal navigation (e.g., FundWallet) + * - mobile://swap?... - Swap modal with flexible pre-selection: + * - inputChain: Pre-fills INPUT with that chain's native token + * - outputChain: Pre-fills OUTPUT with that chain's native token + * - selectingField: Which token selector to open ('input' or 'output', defaults to 'output') + * - selectingChain: Filter the open token selector to this chain + * - mobile://explore?chain={chain} - Explore screen with optional chain pre-selection + * - mobile://backup - Backup flow navigation to "Choose your backup method" screen + * - mobile://{Stack}/{Screen} - Stack navigation (e.g., SettingsStack/SettingsViewSeedPhrase) + * - unitag://{screen} - Unitag claim flow + * - https://... - External URLs opened in browser + */ +export function handleNotificationNavigation(url: string): void { + // Ensure navigation is ready + if (!navigationRef.isReady()) { + getLogger().warn('handleNotificationNavigation', 'handleNotificationNavigation', 'Navigation not ready', { url }) + return + } + + // Handle mobile:// protocol for internal navigation + if (url.startsWith(MOBILE_NAV_PREFIX)) { + const path = url.replace(MOBILE_NAV_PREFIX, '') + handleMobileNavigation(url, path) + return + } + + // Handle unitag:// protocol for Unitag claim flow + if (url.startsWith(UNITAG_NAV_PREFIX)) { + const screen = url.replace(UNITAG_NAV_PREFIX, '') + handleUnitagNavigation(url, screen) + return + } + + // All other URLs are external - open in browser + openUri({ uri: url }).catch((error) => { + getLogger().error(error, { + tags: { file: 'handleNotificationNavigation', function: 'handleNotificationNavigation' }, + extra: { url }, + }) + }) +} diff --git a/apps/mobile/src/notification-service/notification-renderer/ForceUpgradeNotification.tsx b/apps/mobile/src/notification-service/notification-renderer/ForceUpgradeNotification.tsx new file mode 100644 index 00000000..fb26c789 --- /dev/null +++ b/apps/mobile/src/notification-service/notification-renderer/ForceUpgradeNotification.tsx @@ -0,0 +1,136 @@ +import type { InAppNotification } from '@universe/api' +import type { NotificationClickTarget } from '@universe/notifications' +import { memo, useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { SeedPhraseModalContent } from 'src/components/forceUpgrade/ForceUpgradeModal' +import { useSporeColors } from 'ui/src' +import { Modal } from 'uniswap/src/components/modals/Modal' +import { ModalName } from 'uniswap/src/features/telemetry/constants' +import { useEvent } from 'utilities/src/react/hooks' +import { ForceUpgradeModalContent } from 'wallet/src/features/forceUpgrade/ForceUpgradeModalContent' +import { SignerMnemonicAccount } from 'wallet/src/features/wallet/accounts/types' +import { useSignerAccounts } from 'wallet/src/features/wallet/hooks' + +interface ForceUpgradeNotificationProps { + notification: InAppNotification + onNotificationClick?: (notificationId: string, target: NotificationClickTarget) => void + onNotificationShown?: (notificationId: string) => void +} + +/** + * Determines if a notification is a force upgrade notification based on its ID. + */ +export function isForceUpgradeNotification(notification: InAppNotification): boolean { + return notification.id.startsWith('local:force_upgrade_') +} + +/** + * Determines if the force upgrade is required (blocking) based on the notification ID. + */ +function isRequiredUpgrade(notification: InAppNotification): boolean { + return notification.id.includes('required') +} + +/** + * Custom force upgrade notification renderer that preserves the legacy UI. + * + * This component renders the force upgrade modal with: + * - Custom image section (Uniswap logo with dot pattern and "New!" tag) + * - Non-dismissible modal for required upgrades (no background tap dismiss) + * - Seed phrase backup flow for required upgrades + * + * The notification system controls visibility, while this component handles + * the UI and user interactions, delegating actions back via onNotificationClick. + */ +export const ForceUpgradeNotification = memo(function ForceUpgradeNotification({ + notification, + onNotificationClick, + onNotificationShown, +}: ForceUpgradeNotificationProps): JSX.Element { + const { t } = useTranslation() + const colors = useSporeColors() + const isRequired = isRequiredUpgrade(notification) + + const [showSeedPhrase, setShowSeedPhrase] = useState(false) + // Track if we've already dismissed to prevent double-dismiss (button click + modal onClose) + const hasDismissedRef = useRef(false) + + // Get title and subtitle from notification content + const title = notification.content?.title ?? t('forceUpgrade.title') + const subtitle = notification.content?.subtitle ?? t('forceUpgrade.description.wallet') + + // Get signer accounts for seed phrase backup + const signerAccounts = useSignerAccounts() + const mnemonicId = signerAccounts.length > 0 ? (signerAccounts[0] as SignerMnemonicAccount).mnemonicId : undefined + + // Notify that the notification is shown + useEffect(() => { + onNotificationShown?.(notification.id) + }, [notification.id, onNotificationShown]) + + const onClose = useEvent(() => { + // For recommended upgrades, dismiss the notification (background tap) + // Skip if already dismissed via button to prevent double-dismiss + if (!isRequired && !hasDismissedRef.current) { + hasDismissedRef.current = true + onNotificationClick?.(notification.id, { type: 'dismiss' }) + } + // Required upgrades cannot be dismissed via background tap + }) + + const onPressUpdate = useEvent(() => { + // Click the primary button (index 0) - this triggers the app store navigation + onNotificationClick?.(notification.id, { type: 'button', index: 0 }) + }) + + const onPressBackup = useEvent(() => { + // Show seed phrase modal instead of navigating away + setShowSeedPhrase(true) + }) + + const onDismissSeedPhrase = useEvent(() => { + setShowSeedPhrase(false) + }) + + const onPressNotNow = useEvent(() => { + hasDismissedRef.current = true + onNotificationClick?.(notification.id, { type: 'dismiss' }) + }) + + return ( + <> + + + + + {mnemonicId && showSeedPhrase && ( + + + + )} + + ) +}) diff --git a/apps/mobile/src/notification-service/notification-renderer/NotificationContainer.tsx b/apps/mobile/src/notification-service/notification-renderer/NotificationContainer.tsx new file mode 100644 index 00000000..ee954a23 --- /dev/null +++ b/apps/mobile/src/notification-service/notification-renderer/NotificationContainer.tsx @@ -0,0 +1,278 @@ +import { ContentStyle, type InAppNotification } from '@universe/api' +import type { NotificationClickTarget } from '@universe/notifications' +import { InlineBannerNotification } from '@universe/notifications/src/notification-renderer/components/InlineBannerNotification' +import { Fragment, memo, useEffect, useMemo } from 'react' +import { isOfflineBannerNotification } from 'src/notification-service/data-sources/reactive/offlineCondition' +import { + ForceUpgradeNotification, + isForceUpgradeNotification, +} from 'src/notification-service/notification-renderer/ForceUpgradeNotification' +import { + mobileNotificationStore, + type NotificationState, +} from 'src/notification-service/notification-renderer/notificationStore' +import { useSystemBannerPortal } from 'src/notification-service/notification-renderer/SystemBannerPortal' +import { BackupReminderModalRenderer } from 'src/notification-service/renderers/BackupReminderModalRenderer' +import { OfflineBannerRenderer } from 'src/notification-service/renderers/OfflineBannerRenderer' +import { isBackupReminderNotification } from 'src/notification-service/triggers/backupReminderTrigger' +import { isLocalTriggerNotification } from 'src/notification-service/triggers/createMobileLocalTriggerDataSource' +import { Flex } from 'ui/src' +import { ModalNotification } from 'uniswap/src/components/notifications/ModalNotification' +import { getLogger } from 'utilities/src/logger/logger' +import { useEvent } from 'utilities/src/react/hooks' +import { type IntroCardProps } from 'wallet/src/components/introCards/IntroCard' +import { IntroCardStack } from 'wallet/src/components/introCards/IntroCardStack' +import { + convertNotificationToIntroCard, + shouldRenderAsIntroCard, +} from 'wallet/src/features/notifications/convertNotificationToIntroCard' +import { type StoreApi, type UseBoundStore } from 'zustand' + +/** + * Routes a notification to the appropriate renderer based on its style. + * Note: Force upgrade notifications are handled separately in NotificationContainer + * to preserve proper hook ordering. + */ +function Notification({ + notification, + onRenderFailed, + onNotificationClick, + onNotificationShown, +}: { + notification: InAppNotification + onRenderFailed?: (id: string) => void + onNotificationClick?: (notificationId: string, target: NotificationClickTarget) => void + onNotificationShown?: (notificationId: string) => void +}): JSX.Element | null { + const style = notification.content?.style + const isUnknownStyle = style !== ContentStyle.MODAL && style !== ContentStyle.LOWER_LEFT_BANNER + + // Handle unknown/invalid notification styles as a side effect + // This handles cases where the server sends string enums on first request and numeric + // enums on subsequent requests. Clean up without marking as processed to allow retry. + useEffect(() => { + if (!isUnknownStyle) { + return () => null + } + + getLogger().warn( + 'NotificationRenderer', + 'renderNotification', + `Unknown notification style: ${style}, cleaning up failed render to allow retry with correct data`, + { + notification, + }, + ) + + const timeoutId = setTimeout(() => { + onRenderFailed?.(notification.id) + }, 1) + + return (): void => clearTimeout(timeoutId) + }, [isUnknownStyle, style, notification, onRenderFailed]) + + if (isUnknownStyle) { + return null + } + + if (style === ContentStyle.MODAL) { + return ( + + ) + } + + // ContentStyle.LOWER_LEFT_BANNER + // Intro cards are handled by IntroCardStack in NotificationContainer + if (shouldRenderAsIntroCard(notification)) { + // Intro cards shouldn't reach here since they're filtered in NotificationContainer + getLogger().warn( + 'NotificationRenderer', + 'renderNotification', + 'IntroCard notification reached NotificationRenderer - should be handled by IntroCardStack', + { notification }, + ) + return null + } + + // Standard banner notification + return +} + +/** + * Subscribes to the notification store and renders active notifications depending on their style. + */ +export const NotificationContainer = memo(function NotificationContainer({ + onRenderFailed, + onNotificationClick, + onNotificationShown, + store = mobileNotificationStore, +}: { + onRenderFailed?: (notificationId: string) => void + onNotificationClick?: (notificationId: string, target: NotificationClickTarget) => void + onNotificationShown?: (notificationId: string) => void + store?: UseBoundStore> +}): JSX.Element { + const activeNotifications = store((state) => state.activeNotifications) + const removeNotification = store((state) => state.removeNotification) + + const handleRenderFailed = useEvent((notificationId: string) => { + removeNotification(notificationId) + onRenderFailed?.(notificationId) + }) + + const handleIntroCardPress = useEvent((notificationId: string) => { + onNotificationClick?.(notificationId, { type: 'background' }) + }) + + const handleIntroCardClose = useEvent((notificationId: string) => { + onNotificationClick?.(notificationId, { type: 'dismiss' }) + }) + + // Access the system banner portal for rendering at app root level + const { setContent: setSystemBannerContent } = useSystemBannerPortal() + + // Separate notifications by type for specialized rendering + const { + introCardNotifications, + localTriggerNotifications, + systemBannerNotifications, + forceUpgradeNotifications, + otherNotifications, + } = useMemo(() => { + const introCards: InAppNotification[] = [] + const localTriggers: InAppNotification[] = [] + const systemBanners: InAppNotification[] = [] + const forceUpgrades: InAppNotification[] = [] + const others: InAppNotification[] = [] + + activeNotifications.forEach((notification) => { + if (isForceUpgradeNotification(notification)) { + forceUpgrades.push(notification) + } else if (notification.content?.style === ContentStyle.SYSTEM_BANNER) { + systemBanners.push(notification) + } else if (shouldRenderAsIntroCard(notification)) { + introCards.push(notification) + } else if (isLocalTriggerNotification(notification.id)) { + localTriggers.push(notification) + } else { + others.push(notification) + } + }) + + return { + introCardNotifications: introCards, + localTriggerNotifications: localTriggers, + systemBannerNotifications: systemBanners, + forceUpgradeNotifications: forceUpgrades, + otherNotifications: others, + } + }, [activeNotifications]) + + // Render system banners through the portal at the app root level + // This ensures position: absolute + bottom: 0 works correctly + useEffect(() => { + if (systemBannerNotifications.length === 0) { + setSystemBannerContent(null) + return () => setSystemBannerContent(null) + } + + const content = ( + + {systemBannerNotifications.map((notification) => { + if (isOfflineBannerNotification(notification)) { + return ( + + ) + } + getLogger().warn( + 'NotificationContainer', + 'systemBannerNotifications', + `Unknown system banner notification: ${notification.id}`, + { notification }, + ) + return null + })} + + ) + setSystemBannerContent(content) + + return () => setSystemBannerContent(null) + }, [systemBannerNotifications, onNotificationClick, onNotificationShown, setSystemBannerContent]) + + // Convert intro card notifications to IntroCardProps + const introCards: IntroCardProps[] = useMemo(() => { + return introCardNotifications + .map((notification) => { + return convertNotificationToIntroCard(notification, { + onPress: () => handleIntroCardPress(notification.id), + onClose: () => handleIntroCardClose(notification.id), + }) + }) + .filter((card): card is IntroCardProps => card !== null) + }, [introCardNotifications, handleIntroCardPress, handleIntroCardClose]) + + return ( + <> + {/* Render intro cards in a stack with padding matching OnboardingIntroCardStack */} + {introCards.length > 0 && ( + + + + )} + + {/* Render force upgrade notifications with custom UI */} + {forceUpgradeNotifications.map((notification) => ( + + ))} + + {/* Render local trigger notifications with custom renderers */} + {localTriggerNotifications.map((notification) => { + if (isBackupReminderNotification(notification)) { + return ( + + ) + } + // Log warning for unknown local trigger notification + getLogger().warn( + 'NotificationContainer', + 'localTriggerNotifications', + `Unknown local trigger notification: ${notification.id}`, + { notification }, + ) + return null + })} + + {/* System banner notifications are rendered via SystemBannerPortal at the app root */} + + {/* Render other notification types (modals, standard banners, etc) */} + {otherNotifications.map((notification) => ( + + ))} + + ) +}) diff --git a/apps/mobile/src/notification-service/notification-renderer/SystemBannerPortal.tsx b/apps/mobile/src/notification-service/notification-renderer/SystemBannerPortal.tsx new file mode 100644 index 00000000..9b85b602 --- /dev/null +++ b/apps/mobile/src/notification-service/notification-renderer/SystemBannerPortal.tsx @@ -0,0 +1,73 @@ +import { createContext, memo, type ReactNode, useCallback, useContext, useMemo, useState } from 'react' +import { View } from 'react-native' + +/** + * Context for the system banner portal. + * Allows notification components to render system banners at the app root level. + */ +interface SystemBannerPortalContextValue { + /** + * Set the content to render in the portal. + * Pass null to clear the portal. + */ + setContent: (content: ReactNode) => void +} + +const SystemBannerPortalContext = createContext(null) + +/** + * Hook to access the system banner portal. + * Used by NotificationContainer to render system banners at the app root. + */ +export function useSystemBannerPortal(): SystemBannerPortalContextValue { + const context = useContext(SystemBannerPortalContext) + if (!context) { + throw new Error('useSystemBannerPortal must be used within a SystemBannerPortalProvider') + } + return context +} + +/** + * Provider for the system banner portal. Place this at the app root. + * + * System banners (like the offline banner) use position: absolute with bottom: 0, + * so they need to be rendered at the root level to position correctly on screen. + * + * Usage in App.tsx: + * ```tsx + * + * + * + * ``` + * + * Then in NotificationContainer: + * ```tsx + * const { setContent } = useSystemBannerPortal() + * useEffect(() => { + * setContent() + * return () => setContent(null) + * }, []) + * ``` + */ +export const SystemBannerPortalProvider = memo(function SystemBannerPortalProvider({ + children, +}: { + children: ReactNode +}): JSX.Element { + const [portalContent, setPortalContent] = useState(null) + + const setContent = useCallback((content: ReactNode) => { + setPortalContent(content) + }, []) + + // Memoize context value to prevent unnecessary consumer re-renders + const contextValue = useMemo(() => ({ setContent }), [setContent]) + + return ( + + {children} + {/* Portal host - renders at app root level */} + {portalContent && {portalContent}} + + ) +}) diff --git a/apps/mobile/src/notification-service/notification-renderer/createMobileNotificationRenderer.ts b/apps/mobile/src/notification-service/notification-renderer/createMobileNotificationRenderer.ts new file mode 100644 index 00000000..73394f14 --- /dev/null +++ b/apps/mobile/src/notification-service/notification-renderer/createMobileNotificationRenderer.ts @@ -0,0 +1,38 @@ +import { ContentStyle, type InAppNotification } from '@universe/api' +import { createNotificationRenderer, type NotificationRenderer } from '@universe/notifications' +import { type NotificationState } from 'src/notification-service/notification-renderer/notificationStore' +import { type StoreApi, type UseBoundStore } from 'zustand' + +interface CreateMobileNotificationRendererContext { + store: UseBoundStore> +} + +/** + * Creates a mobile-specific NotificationRenderer that uses Zustand store. + * This renderer coordinates rendering for all notification types on mobile. + */ +export function createMobileNotificationRenderer(ctx: CreateMobileNotificationRendererContext): NotificationRenderer { + const store = ctx.store + + return createNotificationRenderer({ + render: (notification: InAppNotification): (() => void) => { + store.getState().addNotification(notification) + + return (): void => { + store.getState().removeNotification(notification.id) + } + }, + + canRender: (notification: InAppNotification): boolean => { + const { activeNotifications } = store.getState() + const style = notification.content?.style + + if (style === ContentStyle.MODAL) { + const hasActiveModal = activeNotifications.some((n) => n.content?.style === ContentStyle.MODAL) + return !hasActiveModal + } + + return true + }, + }) +} diff --git a/apps/mobile/src/notification-service/notification-renderer/notificationStore.ts b/apps/mobile/src/notification-service/notification-renderer/notificationStore.ts new file mode 100644 index 00000000..8251819b --- /dev/null +++ b/apps/mobile/src/notification-service/notification-renderer/notificationStore.ts @@ -0,0 +1,35 @@ +import { type InAppNotification } from '@universe/api' +import { create, type StoreApi, type UseBoundStore } from 'zustand' + +export interface NotificationState { + // Currently active notifications + activeNotifications: InAppNotification[] + // Add a notification to be rendered + addNotification: (notification: InAppNotification) => void + // Remove a notification from the active list + removeNotification: (notificationId: string) => void +} + +export const mobileNotificationStore: UseBoundStore> = create((set) => ({ + activeNotifications: [], + + addNotification: (notification: InAppNotification): void => { + set((state) => { + // The NotificationService should prevent duplicates, but we check here defensively just in case. + const exists = state.activeNotifications.some((n) => n.id === notification.id) + if (exists) { + return state + } + + return { + activeNotifications: [...state.activeNotifications, notification], + } + }) + }, + + removeNotification: (notificationId: string): void => { + set((state) => ({ + activeNotifications: state.activeNotifications.filter((n) => n.id !== notificationId), + })) + }, +})) diff --git a/apps/mobile/src/notification-service/notification-telemetry/getNotificationTelemetry.ts b/apps/mobile/src/notification-service/notification-telemetry/getNotificationTelemetry.ts new file mode 100644 index 00000000..0f18f720 --- /dev/null +++ b/apps/mobile/src/notification-service/notification-telemetry/getNotificationTelemetry.ts @@ -0,0 +1,36 @@ +import { createNotificationTelemetry, type NotificationTelemetry } from '@universe/notifications' +import { InterfaceEventName } from 'uniswap/src/features/telemetry/constants' +import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' + +/** + * Creates a NotificationTelemetry implementation that sends events to Amplitude + * via the existing mobile analytics infrastructure. + */ +export function getNotificationTelemetry(): NotificationTelemetry { + return createNotificationTelemetry({ + onNotificationReceived(params) { + sendAnalyticsEvent(InterfaceEventName.NotificationReceived, { + notification_id: params.notificationId, + notification_type: params.type, + source: params.source, + timestamp: params.timestamp, + }) + }, + + onNotificationShown(params) { + sendAnalyticsEvent(InterfaceEventName.NotificationShown, { + notification_id: params.notificationId, + notification_type: params.type, + timestamp: params.timestamp, + }) + }, + + onNotificationInteracted(params) { + sendAnalyticsEvent(InterfaceEventName.NotificationInteracted, { + notification_id: params.notificationId, + notification_type: params.type, + action: params.action, + }) + }, + }) +} diff --git a/apps/mobile/src/notification-service/renderers/BackupReminderModalRenderer.tsx b/apps/mobile/src/notification-service/renderers/BackupReminderModalRenderer.tsx new file mode 100644 index 00000000..9d48bb75 --- /dev/null +++ b/apps/mobile/src/notification-service/renderers/BackupReminderModalRenderer.tsx @@ -0,0 +1,40 @@ +import { type InAppNotification } from '@universe/api' +import { type NotificationClickTarget } from '@universe/notifications' +import { useEffect } from 'react' +import { BackupReminderModal } from 'src/app/modals/BackupReminderModal' + +interface BackupReminderModalRendererProps { + notification: InAppNotification + onNotificationClick?: (notificationId: string, target: NotificationClickTarget) => void + onNotificationShown?: (notificationId: string) => void +} + +/** + * Wrapper component that renders the BackupReminderModal within the notification service. + * + * This component: + * 1. Reports when the modal is shown (for telemetry) + * 2. Reports when the modal is dismissed (for tracking) + * 3. Preserves the existing modal UI exactly (no visual changes) + * + * The BackupReminderModal handles its own internal state and Redux updates. + * This wrapper just handles the notification service integration. + */ +export function BackupReminderModalRenderer({ + notification, + onNotificationClick, + onNotificationShown, +}: BackupReminderModalRendererProps): JSX.Element { + // Report when the modal is shown + useEffect(() => { + onNotificationShown?.(notification.id) + }, [notification.id, onNotificationShown]) + + const handleClose = (): void => { + // Report to notification service that user dismissed the modal + // The BackupReminderModal internally handles analytics and Redux updates + onNotificationClick?.(notification.id, { type: 'dismiss' }) + } + + return +} diff --git a/apps/mobile/src/notification-service/renderers/OfflineBannerRenderer.tsx b/apps/mobile/src/notification-service/renderers/OfflineBannerRenderer.tsx new file mode 100644 index 00000000..9d448c52 --- /dev/null +++ b/apps/mobile/src/notification-service/renderers/OfflineBannerRenderer.tsx @@ -0,0 +1,59 @@ +import { type InAppNotification } from '@universe/api' +import { type NotificationClickTarget } from '@universe/notifications' +import { useCallback, useEffect } from 'react' +import { useTranslation } from 'react-i18next' +import { BANNER_HEIGHT, BottomBanner } from 'src/components/banners/BottomBanner' +import { InfoCircle } from 'ui/src/components/icons/InfoCircle' + +const EXTRA_MARGIN = 5 + +interface OfflineBannerRendererProps { + notification: InAppNotification + onNotificationClick?: (notificationId: string, target: NotificationClickTarget) => void + onNotificationShown?: (notificationId: string) => void +} + +/** + * Renderer for the offline banner notification. + * + * This component preserves the exact UI of the original OfflineBanner component: + * - Bottom fixed position banner + * - InfoCircle icon + * - Translated "You're offline" text + * - 45px height with animation + * - Dismissable via X button (per-session only) + * + * Note: __DEV__ mode check is NOT done here - the condition should not + * emit notifications in dev mode. This keeps the renderer pure and + * focused on presentation only. + * + * @see OfflineBanner for the original implementation + */ +export function OfflineBannerRenderer({ + notification, + onNotificationClick, + onNotificationShown, +}: OfflineBannerRendererProps): JSX.Element { + const { t } = useTranslation() + + // Report when the banner is shown + useEffect(() => { + onNotificationShown?.(notification.id) + }, [notification.id, onNotificationShown]) + + // Handle dismiss - uses session-scoped tracking (local:session: prefix) + // so the banner will reappear on app restart if still offline + const handleDismiss = useCallback(() => { + onNotificationClick?.(notification.id, { type: 'dismiss' }) + }, [notification.id, onNotificationClick]) + + return ( + } + text={t('home.banner.offline')} + translateY={BANNER_HEIGHT - EXTRA_MARGIN} + onDismiss={handleDismiss} + /> + ) +} diff --git a/apps/mobile/src/notification-service/triggers/backupReminderTrigger.test.ts b/apps/mobile/src/notification-service/triggers/backupReminderTrigger.test.ts new file mode 100644 index 00000000..72f5137b --- /dev/null +++ b/apps/mobile/src/notification-service/triggers/backupReminderTrigger.test.ts @@ -0,0 +1,266 @@ +import { + Content, + Metadata, + Notification, +} from '@uniswap/client-notification-service/dist/uniswap/notificationservice/v1/api_pb' +import { ContentStyle } from '@universe/api' +import { type MobileState } from 'src/app/mobileReducer' +import { + BACKUP_REMINDER_NOTIFICATION_ID, + createBackupReminderTrigger, + isBackupReminderNotification, +} from 'src/notification-service/triggers/backupReminderTrigger' +import { AccountType } from 'uniswap/src/features/accounts/types' +import { ONE_DAY_MS } from 'utilities/src/time/time' +import { selectBackupReminderLastSeenTs } from 'wallet/src/features/behaviorHistory/selectors' +import { setBackupReminderLastSeenTs } from 'wallet/src/features/behaviorHistory/slice' +import { hasExternalBackup } from 'wallet/src/features/wallet/accounts/utils' +import { selectActiveAccount } from 'wallet/src/features/wallet/selectors' + +jest.mock('wallet/src/features/behaviorHistory/selectors') +jest.mock('wallet/src/features/wallet/selectors') +jest.mock('wallet/src/features/wallet/accounts/utils') + +const mockSelectBackupReminderLastSeenTs = selectBackupReminderLastSeenTs as jest.MockedFunction< + typeof selectBackupReminderLastSeenTs +> +const mockSelectActiveAccount = selectActiveAccount as jest.MockedFunction +const mockHasExternalBackup = hasExternalBackup as jest.MockedFunction + +describe('backupReminderTrigger', () => { + const mockDispatch = jest.fn() + const mockGetState = jest.fn() + const mockGetPortfolioValue = jest.fn, []>() + + const mockSignerAccount = { + address: '0x1234567890abcdef1234567890abcdef12345678', + type: AccountType.SignerMnemonic as const, + name: 'Test Account', + timeImportedMs: Date.now(), + pushNotificationsEnabled: false, + derivationIndex: 0, + mnemonicId: 'test-mnemonic-id', + } + + const mockViewOnlyAccount = { + address: '0xabcdef1234567890abcdef1234567890abcdef12', + type: AccountType.Readonly as const, + name: 'View Only Account', + timeImportedMs: Date.now(), + pushNotificationsEnabled: false, + } + + beforeEach(() => { + jest.clearAllMocks() + jest.useFakeTimers() + jest.setSystemTime(new Date('2024-06-15T12:00:00.000Z')) + }) + + afterEach(() => { + jest.useRealTimers() + }) + + describe('createBackupReminderTrigger', () => { + it('returns a trigger with the correct ID', () => { + const trigger = createBackupReminderTrigger({ + getState: mockGetState, + dispatch: mockDispatch, + getPortfolioValue: mockGetPortfolioValue, + }) + + expect(trigger.id).toBe(BACKUP_REMINDER_NOTIFICATION_ID) + expect(trigger.id.startsWith('local:')).toBe(true) + }) + + describe('shouldShow', () => { + it('returns true when all conditions are met', async () => { + mockSelectActiveAccount.mockReturnValue(mockSignerAccount) + mockHasExternalBackup.mockReturnValue(false) + mockSelectBackupReminderLastSeenTs.mockReturnValue(Date.now() - 2 * ONE_DAY_MS) // 2 days ago + mockGetPortfolioValue.mockResolvedValue(150) // $150 + + const trigger = createBackupReminderTrigger({ + getState: mockGetState, + dispatch: mockDispatch, + getPortfolioValue: mockGetPortfolioValue, + }) + + await expect(trigger.shouldShow()).resolves.toBe(true) + }) + + it('returns false when no active account', async () => { + mockSelectActiveAccount.mockReturnValue(null) + + const trigger = createBackupReminderTrigger({ + getState: mockGetState, + dispatch: mockDispatch, + getPortfolioValue: mockGetPortfolioValue, + }) + + await expect(trigger.shouldShow()).resolves.toBe(false) + expect(mockGetPortfolioValue).not.toHaveBeenCalled() + }) + + it('returns false when account is view-only (not signer)', async () => { + mockSelectActiveAccount.mockReturnValue(mockViewOnlyAccount) + + const trigger = createBackupReminderTrigger({ + getState: mockGetState, + dispatch: mockDispatch, + getPortfolioValue: mockGetPortfolioValue, + }) + + await expect(trigger.shouldShow()).resolves.toBe(false) + expect(mockGetPortfolioValue).not.toHaveBeenCalled() + }) + + it('returns false when account has external backup', async () => { + mockSelectActiveAccount.mockReturnValue(mockSignerAccount) + mockHasExternalBackup.mockReturnValue(true) + + const trigger = createBackupReminderTrigger({ + getState: mockGetState, + dispatch: mockDispatch, + getPortfolioValue: mockGetPortfolioValue, + }) + + await expect(trigger.shouldShow()).resolves.toBe(false) + expect(mockGetPortfolioValue).not.toHaveBeenCalled() + }) + + it('returns false when last seen is within 24 hours', async () => { + mockSelectActiveAccount.mockReturnValue(mockSignerAccount) + mockHasExternalBackup.mockReturnValue(false) + mockSelectBackupReminderLastSeenTs.mockReturnValue(Date.now() - 12 * 60 * 60 * 1000) // 12 hours ago + + const trigger = createBackupReminderTrigger({ + getState: mockGetState, + dispatch: mockDispatch, + getPortfolioValue: mockGetPortfolioValue, + }) + + await expect(trigger.shouldShow()).resolves.toBe(false) + expect(mockGetPortfolioValue).not.toHaveBeenCalled() + }) + + it('returns false when portfolio value is below $100', async () => { + mockSelectActiveAccount.mockReturnValue(mockSignerAccount) + mockHasExternalBackup.mockReturnValue(false) + mockSelectBackupReminderLastSeenTs.mockReturnValue(undefined) // Never seen + mockGetPortfolioValue.mockResolvedValue(50) // $50 + + const trigger = createBackupReminderTrigger({ + getState: mockGetState, + dispatch: mockDispatch, + getPortfolioValue: mockGetPortfolioValue, + }) + + await expect(trigger.shouldShow()).resolves.toBe(false) + }) + + it('returns true when never seen before and conditions met', async () => { + mockSelectActiveAccount.mockReturnValue(mockSignerAccount) + mockHasExternalBackup.mockReturnValue(false) + mockSelectBackupReminderLastSeenTs.mockReturnValue(undefined) // Never seen + mockGetPortfolioValue.mockResolvedValue(100) // Exactly $100 + + const trigger = createBackupReminderTrigger({ + getState: mockGetState, + dispatch: mockDispatch, + getPortfolioValue: mockGetPortfolioValue, + }) + + await expect(trigger.shouldShow()).resolves.toBe(true) + }) + + it('returns false when getPortfolioValue throws an error', async () => { + mockSelectActiveAccount.mockReturnValue(mockSignerAccount) + mockHasExternalBackup.mockReturnValue(false) + mockSelectBackupReminderLastSeenTs.mockReturnValue(undefined) + mockGetPortfolioValue.mockRejectedValue(new Error('Network error')) + + const trigger = createBackupReminderTrigger({ + getState: mockGetState, + dispatch: mockDispatch, + getPortfolioValue: mockGetPortfolioValue, + }) + + await expect(trigger.shouldShow()).resolves.toBe(false) + }) + }) + + describe('createNotification', () => { + it('returns a notification with the correct ID and style', () => { + const trigger = createBackupReminderTrigger({ + getState: mockGetState, + dispatch: mockDispatch, + getPortfolioValue: mockGetPortfolioValue, + }) + + const notification = trigger.createNotification() + + expect(notification.id).toBe(BACKUP_REMINDER_NOTIFICATION_ID) + expect(notification.content?.style).toBe(ContentStyle.MODAL) + }) + + it('returns a notification with local metadata', () => { + const trigger = createBackupReminderTrigger({ + getState: mockGetState, + dispatch: mockDispatch, + getPortfolioValue: mockGetPortfolioValue, + }) + + const notification = trigger.createNotification() + + expect(notification.metadata?.owner).toBe('local') + expect(notification.metadata?.business).toBe('backup_reminder') + }) + }) + + describe('onAcknowledge', () => { + it('dispatches setBackupReminderLastSeenTs with current timestamp when called', () => { + const trigger = createBackupReminderTrigger({ + getState: mockGetState, + dispatch: mockDispatch, + getPortfolioValue: mockGetPortfolioValue, + }) + + trigger.onAcknowledge?.() + + expect(mockDispatch).toHaveBeenCalledWith(setBackupReminderLastSeenTs(Date.now())) + }) + }) + }) + + describe('isBackupReminderNotification', () => { + it('returns true for backup reminder notification', () => { + const notification = new Notification({ + id: BACKUP_REMINDER_NOTIFICATION_ID, + content: new Content({ style: ContentStyle.MODAL, title: '' }), + metadata: new Metadata({ owner: 'local', business: 'backup_reminder' }), + }) + + expect(isBackupReminderNotification(notification)).toBe(true) + }) + + it('returns false for other notifications', () => { + const notification = new Notification({ + id: 'some-other-notification', + content: new Content({ style: ContentStyle.MODAL, title: '' }), + metadata: new Metadata({ owner: 'test', business: 'test' }), + }) + + expect(isBackupReminderNotification(notification)).toBe(false) + }) + + it('returns false for other local notifications', () => { + const notification = new Notification({ + id: 'local:other_trigger', + content: new Content({ style: ContentStyle.MODAL, title: '' }), + metadata: new Metadata({ owner: 'local', business: 'other' }), + }) + + expect(isBackupReminderNotification(notification)).toBe(false) + }) + }) +}) diff --git a/apps/mobile/src/notification-service/triggers/backupReminderTrigger.ts b/apps/mobile/src/notification-service/triggers/backupReminderTrigger.ts new file mode 100644 index 00000000..5111faec --- /dev/null +++ b/apps/mobile/src/notification-service/triggers/backupReminderTrigger.ts @@ -0,0 +1,131 @@ +import { + Content, + Metadata, + Notification, + OnClick, +} from '@uniswap/client-notification-service/dist/uniswap/notificationservice/v1/api_pb' +import { ContentStyle, type InAppNotification, OnClickAction } from '@universe/api' +import { type TriggerCondition } from '@universe/notifications/src/notification-data-source/implementations/createLocalTriggerDataSource' +import { type MobileState } from 'src/app/mobileReducer' +import { AccountType } from 'uniswap/src/features/accounts/types' +import { ONE_DAY_MS } from 'utilities/src/time/time' +import { selectBackupReminderLastSeenTs } from 'wallet/src/features/behaviorHistory/selectors' +import { setBackupReminderLastSeenTs } from 'wallet/src/features/behaviorHistory/slice' +import { hasExternalBackup } from 'wallet/src/features/wallet/accounts/utils' +import { selectActiveAccount } from 'wallet/src/features/wallet/selectors' + +/** + * Minimum portfolio value in USD to show the backup reminder + */ +const MIN_PORTFOLIO_VALUE_FOR_BACKUP_REMINDER = 100 + +/** + * Cooldown period between backup reminders (24 hours) + */ +const BACKUP_REMINDER_COOLDOWN_MS = ONE_DAY_MS + +/** + * Unique ID for the backup reminder notification. + * Uses 'local:' prefix to distinguish from backend-generated notifications. + */ +export const BACKUP_REMINDER_NOTIFICATION_ID = 'local:backup_reminder_modal' + +/** + * Context required to create the backup reminder trigger. + */ +interface CreateBackupReminderTriggerContext { + /** Function to get the current Redux state */ + getState: () => MobileState + /** Redux dispatch function */ + dispatch: (action: ReturnType) => void + /** Function to get current portfolio value in USD */ + getPortfolioValue: () => Promise +} + +/** + * Creates a trigger condition for the backup reminder modal. + * + * The trigger will show the modal when: + * - User has a signer account (not view-only) + * - User has no external backups (Cloud or Manual) + * - 24+ hours have passed since last reminder + * - Portfolio value is >= $100 USD + */ +export function createBackupReminderTrigger(ctx: CreateBackupReminderTriggerContext): TriggerCondition { + const { getState, dispatch, getPortfolioValue } = ctx + + return { + id: BACKUP_REMINDER_NOTIFICATION_ID, + + shouldShow: async (): Promise => { + const state = getState() + + // Check Redux-based conditions first (cheap) + const activeAccount = selectActiveAccount(state) + if (!activeAccount) { + return false + } + + // Must be a signer account (not view-only) + if (activeAccount.type !== AccountType.SignerMnemonic) { + return false + } + + // Must not have external backups + if (hasExternalBackup(activeAccount)) { + return false + } + + // Check 24-hour cooldown + const lastSeenTs = selectBackupReminderLastSeenTs(state) + const now = Date.now() + const timeSinceLastSeen = lastSeenTs ? now - lastSeenTs : Infinity + if (timeSinceLastSeen < BACKUP_REMINDER_COOLDOWN_MS) { + return false + } + + // Finally, check portfolio value (async) + try { + const portfolioValue = await getPortfolioValue() + return portfolioValue >= MIN_PORTFOLIO_VALUE_FOR_BACKUP_REMINDER + } catch { + // If we can't get portfolio value, don't show the modal + return false + } + }, + + createNotification: (): InAppNotification => { + // Create a minimal notification - the actual UI is rendered by BackupReminderModalRenderer + return new Notification({ + id: BACKUP_REMINDER_NOTIFICATION_ID, + metadata: new Metadata({ + owner: 'local', + business: 'backup_reminder', + }), + content: new Content({ + style: ContentStyle.MODAL, + title: '', // Title handled by BackupReminderModal component + version: 0, + buttons: [], // Buttons handled by BackupReminderModal component + // Required: notifications must have a DISMISS action to be valid + onDismissClick: new OnClick({ + onClick: [OnClickAction.DISMISS], + }), + }), + }) + }, + + onAcknowledge: (): void => { + // Update Redux to mark the last seen timestamp + dispatch(setBackupReminderLastSeenTs(Date.now())) + }, + } +} + +/** + * Type guard to check if a notification is the backup reminder notification. + * Used by NotificationContainer to route to the correct renderer. + */ +export function isBackupReminderNotification(notification: InAppNotification): boolean { + return notification.id === BACKUP_REMINDER_NOTIFICATION_ID +} diff --git a/apps/mobile/src/notification-service/triggers/createMobileLocalTriggerDataSource.test.ts b/apps/mobile/src/notification-service/triggers/createMobileLocalTriggerDataSource.test.ts new file mode 100644 index 00000000..96463ea3 --- /dev/null +++ b/apps/mobile/src/notification-service/triggers/createMobileLocalTriggerDataSource.test.ts @@ -0,0 +1,135 @@ +import { createLocalTriggerDataSource } from '@universe/notifications/src/notification-data-source/implementations/createLocalTriggerDataSource' +import { type NotificationTracker } from '@universe/notifications/src/notification-tracker/NotificationTracker' +import { type MobileState } from 'src/app/mobileReducer' +import { createBackupReminderTrigger } from 'src/notification-service/triggers/backupReminderTrigger' +import { + createMobileLocalTriggerDataSource, + isLocalTriggerNotification, +} from 'src/notification-service/triggers/createMobileLocalTriggerDataSource' + +jest.mock('@universe/notifications/src/notification-data-source/implementations/createLocalTriggerDataSource') +jest.mock('src/notification-service/triggers/backupReminderTrigger') + +const mockCreateLocalTriggerDataSource = createLocalTriggerDataSource as jest.MockedFunction< + typeof createLocalTriggerDataSource +> +const mockCreateBackupReminderTrigger = createBackupReminderTrigger as jest.MockedFunction< + typeof createBackupReminderTrigger +> + +describe('createMobileLocalTriggerDataSource', () => { + const mockDispatch = jest.fn() + const mockGetState = jest.fn() + const mockGetPortfolioValue = jest.fn, []>() + const mockTracker = { + isProcessed: jest.fn(), + markProcessed: jest.fn(), + markNotProcessed: jest.fn(), + } as unknown as NotificationTracker + + const mockBackupReminderTrigger = { + id: 'local:backup_reminder_modal', + shouldShow: jest.fn().mockResolvedValue(true), + createNotification: jest.fn(), + onAcknowledge: jest.fn(), + } + + beforeEach(() => { + jest.clearAllMocks() + mockCreateBackupReminderTrigger.mockReturnValue(mockBackupReminderTrigger) + mockCreateLocalTriggerDataSource.mockReturnValue({ + start: jest.fn(), + stop: jest.fn(), + }) + }) + + describe('createMobileLocalTriggerDataSource', () => { + it('creates a data source with backup reminder trigger', () => { + createMobileLocalTriggerDataSource({ + getState: mockGetState, + dispatch: mockDispatch, + tracker: mockTracker, + getPortfolioValue: mockGetPortfolioValue, + }) + + expect(mockCreateBackupReminderTrigger).toHaveBeenCalledWith({ + getState: mockGetState, + dispatch: mockDispatch, + getPortfolioValue: mockGetPortfolioValue, + }) + }) + + it('passes triggers to createLocalTriggerDataSource', () => { + createMobileLocalTriggerDataSource({ + getState: mockGetState, + dispatch: mockDispatch, + tracker: mockTracker, + getPortfolioValue: mockGetPortfolioValue, + }) + + expect(mockCreateLocalTriggerDataSource).toHaveBeenCalledWith({ + triggers: [mockBackupReminderTrigger], + tracker: mockTracker, + pollIntervalMs: 5000, // default + source: 'mobile_local_triggers', + logFileTag: 'createMobileLocalTriggerDataSource', + }) + }) + + it('uses custom poll interval when provided', () => { + createMobileLocalTriggerDataSource({ + getState: mockGetState, + dispatch: mockDispatch, + tracker: mockTracker, + getPortfolioValue: mockGetPortfolioValue, + pollIntervalMs: 10000, + }) + + expect(mockCreateLocalTriggerDataSource).toHaveBeenCalledWith( + expect.objectContaining({ + pollIntervalMs: 10000, + }), + ) + }) + + it('returns the data source from createLocalTriggerDataSource', () => { + const mockDataSource = { + start: jest.fn(), + stop: jest.fn(), + } + mockCreateLocalTriggerDataSource.mockReturnValue(mockDataSource) + + const result = createMobileLocalTriggerDataSource({ + getState: mockGetState, + dispatch: mockDispatch, + tracker: mockTracker, + getPortfolioValue: mockGetPortfolioValue, + }) + + expect(result).toBe(mockDataSource) + }) + }) + + describe('isLocalTriggerNotification', () => { + it('returns true for notifications with local: prefix', () => { + expect(isLocalTriggerNotification('local:backup_reminder_modal')).toBe(true) + expect(isLocalTriggerNotification('local:app_rating')).toBe(true) + expect(isLocalTriggerNotification('local:some_other_trigger')).toBe(true) + }) + + it('returns false for notifications without local: prefix', () => { + expect(isLocalTriggerNotification('backup_reminder_modal')).toBe(false) + expect(isLocalTriggerNotification('some-notification-id')).toBe(false) + expect(isLocalTriggerNotification('uuid-1234-5678')).toBe(false) + }) + + it('returns false for empty string', () => { + expect(isLocalTriggerNotification('')).toBe(false) + }) + + it('is case-sensitive for prefix', () => { + expect(isLocalTriggerNotification('LOCAL:backup_reminder')).toBe(false) + expect(isLocalTriggerNotification('Local:backup_reminder')).toBe(false) + }) + }) +}) diff --git a/apps/mobile/src/notification-service/triggers/createMobileLocalTriggerDataSource.ts b/apps/mobile/src/notification-service/triggers/createMobileLocalTriggerDataSource.ts new file mode 100644 index 00000000..184c27b0 --- /dev/null +++ b/apps/mobile/src/notification-service/triggers/createMobileLocalTriggerDataSource.ts @@ -0,0 +1,72 @@ +import { + createLocalTriggerDataSource, + type TriggerCondition, +} from '@universe/notifications/src/notification-data-source/implementations/createLocalTriggerDataSource' +import { type NotificationDataSource } from '@universe/notifications/src/notification-data-source/NotificationDataSource' +import { type NotificationTracker } from '@universe/notifications/src/notification-tracker/NotificationTracker' +import { type MobileState } from 'src/app/mobileReducer' +import { createBackupReminderTrigger } from 'src/notification-service/triggers/backupReminderTrigger' +import { setBackupReminderLastSeenTs } from 'wallet/src/features/behaviorHistory/slice' + +/** + * Context required to create the mobile local trigger data source. + */ +interface CreateMobileLocalTriggerDataSourceContext { + /** Function to get the current Redux state */ + getState: () => MobileState + /** Redux dispatch function */ + dispatch: (action: ReturnType) => void + /** Notification tracker for checking processed state */ + tracker: NotificationTracker + /** Function to get current portfolio value in USD for the active account */ + getPortfolioValue: () => Promise + /** How often to check triggers in milliseconds (default: 5000ms) */ + pollIntervalMs?: number +} + +/** + * All trigger conditions for mobile. + * Add new triggers here as they are migrated. + */ +function getMobileTriggers(ctx: { + getState: () => MobileState + dispatch: (action: ReturnType) => void + getPortfolioValue: () => Promise +}): TriggerCondition[] { + return [ + createBackupReminderTrigger(ctx), + // Future triggers can be added here: + // createAppRatingTrigger(ctx), + // etc. + ] +} + +/** + * Creates a data source for all mobile local trigger notifications. + * + * This combines all mobile-specific triggers (backup reminder, etc.) + * into a single data source that can be added to the notification service. + */ +export function createMobileLocalTriggerDataSource( + ctx: CreateMobileLocalTriggerDataSourceContext, +): NotificationDataSource { + const { getState, dispatch, tracker, getPortfolioValue, pollIntervalMs = 5000 } = ctx + + const triggers = getMobileTriggers({ getState, dispatch, getPortfolioValue }) + + return createLocalTriggerDataSource({ + triggers, + tracker, + pollIntervalMs, + source: 'mobile_local_triggers', + logFileTag: 'createMobileLocalTriggerDataSource', + }) +} + +/** + * Check if a notification ID is a local trigger notification. + * Local trigger notifications use the 'local:' prefix. + */ +export function isLocalTriggerNotification(notificationId: string): boolean { + return notificationId.startsWith('local:') +} diff --git a/apps/mobile/src/package.json b/apps/mobile/src/package.json new file mode 100644 index 00000000..0e71c241 --- /dev/null +++ b/apps/mobile/src/package.json @@ -0,0 +1,3 @@ +{ + "name": "src" +} diff --git a/apps/mobile/src/polyfills/arrayAt.js b/apps/mobile/src/polyfills/arrayAt.js new file mode 100644 index 00000000..514df0a1 --- /dev/null +++ b/apps/mobile/src/polyfills/arrayAt.js @@ -0,0 +1,29 @@ +// From https://github.com/tc39/proposal-relative-indexing-method#polyfill +if (!Array.prototype.at) { + function at(n) { + // ToInteger() abstract op + n = Math.trunc(n) || 0 + // Allow negative indexing from the end + if (n < 0) { + n += this.length + } + // OOB access is guaranteed to return undefined + if (n < 0 || n >= this.length) { + return undefined + } + // Otherwise, this is just normal property access + return this[n] + } + + const TypedArray = Reflect.getPrototypeOf(Int8Array) + for (const C of [Array, String, TypedArray]) { + Object.defineProperty(C.prototype, 'at', { + value: at, + writable: true, + enumerable: false, + configurable: true, + }) + } +} + +export {} diff --git a/apps/mobile/src/polyfills/index.ts b/apps/mobile/src/polyfills/index.ts new file mode 100644 index 00000000..ae5f02a4 --- /dev/null +++ b/apps/mobile/src/polyfills/index.ts @@ -0,0 +1,13 @@ +/** + * Ethers Shims + * https://docs.ethers.io/v5/cookbook/react-native/#cookbook-reactnative-security + */ + +// Import the crypto getRandomValues shim BEFORE ethers shims +import 'react-native-get-random-values' +// Import the ethers shims BEFORE ethers +import '@ethersproject/shims' +// Add .at() method to Array if necessary (missing before iOS 15) +import 'src/polyfills/arrayAt' +// Import the Intl polyfills for Hermes +import 'src/polyfills/intl' diff --git a/apps/mobile/src/polyfills/intl-delayed.ts b/apps/mobile/src/polyfills/intl-delayed.ts new file mode 100644 index 00000000..1cc932f0 --- /dev/null +++ b/apps/mobile/src/polyfills/intl-delayed.ts @@ -0,0 +1,260 @@ +import { getWalletDeviceLocale } from 'uniswap/src/i18n/utils' + +export function initDynamicIntlPolyfills(): void { + const locale = getWalletDeviceLocale() + loadDynamicIntlPolyfills(locale) +} + +/** + * Synchronously load the locale polyfills for the given language code. + * We need to load them synchronously because the polyfills are needed for other code to run. + * Polyfills affect the app startup time, so we need to load them selectively. + */ +// oxlint-disable-next-line complexity +function loadDynamicIntlPolyfills(locale: string): void { + const baseCode = locale.split('-')[0] + + // Always load English for swap inputs number formatting + require('@formatjs/intl-numberformat/locale-data/en') + + switch (baseCode) { + case 'zh': + require('@formatjs/intl-pluralrules/locale-data/zh') + switch (locale) { + case 'zh-Hans': + require('@formatjs/intl-numberformat/locale-data/zh-Hans') + require('@formatjs/intl-datetimeformat/locale-data/zh-Hans') + require('@formatjs/intl-relativetimeformat/locale-data/zh-Hans') + break + case 'zh-Hant': + require('@formatjs/intl-numberformat/locale-data/zh-Hant') + require('@formatjs/intl-datetimeformat/locale-data/zh-Hant') + require('@formatjs/intl-relativetimeformat/locale-data/zh-Hant') + break + default: + require('@formatjs/intl-numberformat/locale-data/zh') + require('@formatjs/intl-datetimeformat/locale-data/zh') + require('@formatjs/intl-relativetimeformat/locale-data/zh') + break + } + break + + case 'nl': + require('@formatjs/intl-pluralrules/locale-data/nl') + require('@formatjs/intl-numberformat/locale-data/nl') + require('@formatjs/intl-datetimeformat/locale-data/nl') + require('@formatjs/intl-relativetimeformat/locale-data/nl') + break + + case 'es': + require('@formatjs/intl-pluralrules/locale-data/es') + switch (locale) { + case 'es-419': + require('@formatjs/intl-numberformat/locale-data/es-419') + require('@formatjs/intl-datetimeformat/locale-data/es-419') + require('@formatjs/intl-relativetimeformat/locale-data/es-419') + break + case 'es-BZ': + require('@formatjs/intl-numberformat/locale-data/es-BZ') + require('@formatjs/intl-datetimeformat/locale-data/es-BZ') + require('@formatjs/intl-relativetimeformat/locale-data/es-BZ') + break + case 'es-CU': + require('@formatjs/intl-numberformat/locale-data/es-CU') + require('@formatjs/intl-datetimeformat/locale-data/es-CU') + require('@formatjs/intl-relativetimeformat/locale-data/es-CU') + break + case 'es-DO': + require('@formatjs/intl-numberformat/locale-data/es-DO') + require('@formatjs/intl-datetimeformat/locale-data/es-DO') + require('@formatjs/intl-relativetimeformat/locale-data/es-DO') + break + case 'es-GT': + require('@formatjs/intl-numberformat/locale-data/es-GT') + require('@formatjs/intl-datetimeformat/locale-data/es-GT') + require('@formatjs/intl-relativetimeformat/locale-data/es-GT') + break + case 'es-HN': + require('@formatjs/intl-numberformat/locale-data/es-HN') + require('@formatjs/intl-datetimeformat/locale-data/es-HN') + require('@formatjs/intl-relativetimeformat/locale-data/es-HN') + break + case 'es-MX': + require('@formatjs/intl-numberformat/locale-data/es-MX') + require('@formatjs/intl-datetimeformat/locale-data/es-MX') + require('@formatjs/intl-relativetimeformat/locale-data/es-MX') + break + case 'es-NI': + require('@formatjs/intl-numberformat/locale-data/es-NI') + require('@formatjs/intl-datetimeformat/locale-data/es-NI') + require('@formatjs/intl-relativetimeformat/locale-data/es-NI') + break + case 'es-PA': + require('@formatjs/intl-numberformat/locale-data/es-PA') + require('@formatjs/intl-datetimeformat/locale-data/es-PA') + require('@formatjs/intl-relativetimeformat/locale-data/es-PA') + break + case 'es-PE': + require('@formatjs/intl-numberformat/locale-data/es-PE') + require('@formatjs/intl-datetimeformat/locale-data/es-PE') + require('@formatjs/intl-relativetimeformat/locale-data/es-PE') + break + case 'es-PR': + require('@formatjs/intl-numberformat/locale-data/es-PR') + require('@formatjs/intl-datetimeformat/locale-data/es-PR') + require('@formatjs/intl-relativetimeformat/locale-data/es-PR') + break + case 'es-SV': + require('@formatjs/intl-numberformat/locale-data/es-SV') + require('@formatjs/intl-datetimeformat/locale-data/es-SV') + require('@formatjs/intl-relativetimeformat/locale-data/es-SV') + break + case 'es-US': + require('@formatjs/intl-numberformat/locale-data/es-US') + require('@formatjs/intl-datetimeformat/locale-data/es-US') + require('@formatjs/intl-relativetimeformat/locale-data/es-US') + break + case 'es-AR': + require('@formatjs/intl-numberformat/locale-data/es-AR') + require('@formatjs/intl-datetimeformat/locale-data/es-AR') + require('@formatjs/intl-relativetimeformat/locale-data/es-AR') + break + case 'es-BO': + require('@formatjs/intl-numberformat/locale-data/es-BO') + require('@formatjs/intl-datetimeformat/locale-data/es-BO') + require('@formatjs/intl-relativetimeformat/locale-data/es-BO') + break + case 'es-CL': + require('@formatjs/intl-numberformat/locale-data/es-CL') + require('@formatjs/intl-datetimeformat/locale-data/es-CL') + require('@formatjs/intl-relativetimeformat/locale-data/es-CL') + break + case 'es-CO': + require('@formatjs/intl-numberformat/locale-data/es-CO') + require('@formatjs/intl-datetimeformat/locale-data/es-CO') + require('@formatjs/intl-relativetimeformat/locale-data/es-CO') + break + case 'es-CR': + require('@formatjs/intl-numberformat/locale-data/es-CR') + require('@formatjs/intl-datetimeformat/locale-data/es-CR') + require('@formatjs/intl-relativetimeformat/locale-data/es-CR') + break + case 'es-EC': + require('@formatjs/intl-numberformat/locale-data/es-EC') + require('@formatjs/intl-datetimeformat/locale-data/es-EC') + require('@formatjs/intl-relativetimeformat/locale-data/es-EC') + break + case 'es-PY': + require('@formatjs/intl-numberformat/locale-data/es-PY') + require('@formatjs/intl-datetimeformat/locale-data/es-PY') + require('@formatjs/intl-relativetimeformat/locale-data/es-PY') + break + case 'es-UY': + require('@formatjs/intl-numberformat/locale-data/es-UY') + require('@formatjs/intl-datetimeformat/locale-data/es-UY') + require('@formatjs/intl-relativetimeformat/locale-data/es-UY') + break + case 'es-VE': + require('@formatjs/intl-numberformat/locale-data/es-VE') + require('@formatjs/intl-datetimeformat/locale-data/es-VE') + require('@formatjs/intl-relativetimeformat/locale-data/es-VE') + break + default: + require('@formatjs/intl-numberformat/locale-data/es') + require('@formatjs/intl-datetimeformat/locale-data/es') + require('@formatjs/intl-relativetimeformat/locale-data/es') + break + } + break + + case 'fr': + require('@formatjs/intl-pluralrules/locale-data/fr') + require('@formatjs/intl-numberformat/locale-data/fr') + require('@formatjs/intl-datetimeformat/locale-data/fr') + require('@formatjs/intl-relativetimeformat/locale-data/fr') + break + + case 'hi': + require('@formatjs/intl-pluralrules/locale-data/hi') + require('@formatjs/intl-numberformat/locale-data/hi') + require('@formatjs/intl-datetimeformat/locale-data/hi') + require('@formatjs/intl-relativetimeformat/locale-data/hi') + break + + case 'id': + require('@formatjs/intl-pluralrules/locale-data/id') + require('@formatjs/intl-numberformat/locale-data/id') + require('@formatjs/intl-datetimeformat/locale-data/id') + require('@formatjs/intl-relativetimeformat/locale-data/id') + break + + case 'ja': + require('@formatjs/intl-pluralrules/locale-data/ja') + require('@formatjs/intl-numberformat/locale-data/ja') + require('@formatjs/intl-datetimeformat/locale-data/ja') + require('@formatjs/intl-relativetimeformat/locale-data/ja') + break + + case 'ms': + require('@formatjs/intl-pluralrules/locale-data/ms') + require('@formatjs/intl-numberformat/locale-data/ms') + require('@formatjs/intl-datetimeformat/locale-data/ms') + require('@formatjs/intl-relativetimeformat/locale-data/ms') + break + + case 'pt': + require('@formatjs/intl-pluralrules/locale-data/pt') + require('@formatjs/intl-numberformat/locale-data/pt') + require('@formatjs/intl-datetimeformat/locale-data/pt') + require('@formatjs/intl-relativetimeformat/locale-data/pt') + break + + case 'ru': + require('@formatjs/intl-pluralrules/locale-data/ru') + require('@formatjs/intl-numberformat/locale-data/ru') + require('@formatjs/intl-datetimeformat/locale-data/ru') + require('@formatjs/intl-relativetimeformat/locale-data/ru') + break + + case 'th': + require('@formatjs/intl-pluralrules/locale-data/th') + require('@formatjs/intl-numberformat/locale-data/th') + require('@formatjs/intl-datetimeformat/locale-data/th') + require('@formatjs/intl-relativetimeformat/locale-data/th') + break + + case 'tr': + require('@formatjs/intl-pluralrules/locale-data/tr') + require('@formatjs/intl-numberformat/locale-data/tr') + require('@formatjs/intl-datetimeformat/locale-data/tr') + require('@formatjs/intl-relativetimeformat/locale-data/tr') + break + + case 'uk': + require('@formatjs/intl-pluralrules/locale-data/uk') + require('@formatjs/intl-numberformat/locale-data/uk') + require('@formatjs/intl-datetimeformat/locale-data/uk') + require('@formatjs/intl-relativetimeformat/locale-data/uk') + break + + case 'ur': + require('@formatjs/intl-pluralrules/locale-data/ur') + require('@formatjs/intl-numberformat/locale-data/ur') + require('@formatjs/intl-datetimeformat/locale-data/ur') + require('@formatjs/intl-relativetimeformat/locale-data/ur') + break + + case 'vi': + require('@formatjs/intl-pluralrules/locale-data/vi') + require('@formatjs/intl-numberformat/locale-data/vi') + require('@formatjs/intl-datetimeformat/locale-data/vi') + require('@formatjs/intl-relativetimeformat/locale-data/vi') + break + + default: + require('@formatjs/intl-pluralrules/locale-data/en') + require('@formatjs/intl-datetimeformat/locale-data/en') + require('@formatjs/intl-relativetimeformat/locale-data/en') + break + } +} diff --git a/apps/mobile/src/polyfills/intl.js b/apps/mobile/src/polyfills/intl.js new file mode 100644 index 00000000..abd20fc4 --- /dev/null +++ b/apps/mobile/src/polyfills/intl.js @@ -0,0 +1,19 @@ +/* oxlint-disable typescript/no-unused-expressions */ +import { isAndroid } from 'utilities/src/platform' + +// TODO: [MOB-247] remove polyfill once Hermes support it +// https://github.com/facebook/hermes/issues/23 + +// Polyfills required to use Intl with Hermes engine +require('@formatjs/intl-getcanonicallocales/polyfill').default +require('@formatjs/intl-locale/polyfill').default +require('@formatjs/intl-pluralrules/polyfill').default +if (isAndroid) { + // Forces polyfill to replace Hermes NumberFormat due to issues with "compact" notation + // https://hermesengine.dev/docs/intl/#android-11 + require('@formatjs/intl-numberformat/polyfill-force').default +} else { + require('@formatjs/intl-numberformat/polyfill').default +} +require('@formatjs/intl-datetimeformat/polyfill').default +require('@formatjs/intl-relativetimeformat/polyfill').default diff --git a/apps/mobile/src/react-native-dotenv.d.ts b/apps/mobile/src/react-native-dotenv.d.ts new file mode 100644 index 00000000..3f08c798 --- /dev/null +++ b/apps/mobile/src/react-native-dotenv.d.ts @@ -0,0 +1,3 @@ +declare module 'react-native-dotenv' { + export * from 'uniswap/src/react-native-dotenv.d.ts' +} diff --git a/apps/mobile/src/screens/ActivityScreen.tsx b/apps/mobile/src/screens/ActivityScreen.tsx new file mode 100644 index 00000000..68e8a231 --- /dev/null +++ b/apps/mobile/src/screens/ActivityScreen.tsx @@ -0,0 +1,59 @@ +import { useScrollToTop } from '@react-navigation/native' +import { FeatureFlags, useFeatureFlag } from '@universe/gating' +import { useEffect, useMemo, useRef } from 'react' +import { useTranslation } from 'react-i18next' +import { useDispatch } from 'react-redux' +import { ESTIMATED_BOTTOM_TABS_HEIGHT } from 'src/app/navigation/tabs/CustomTabBar/constants' +import { ActivityContent } from 'src/components/activity/ActivityContent' +import { Screen } from 'src/components/layout/Screen' +import { Text } from 'ui/src' +import { spacing } from 'ui/src/theme' +import { AccountType } from 'uniswap/src/features/accounts/types' +import { useSelectAddressHasNotifications } from 'uniswap/src/features/notifications/slice/hooks' +import { setNotificationStatus } from 'uniswap/src/features/notifications/slice/slice' +import { useAppInsets } from 'uniswap/src/hooks/useAppInsets' +import { useActiveAccountWithThrow } from 'wallet/src/features/wallet/hooks' + +export function ActivityScreen(): JSX.Element { + const { t } = useTranslation() + const activeAccount = useActiveAccountWithThrow() + const dispatch = useDispatch() + const scrollRef = useRef(null) + + useScrollToTop(scrollRef) + + const insets = useAppInsets() + const isBottomTabsEnabled = useFeatureFlag(FeatureFlags.BottomTabs) + + const containerProps = useMemo( + () => ({ + contentContainerStyle: { + paddingBottom: isBottomTabsEnabled ? ESTIMATED_BOTTOM_TABS_HEIGHT + insets.bottom + spacing.spacing32 : 0, + }, + }), + [isBottomTabsEnabled, insets.bottom], + ) + + // clear all notification indicator if the user is on the activity screen + const hasNotifications = useSelectAddressHasNotifications(activeAccount.address) + + useEffect(() => { + if (hasNotifications) { + dispatch(setNotificationStatus({ address: activeAccount.address, hasNotifications: false })) + } + }, [hasNotifications, activeAccount.address, dispatch]) + + return ( + + + {t('common.activity')} + + + + ) +} diff --git a/apps/mobile/src/screens/AppLoadingScreen.tsx b/apps/mobile/src/screens/AppLoadingScreen.tsx new file mode 100644 index 00000000..72a82449 --- /dev/null +++ b/apps/mobile/src/screens/AppLoadingScreen.tsx @@ -0,0 +1,275 @@ +import { type NativeStackScreenProps } from '@react-navigation/native-stack' +import { DynamicConfigs, OnDeviceRecoveryConfigKey, useDynamicConfigValue } from '@universe/gating' +import dayjs from 'dayjs' +import { isEnrolledAsync } from 'expo-local-authentication' +import { useCallback, useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useDispatch, useSelector } from 'react-redux' +import { type OnboardingStackParamList } from 'src/app/navigation/types' +import { SplashScreen } from 'src/features/appLoading/SplashScreen' +import { useBiometricAppSettings } from 'src/features/biometrics/useBiometricAppSettings' +import { useBiometricsState } from 'src/features/biometrics/useBiometricsState' +import { + NotificationPermission, + useNotificationOSPermissionsEnabled, +} from 'src/features/notifications/hooks/useNotificationOSPermissionsEnabled' +import { useHideSplashScreen } from 'src/features/splashScreen/useHideSplashScreen' +import { type RecoveryWalletInfo, useOnDeviceRecoveryData } from 'src/screens/Import/useOnDeviceRecoveryData' +import { AccountType } from 'uniswap/src/features/accounts/types' +import { MobileEventName } from 'uniswap/src/features/telemetry/constants' +import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import Trace from 'uniswap/src/features/telemetry/Trace' +import { ImportType, OnboardingEntryPoint } from 'uniswap/src/types/onboarding' +import { OnboardingScreens } from 'uniswap/src/types/screens/mobile' +import { logger } from 'utilities/src/logger/logger' +import { useOnboardingContext } from 'wallet/src/features/onboarding/OnboardingContext' +import { type SignerMnemonicAccount } from 'wallet/src/features/wallet/accounts/types' +import { Keyring } from 'wallet/src/features/wallet/Keyring/Keyring' +import { selectAnyAddressHasNotificationsEnabled } from 'wallet/src/features/wallet/selectors' +import { setFinishedOnboarding } from 'wallet/src/features/wallet/slice' + +type Props = NativeStackScreenProps + +function useFinishAutomatedRecovery(navigation: Props['navigation']): { + finishRecovery: (mnemonicId: string, recoveryWalletInfos: RecoveryWalletInfo[]) => void +} { + const { t } = useTranslation() + const dispatch = useDispatch() + const { setRecoveredImportedAccounts, finishOnboarding } = useOnboardingContext() + const hideSplashScreen = useHideSplashScreen() + + const { notificationPermissionsEnabled: notificationOSPermission } = useNotificationOSPermissionsEnabled() + const hasAnyNotificationsEnabled = useSelector(selectAnyAddressHasNotificationsEnabled) + const { deviceSupportsBiometrics } = useBiometricsState() + const { requiredForTransactions: isBiometricAuthEnabled } = useBiometricAppSettings() + + const importAccounts = useCallback( + async (mnemonicId: string, recoveryWalletInfos: RecoveryWalletInfo[]) => { + const accountsToImport = recoveryWalletInfos.map((addressInfo, index): SignerMnemonicAccount => { + return { + type: AccountType.SignerMnemonic, + mnemonicId, + name: t('onboarding.wallet.defaultName', { number: index + 1 }), + address: addressInfo.address, + derivationIndex: addressInfo.derivationIndex, + timeImportedMs: dayjs().valueOf(), + pushNotificationsEnabled: true, + } + }) + setRecoveredImportedAccounts(accountsToImport) + }, + [t, setRecoveredImportedAccounts], + ) + + const finishRecovery = useCallback( + async (mnemonicId: string, recoveryWalletInfos: RecoveryWalletInfo[]) => { + logger.debug( + 'AppLoadingScreen', + 'finishRecovery', + `Starting recovery with ${recoveryWalletInfos.length} wallet(s)`, + ) + + await importAccounts(mnemonicId, recoveryWalletInfos) + logger.debug('AppLoadingScreen', 'finishRecovery', 'Accounts imported successfully') + + const isBiometricsEnrolled = await isEnrolledAsync() + + const showNotificationScreen = notificationOSPermission !== NotificationPermission.Enabled + const showBiometricsScreen = + (deviceSupportsBiometrics && (!isBiometricAuthEnabled || !isBiometricsEnrolled)) ?? false + + sendAnalyticsEvent(MobileEventName.AutomatedOnDeviceRecoveryTriggered, { + showNotificationScreen, + showBiometricsScreen, + notificationOSPermission, + hasAnyNotificationsEnabled, + deviceSupportsBiometrics, + isBiometricsEnrolled, + isBiometricAuthEnabled, + }) + + hideSplashScreen() + + // Notification screen should always navigate to biometrics screen if supported + // This is acceptable because we're already triggering a setup screen + // and biometrics is the more important one to include + if (showNotificationScreen) { + logger.debug('AppLoadingScreen', 'finishRecovery', 'Navigating to Notifications screen') + navigation.replace(OnboardingScreens.Notifications, { + importType: ImportType.OnDeviceRecovery, + entryPoint: OnboardingEntryPoint.FreshInstallOrReplace, + }) + } else if (showBiometricsScreen) { + logger.debug('AppLoadingScreen', 'finishRecovery', 'Navigating to Security screen') + navigation.replace(OnboardingScreens.Security, { + importType: ImportType.OnDeviceRecovery, + entryPoint: OnboardingEntryPoint.FreshInstallOrReplace, + }) + } else { + logger.debug('AppLoadingScreen', 'finishRecovery', 'Completing recovery directly without setup screens') + await finishOnboarding({ importType: ImportType.OnDeviceRecovery }) + dispatch(setFinishedOnboarding({ finishedOnboarding: true })) + logger.debug('AppLoadingScreen', 'finishRecovery', 'Recovery completed successfully') + } + }, + [ + deviceSupportsBiometrics, + dispatch, + finishOnboarding, + hasAnyNotificationsEnabled, + importAccounts, + isBiometricAuthEnabled, + navigation, + notificationOSPermission, + hideSplashScreen, + ], + ) + + return { + finishRecovery, + } +} + +const FALLBACK_APP_LOADING_TIMEOUT_MS = 15000 + +export function AppLoadingScreen({ navigation }: Props): JSX.Element | null { + const appLoadingTimeoutMs = useDynamicConfigValue({ + config: DynamicConfigs.OnDeviceRecovery, + key: OnDeviceRecoveryConfigKey.AppLoadingTimeoutMs, + defaultValue: FALLBACK_APP_LOADING_TIMEOUT_MS, + }) + const maxMnemonicsToLoad = useDynamicConfigValue({ + config: DynamicConfigs.OnDeviceRecovery, + key: OnDeviceRecoveryConfigKey.MaxMnemonicsToLoad, + defaultValue: 20, + }) + + // Used to stop this running multiple times during navigation + const [finished, setFinished] = useState(false) + + const [mnemonicIds, setMnemonicIds] = useState() + const { significantRecoveryWalletInfos, loading } = useOnDeviceRecoveryData(mnemonicIds?.[0]) + const { finishRecovery } = useFinishAutomatedRecovery(navigation) + + const navigateToLanding = useCallback((): void => { + logger.debug('AppLoadingScreen', 'navigateToLanding', 'Navigating to Landing screen') + navigation.replace(OnboardingScreens.Landing, { + importType: ImportType.NotYetSelected, + entryPoint: OnboardingEntryPoint.FreshInstallOrReplace, + }) + }, [navigation]) + + useEffect(() => { + logger.debug('AppLoadingScreen', 'useEffect-getMnemonicIds', 'Starting Keyring.getMnemonicIds() call') + Keyring.getMnemonicIds() + .then((storedMnemonicIds) => { + logger.debug( + 'AppLoadingScreen', + 'useEffect-getMnemonicIds', + `Successfully fetched ${storedMnemonicIds.length} mnemonic(s)`, + ) + setMnemonicIds(storedMnemonicIds) + sendAnalyticsEvent(MobileEventName.AutomatedOnDeviceRecoveryMnemonicsFound, { + mnemonicCount: storedMnemonicIds.length, + }) + }) + .catch(() => { + logger.error('Failed to load mnemonic ids', { + tags: { file: 'AppLoadingScreen', function: 'useEffect-getMnemonicIds' }, + }) + setMnemonicIds([]) // Needed to leave the loading screen + }) + }, []) + + useEffect(() => { + logger.debug('AppLoadingScreen', 'useEffect-timeout', `Setting up timeout for ${appLoadingTimeoutMs}ms`) + const timeout = setTimeout(() => { + if (!finished) { + setFinished(true) + navigateToLanding() + logger.warn('AppLoadingScreen', 'useEffect-timeout', `Loading timeout triggered after ${appLoadingTimeoutMs}ms`) + } + }, appLoadingTimeoutMs) + return () => clearTimeout(timeout) + }, [appLoadingTimeoutMs, finished, navigateToLanding]) + + // Logic to determine what screen to show on app load + useEffect(() => { + if (!mnemonicIds || finished) { + logger.debug( + 'AppLoadingScreen', + 'useEffect-chooseScreen', + `Early return: mnemonicIds=${mnemonicIds ? 'defined' : 'undefined'}, finished=${finished}`, + ) + return + } + + const mnemonicIdsCount = mnemonicIds.length + const firstMnemonicId = mnemonicIds[0] + + if (mnemonicIdsCount === 1 && firstMnemonicId) { + if (loading) { + logger.debug( + 'AppLoadingScreen', + 'useEffect-chooseScreen', + 'Single mnemonic found, waiting for wallet data to load', + ) + return + } + + setFinished(true) + + sendAnalyticsEvent(MobileEventName.AutomatedOnDeviceRecoverySingleMnemonicFetched, { + balance: significantRecoveryWalletInfos[0]?.balance ?? 0, + hasUnitag: Boolean(significantRecoveryWalletInfos[0]?.unitag), + hasENS: Boolean(significantRecoveryWalletInfos[0]?.ensName), + }) + if (significantRecoveryWalletInfos.length) { + logger.debug( + 'AppLoadingScreen', + 'useEffect-chooseScreen', + `Finishing recovery with ${significantRecoveryWalletInfos.length} wallet(s)`, + ) + finishRecovery(firstMnemonicId, significantRecoveryWalletInfos) + } else { + logger.debug( + 'AppLoadingScreen', + 'useEffect-chooseScreen', + 'No significant wallets found, navigating to Landing', + ) + navigateToLanding() + } + } else if (mnemonicIdsCount > 1) { + logger.debug( + 'AppLoadingScreen', + 'useEffect-chooseScreen', + `Multiple mnemonics (${mnemonicIdsCount}) found, navigating to OnDeviceRecovery screen`, + ) + setFinished(true) + navigation.replace(OnboardingScreens.OnDeviceRecovery, { + importType: ImportType.OnDeviceRecovery, + entryPoint: OnboardingEntryPoint.FreshInstallOrReplace, + mnemonicIds: mnemonicIds.slice(0, maxMnemonicsToLoad), + }) + } else { + logger.debug('AppLoadingScreen', 'useEffect-chooseScreen', 'No mnemonics found, navigating to Landing') + setFinished(true) + navigateToLanding() + } + }, [ + finishRecovery, + finished, + loading, + maxMnemonicsToLoad, + mnemonicIds, + navigateToLanding, + navigation, + significantRecoveryWalletInfos, + ]) + + return ( + + + + ) +} diff --git a/apps/mobile/src/screens/DebugScreensScreen.tsx b/apps/mobile/src/screens/DebugScreensScreen.tsx new file mode 100644 index 00000000..c6c65105 --- /dev/null +++ b/apps/mobile/src/screens/DebugScreensScreen.tsx @@ -0,0 +1,122 @@ +import { LegendList } from '@legendapp/list' +import React, { memo, useCallback, useMemo } from 'react' +import { useAppStackNavigation } from 'src/app/navigation/types' +import { ScreenWithHeader } from 'src/components/layout/screens/ScreenWithHeader' +import { Flex, Text, TouchableArea } from 'ui/src' +import { Clock, Wrench } from 'ui/src/components/icons' +import { iconSizes } from 'ui/src/theme' +import { MobileScreens } from 'uniswap/src/types/screens/mobile' + +interface DebugScreenItem { + id: string + title: string + description: string + icon: JSX.Element + screen: MobileScreens.HashcashBenchmark | MobileScreens.SessionsDebug +} + +const ICON_SIZE = iconSizes.icon24 + +const DEBUG_SCREENS: DebugScreenItem[] = [ + { + id: 'hashcash', + title: 'Hashcash Benchmark', + description: 'Compare native vs JS hashcash performance', + icon: , + screen: MobileScreens.HashcashBenchmark, + }, + { + id: 'sessions', + title: 'Sessions Debug', + description: 'Test session initialization flow', + icon: , + screen: MobileScreens.SessionsDebug, + }, +] + +const ESTIMATED_ITEM_SIZE = 72 + +interface DebugScreenRowProps { + item: DebugScreenItem + onPress: (screen: DebugScreenItem['screen']) => void +} + +const DebugScreenRow = memo(function DebugScreenRow({ item, onPress }: DebugScreenRowProps): JSX.Element { + const handlePress = useCallback(() => { + onPress(item.screen) + }, [item.screen, onPress]) + + return ( + + + + {item.icon} + + + + {item.title} + + + {item.description} + + + + + ) +}) + +function keyExtractor(item: DebugScreenItem): string { + return item.id +} + +export function DebugScreensScreen(): JSX.Element { + const navigation = useAppStackNavigation() + + const handlePress = useCallback( + (screen: DebugScreenItem['screen']) => { + navigation.navigate(screen) + }, + [navigation], + ) + + const renderItem = useCallback( + ({ item }: { item: DebugScreenItem }) => , + [handlePress], + ) + + const data = useMemo(() => DEBUG_SCREENS, []) + + return ( + Debug Screens}> + + + ) +} + +function ItemSeparator(): JSX.Element { + return +} + +const contentContainerStyle = { paddingTop: 16 } diff --git a/apps/mobile/src/screens/DevScreen.tsx b/apps/mobile/src/screens/DevScreen.tsx new file mode 100644 index 00000000..84632890 --- /dev/null +++ b/apps/mobile/src/screens/DevScreen.tsx @@ -0,0 +1,194 @@ +import React, { useState } from 'react' +import { I18nManager, ScrollView } from 'react-native' +import { getUniqueIdSync } from 'react-native-device-info' +import { useDispatch, useSelector } from 'react-redux' +import { navigate } from 'src/app/navigation/rootNavigation' +import { BackButton } from 'src/components/buttons/BackButton' +import { Screen } from 'src/components/layout/Screen' +import { Flex, Switch, Text, TouchableArea } from 'ui/src' +import { CheckmarkCircle, CopyAlt } from 'ui/src/components/icons' +import { spacing } from 'ui/src/theme' +import { pushNotification } from 'uniswap/src/features/notifications/slice/slice' +import { AppNotificationType } from 'uniswap/src/features/notifications/slice/types' +import { + resetDismissedBridgedAssetWarnings, + resetDismissedCompatibleAddressWarnings, + resetDismissedWarnings, +} from 'uniswap/src/features/tokens/warnings/slice/slice' +import { useAppInsets } from 'uniswap/src/hooks/useAppInsets' +import { MobileScreens } from 'uniswap/src/types/screens/mobile' +import { setClipboard } from 'utilities/src/clipboard/clipboard' +import { logger } from 'utilities/src/logger/logger' +import { UniconSampleSheet } from 'wallet/src/components/DevelopmentOnly/UniconSampleSheet' +import { createOnboardingAccount } from 'wallet/src/features/onboarding/createOnboardingAccount' +import { createAccountsActions } from 'wallet/src/features/wallet/create/createAccountsSaga' +import { useActiveAccount } from 'wallet/src/features/wallet/hooks' +import { selectSortedSignerMnemonicAccounts } from 'wallet/src/features/wallet/selectors' +import { resetWallet } from 'wallet/src/features/wallet/slice' + +/** + * Dev screen accessible in the Settings screen. + * + * @deprecated Use the Experiments modal instead. + */ +export function DevScreen(): JSX.Element { + const insets = useAppInsets() + const dispatch = useDispatch() + const activeAccount = useActiveAccount() + const [rtlEnabled, setRTLEnabled] = useState(I18nManager.isRTL) + const sortedMnemonicAccounts = useSelector(selectSortedSignerMnemonicAccounts) + const deviceId = getUniqueIdSync() + + const onPressResetTokenWarnings = (): void => { + dispatch(resetDismissedWarnings()) + dispatch(resetDismissedCompatibleAddressWarnings()) + dispatch(resetDismissedBridgedAssetWarnings()) + } + + const onPressCreate = async (): Promise => { + dispatch( + createAccountsActions.trigger({ + accounts: [await createOnboardingAccount(sortedMnemonicAccounts)], + }), + ) + } + + const activateWormhole = (s: MobileScreens): void => { + switch (s) { + case MobileScreens.SettingsCloudBackupPasswordCreate: + navigate(s, { + address: '0x0000000000000000000000000000000000000000', + }) + break + + case MobileScreens.SettingsCloudBackupPasswordConfirm: + navigate(s, { + address: '0x0000000000000000000000000000000000000000', + password: 'password', + }) + break + + default: + navigate(s) + } + } + + const onPressShowError = (): void => { + const address = activeAccount?.address + if (!address) { + logger.debug('DevScreen', 'onPressShowError', 'Cannot show error if activeAccount is undefined') + return + } + + dispatch( + pushNotification({ + type: AppNotificationType.Error, + address, + errorMessage: 'A scary new error has happened. Be afraid!!', + }), + ) + } + + const onPressResetOnboarding = (): void => { + if (!activeAccount) { + return + } + + dispatch(resetWallet()) + } + + const [showUniconsModal, setShowUniconsModal] = useState(false) + + const onPressShowUniconsModal = (): void => { + setShowUniconsModal((prev) => !prev) + } + + const [showSuccessNotification, setShowSuccessNotification] = useState(false) + + const onPressCopy = async (): Promise => { + if (!deviceId) { + return + } + + await setClipboard(deviceId) + setShowSuccessNotification(true) + } + + return ( + + {showSuccessNotification && ( + + + Device id copied! + + + + )} + + + + + + {`Your address: ${activeAccount?.address || 'none'}`} + + + Your device id + + + + + {deviceId} + + + 🌀🌀Screen Stargate🌀🌀 + + + {Object.values(MobileScreens).map((s) => ( + activateWormhole(s)}> + {s} + + ))} + + + 🌀🌀🌀🌀🌀🌀🌀🌀🌀🌀🌀 + + + Create account + + + Reset token warnings + + + Show global error + + + Reset onboarding + + + Force RTL (requires restart to apply) + { + I18nManager.forceRTL(value) + setRTLEnabled(value) + }} + /> + + + Show Unicons sheet + + + + {showUniconsModal ? setShowUniconsModal(false)} /> : null} + + ) +} diff --git a/apps/mobile/src/screens/EducationScreen.tsx b/apps/mobile/src/screens/EducationScreen.tsx new file mode 100644 index 00000000..93d56496 --- /dev/null +++ b/apps/mobile/src/screens/EducationScreen.tsx @@ -0,0 +1,32 @@ +import React, { useMemo } from 'react' +import { AppStackScreenProp, EducationContentType } from 'src/app/navigation/types' +import { Carousel } from 'src/components/carousel/Carousel' +import { SeedPhraseEducationContent } from 'src/components/education/SeedPhrase' +import { Screen } from 'src/components/layout/Screen' +import { MobileScreens } from 'uniswap/src/types/screens/mobile' +import { isIOS } from 'utilities/src/platform' + +const educationContent = { + [EducationContentType.SeedPhrase]: SeedPhraseEducationContent, +} + +export function EducationScreen({ + route: { + params: { type, importType, entryPoint }, + }, +}: AppStackScreenProp): JSX.Element { + const content = useMemo( + () => + educationContent[type]({ + importType, + entryPoint, + }), + [entryPoint, importType, type], + ) + + return ( + + + + ) +} diff --git a/apps/mobile/src/screens/ExchangeTransferConnecting.tsx b/apps/mobile/src/screens/ExchangeTransferConnecting.tsx new file mode 100644 index 00000000..64eaa0ef --- /dev/null +++ b/apps/mobile/src/screens/ExchangeTransferConnecting.tsx @@ -0,0 +1,129 @@ +import { useCallback, useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useDispatch } from 'react-redux' +import { Screen } from 'src/components/layout/Screen' +import { useFiatOnRampContext } from 'src/features/fiatOnRamp/FiatOnRampContext' +import { Flex, UniversalImage, useIsDarkMode } from 'ui/src' +import { uniswapUrls } from 'uniswap/src/constants/urls' +import { UniverseChainId } from 'uniswap/src/features/chains/types' +import { ServiceProviderLogoStyles } from 'uniswap/src/features/fiatOnRamp/constants' +import { FiatOnRampConnectingView } from 'uniswap/src/features/fiatOnRamp/FiatOnRampConnectingView' +import { useFiatOnRampTransactionCreator } from 'uniswap/src/features/fiatOnRamp/hooks' +import { useFiatOnRampAggregatorTransferWidgetQuery } from 'uniswap/src/features/fiatOnRamp/hooks/useFiatOnRampQueries' +import { type FORServiceProvider } from 'uniswap/src/features/fiatOnRamp/types' +import { getOptionalServiceProviderLogo } from 'uniswap/src/features/fiatOnRamp/utils' +import { pushNotification } from 'uniswap/src/features/notifications/slice/slice' +import { AppNotificationType } from 'uniswap/src/features/notifications/slice/types' +import { FiatOnRampEventName } from 'uniswap/src/features/telemetry/constants' +import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { openUri } from 'uniswap/src/utils/linking' +import { ONE_SECOND_MS } from 'utilities/src/time/time' +import { useTimeout } from 'utilities/src/time/timing' +import { useActiveAccountAddressWithThrow } from 'wallet/src/features/wallet/hooks' + +// Design decision +const CONNECTING_TIMEOUT = 2 * ONE_SECOND_MS + +export function ExchangeTransferConnecting({ + serviceProvider, + onClose, +}: { + serviceProvider: FORServiceProvider + onClose: () => void +}): JSX.Element { + const { t } = useTranslation() + const dispatch = useDispatch() + const activeAccountAddress = useActiveAccountAddressWithThrow() + const [timeoutElapsed, setTimeoutElapsed] = useState(false) + const { isOffRamp } = useFiatOnRampContext() + + const { externalTransactionId, dispatchAddTransaction } = useFiatOnRampTransactionCreator({ + ownerAddress: activeAccountAddress, + chainId: UniverseChainId.Mainnet, + serviceProvider: serviceProvider.serviceProvider, + }) + + const onError = useCallback((): void => { + dispatch( + pushNotification({ + type: AppNotificationType.Error, + errorMessage: t('common.error.general'), + }), + ) + onClose() + }, [dispatch, onClose, t]) + + useTimeout(() => { + setTimeoutElapsed(true) + }, CONNECTING_TIMEOUT) + + const { + data: widgetData, + isLoading: widgetLoading, + error: widgetError, + } = useFiatOnRampAggregatorTransferWidgetQuery({ + serviceProvider: serviceProvider.serviceProvider, + walletAddress: activeAccountAddress, + externalSessionId: externalTransactionId, + redirectUrl: `${uniswapUrls.redirectUrlBase}?screen=transaction&fiatOnRamp=true&userAddress=${activeAccountAddress}`, + }) + + useEffect(() => { + if (widgetError) { + onError() + return + } + async function navigateToWidget(widgetUrl: string): Promise { + onClose() + sendAnalyticsEvent(FiatOnRampEventName.FiatOnRampTransferWidgetOpened, { + externalTransactionId, + serviceProvider: serviceProvider.serviceProvider, + }) + + await openUri({ uri: widgetUrl }).catch(onError) + dispatchAddTransaction({ isOffRamp: false }) + } + if (timeoutElapsed && !widgetLoading && widgetData) { + navigateToWidget(widgetData.widgetUrl).catch(() => undefined) + } + }, [ + dispatchAddTransaction, + onClose, + onError, + timeoutElapsed, + widgetData, + widgetLoading, + widgetError, + externalTransactionId, + serviceProvider, + ]) + + const isDarkMode = useIsDarkMode() + const logoUrl = getOptionalServiceProviderLogo(serviceProvider.logos, isDarkMode) ?? '' + + return ( + + + + + } + serviceProviderName={serviceProvider.name} + /> + + ) +} diff --git a/apps/mobile/src/screens/ExploreScreen.tsx b/apps/mobile/src/screens/ExploreScreen.tsx new file mode 100644 index 00000000..85848016 --- /dev/null +++ b/apps/mobile/src/screens/ExploreScreen.tsx @@ -0,0 +1,253 @@ +import type { RouteProp } from '@react-navigation/native' +import { useIsFocused, useNavigation, useRoute, useScrollToTop } from '@react-navigation/native' +import { SharedEventName } from '@uniswap/analytics-events' +import { FeatureFlags, useFeatureFlag } from '@universe/gating' +import { useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { type TextInput } from 'react-native' +import type { FlatList } from 'react-native-gesture-handler' +import { useAnimatedRef } from 'react-native-reanimated' +import type { Edge } from 'react-native-safe-area-context' +import { useDispatch } from 'react-redux' +import type { ExploreStackParamList } from 'src/app/navigation/types' +import { ExploreSections } from 'src/components/explore/ExploreSections/ExploreSections' +import { ExploreScreenSearchResultsList } from 'src/components/explore/search/ExploreScreenSearchResultsList' +import { Screen } from 'src/components/layout/Screen' +import { Flex, useLayoutAnimationOnChange } from 'ui/src' +import { useBottomSheetContext } from 'uniswap/src/components/modals/BottomSheetContext' +import { HandleBar } from 'uniswap/src/components/modals/HandleBar' +import { NetworkFilter, type NetworkFilterProps } from 'uniswap/src/components/network/NetworkFilter' +import { useEnabledChains } from 'uniswap/src/features/chains/hooks/useEnabledChains' +import type { UniverseChainId } from 'uniswap/src/features/chains/types' +import { useFilterCallbacks } from 'uniswap/src/features/search/SearchModal/hooks/useFilterCallbacks' +import { CancelBehaviorType, SearchTextInput } from 'uniswap/src/features/search/SearchTextInput' +import { MobileEventName, ModalName, SectionName } from 'uniswap/src/features/telemetry/constants' +import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { MobileScreens } from 'uniswap/src/types/screens/mobile' +import { isAndroid } from 'utilities/src/platform' +import { useEvent } from 'utilities/src/react/hooks' +import { setHasUsedExplore } from 'wallet/src/features/behaviorHistory/slice' + +// From design to avoid layout thrash as icons show and hide +const MIN_SEARCH_INPUT_HEIGHT = 52 + +const androidBottomInset: Edge[] = isAndroid ? ['bottom'] : [] +const edges: Edge[] = ['top', ...androidBottomInset] + +const networkFilterStyles: NetworkFilterProps['styles'] = { buttonPaddingY: '$none' } + +export function ExploreScreen(): JSX.Element { + const { chains } = useEnabledChains() + const isBottomTabsEnabled = useFeatureFlag(FeatureFlags.BottomTabs) + const navigation = useNavigation() + const route = useRoute>() + // oxlint-disable-next-line typescript/no-unnecessary-condition -- route.params can be null + const { chainId, orderByMetric, showFavorites } = route.params ?? {} + + const { isSheetReady } = useBottomSheetContext({ forceSafeReturn: isBottomTabsEnabled }) + + const dispatch = useDispatch() + const { t } = useTranslation() + + const textInputRef = useRef(null) + const listRef = useAnimatedRef>() + const isFocused = useIsFocused() + + const [isAtTop, setIsAtTop] = useState(true) + + // Use refs to avoid stale closures in the event listener + const isAtTopRef = useRef(isAtTop) + const isFocusedRef = useRef(isFocused) + // Track the previous route name to detect true double-tap behavior + const prevRouteNameRef = useRef(null) + + isAtTopRef.current = isAtTop + isFocusedRef.current = isFocused + + // Disable default scroll-to-top behavior when bottom tabs are enabled + // We'll implement custom behavior that focuses search input if already at top + useScrollToTop(isBottomTabsEnabled ? { current: null } : listRef) + + const [isSearchMode, setIsSearchMode] = useState(false) + + useLayoutAnimationOnChange(isSearchMode, { + duration: 125, + }) + + // Custom tab press handler for double-tap behavior (only when bottom tabs enabled) + // This effect only runs when isBottomTabsEnabled or navigation changes + useEffect((): (() => void) | undefined => { + if (!isBottomTabsEnabled) { + return undefined + } + + const unsubscribe = navigation.addListener('state', (e) => { + const currentRouteName = e.data.state.routeNames[e.data.state.index] as unknown as string | undefined + const isOnExploreScreen = currentRouteName === MobileScreens.Explore + + // Double-tap detection: Only trigger focus when user taps Explore tab while already on Explore + // This distinguishes between: + // - Initial navigation to Explore (prevRoute !== Explore) → No auto-focus + // - Tab double-tap (prevRoute === Explore && currentRoute === Explore) → Focus search + const isDoubleTap = prevRouteNameRef.current === MobileScreens.Explore && isOnExploreScreen + + // Update the previous route for next navigation event + prevRouteNameRef.current = currentRouteName ?? null + + // Only handle double-tap behavior when: + // 1. This is a true double-tap (was on Explore, tapped Explore again) + // 2. The screen is currently focused + if (!isDoubleTap || !isFocusedRef.current) { + return + } + + // Double-tap behavior: Focus search if at top, scroll to top otherwise + if (isAtTopRef.current) { + textInputRef.current?.focus() + } else { + // If not at top, scroll to top + listRef.current?.scrollToOffset({ offset: 0, animated: true }) + } + }) + + return unsubscribe + // oxlint-disable-next-line react/exhaustive-deps -- biome-parity: oxlint is stricter here + }, [isBottomTabsEnabled, navigation]) + + // TODO(WALL-5482): investigate list rendering performance/scrolling issue + const canRenderList = useRenderNextFrame(isSheetReady && !isSearchMode) + + const { onChangeChainFilter, onChangeText, searchFilter, chainFilter, parsedChainFilter, parsedSearchFilter } = + useFilterCallbacks(chainId ?? null, ModalName.Search) + + const onSearchChangeText = useEvent((newSearchFilter: string): void => { + onChangeText(newSearchFilter) + // Keep the state of the search input after changing theme + textInputRef.current?.setNativeProps({ text: newSearchFilter }) + }) + + const onSearchFocus = useEvent((): void => { + setIsSearchMode(true) + sendAnalyticsEvent(SharedEventName.PAGE_VIEWED, { + section: SectionName.ExploreSearch, + screen: MobileScreens.Explore, + }) + }) + + const onSearchCancel = useEvent((): void => { + setIsSearchMode(false) + }) + + const onPressChain = useEvent((newChainId: UniverseChainId | null): void => { + sendAnalyticsEvent(MobileEventName.ExploreSearchNetworkSelected, { + networkChainId: newChainId ?? 'all', + }) + + onChangeChainFilter(newChainId) + }) + + useEffect(() => { + // Moved location of this event only in bottom tab mode + if (isBottomTabsEnabled) { + dispatch(setHasUsedExplore(true)) + } + }, [dispatch, isBottomTabsEnabled]) + + return ( + + {!isBottomTabsEnabled && } + + + + + ) : null + } + hideIcon={isSearchMode} + minHeight={MIN_SEARCH_INPUT_HEIGHT} + placeholder={t('explore.search.placeholder')} + borderColor="$transparent" + borderWidth="$none" + onCancel={onSearchCancel} + onChangeText={onSearchChangeText} + onFocus={onSearchFocus} + /> + + {isSearchMode ? ( + + ) : isSheetReady && canRenderList ? ( + + ) : null} + + ) +} + +/** + * A hook that safely handles mounting/unmounting using requestAnimationFrame. + * This can help prevent common React Native issues with rendering and gestures + * by ensuring elements mount on the next frame. (not ideal, but better than nothing) + */ +const useRenderNextFrame = (condition: boolean): boolean => { + const [canRender, setCanRender] = useState(false) + const rafRef = useRef(undefined) + const mountedRef = useRef(true) + + const conditionRef = useRef(condition) + + // clean up on unmount to prevent memory leaks + useEffect(() => { + return () => { + mountedRef.current = false + if (rafRef.current) { + cancelAnimationFrame(rafRef.current) + } + } + }, []) + + // schedule render for next frame if we should mount + useEffect(() => { + conditionRef.current = condition + + if (condition) { + rafRef.current = requestAnimationFrame(() => { + // By the time this callback runs, 'condition' might have changed + // since RAF executes in the next frame, so we store the condition in a ref + if (mountedRef.current && conditionRef.current) { + setCanRender(true) + } + }) + } else { + setCanRender(false) + } + + return () => { + if (rafRef.current) { + cancelAnimationFrame(rafRef.current) + } + } + }, [condition]) + + return canRender +} diff --git a/apps/mobile/src/screens/ExternalProfileScreen.tsx b/apps/mobile/src/screens/ExternalProfileScreen.tsx new file mode 100644 index 00000000..fa0c8575 --- /dev/null +++ b/apps/mobile/src/screens/ExternalProfileScreen.tsx @@ -0,0 +1,172 @@ +import { NativeStackScreenProps } from '@react-navigation/native-stack' +import React, { useCallback, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { StyleProp, StyleSheet, ViewStyle } from 'react-native' +import { SceneRendererProps, TabBar } from 'react-native-tab-view' +import { AppStackParamList } from 'src/app/navigation/types' +import { ActivityContent } from 'src/components/activity/ActivityContent' +import { NftsTab } from 'src/components/home/NftsTab' +import { TokensTab } from 'src/components/home/TokensTab' +import { Screen } from 'src/components/layout/Screen' +import { TAB_STYLES, TabContentProps, TabLabel } from 'src/components/layout/TabHelpers' +import TraceTabView from 'src/components/Trace/TraceTabView' +import { ProfileHeader } from 'src/features/externalProfile/ProfileHeader' +import { ExploreModalAwareView } from 'src/screens/ModalAwareView' +import { Flex, useSporeColors } from 'ui/src' +import { spacing } from 'ui/src/theme' +import { DisplayNameType } from 'uniswap/src/features/accounts/types' +import { SectionName } from 'uniswap/src/features/telemetry/constants' +import Trace from 'uniswap/src/features/telemetry/Trace' +import { useAppInsets } from 'uniswap/src/hooks/useAppInsets' +import { MobileScreens } from 'uniswap/src/types/screens/mobile' +import { useDisplayName } from 'wallet/src/features/wallet/hooks' + +type Props = NativeStackScreenProps & { + renderedInModal?: boolean +} + +export function ExternalProfileScreen({ + route: { + params: { address }, + }, + renderedInModal = false, +}: Props): JSX.Element { + const { t } = useTranslation() + const colors = useSporeColors() + const [tabIndex, setIndex] = useState(0) + const insets = useAppInsets() + + const displayName = useDisplayName(address) + + const tabs = useMemo( + () => [ + { key: SectionName.ProfileTokensTab, title: t('home.tokens.title') }, + { key: SectionName.ProfileNftsTab, title: t('home.nfts.title') }, + { key: SectionName.ProfileActivityTab, title: t('home.activity.title') }, + ], + [t], + ) + + const containerStyle = useMemo>( + () => ({ + ...TAB_STYLES.tabListInner, + paddingBottom: insets.bottom + TAB_STYLES.tabListInner.paddingBottom, + }), + [insets.bottom], + ) + + const emptyComponentStyle = useMemo>( + () => ({ + paddingTop: spacing.spacing48, + paddingHorizontal: spacing.spacing36, + paddingBottom: insets.bottom, + }), + [insets.bottom], + ) + + const sharedProps = useMemo( + () => ({ + contentContainerStyle: containerStyle, + loadingContainerStyle: containerStyle, + emptyComponentStyle, + }), + [containerStyle, emptyComponentStyle], + ) + + const renderTab = useCallback( + ({ + route, + }: { + route: { + key: SectionName + title: string + } + }) => { + switch (route.key) { + case SectionName.ProfileActivityTab: + return ( + + ) + case SectionName.ProfileNftsTab: + return ( + + ) + case SectionName.ProfileTokensTab: + return ( + + ) + } + return null + }, + [address, sharedProps, renderedInModal], + ) + + const renderTabBar = useCallback( + (sceneProps: SceneRendererProps) => { + return ( + ( + + )} + style={[ + TAB_STYLES.tabBar, + { + backgroundColor: colors.surface1.get(), + borderBottomColor: colors.surface3.get(), + paddingLeft: spacing.spacing12, + }, + ]} + tabStyle={styles.tabStyle} + /> + ) + }, + [colors.surface1, colors.surface3, tabIndex, tabs], + ) + + const traceProperties = useMemo( + () => ({ + address, + walletName: displayName?.name, + displayNameType: displayName?.type ? DisplayNameType[displayName.type] : undefined, + }), + [address, displayName?.name, displayName?.type], + ) + + return ( + + + + + + + + + + + ) +} + +const styles = StyleSheet.create({ + tabStyle: { width: 'auto' }, +}) diff --git a/apps/mobile/src/screens/FiatOnRampConnecting.tsx b/apps/mobile/src/screens/FiatOnRampConnecting.tsx new file mode 100644 index 00000000..69a7d0c9 --- /dev/null +++ b/apps/mobile/src/screens/FiatOnRampConnecting.tsx @@ -0,0 +1,227 @@ +import { type NativeStackScreenProps } from '@react-navigation/native-stack' +import { skipToken } from '@tanstack/react-query' +import React, { useCallback, useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useDispatch } from 'react-redux' +import { type FiatOnRampStackParamList } from 'src/app/navigation/types' +import { Screen } from 'src/components/layout/Screen' +import { useFiatOnRampContext } from 'src/features/fiatOnRamp/FiatOnRampContext' +import { closeModal } from 'src/features/modals/modalSlice' +import { Flex, Text, UniversalImage, useIsDarkMode } from 'ui/src' +import { spacing } from 'ui/src/theme' +import { uniswapUrls } from 'uniswap/src/constants/urls' +import { UniverseChainId } from 'uniswap/src/features/chains/types' +import { useLocalFiatToUSDConverter } from 'uniswap/src/features/fiatCurrency/hooks' +import { ServiceProviderLogoStyles } from 'uniswap/src/features/fiatOnRamp/constants' +import { FiatOnRampConnectingView } from 'uniswap/src/features/fiatOnRamp/FiatOnRampConnectingView' +import { useFiatOnRampTransactionCreator } from 'uniswap/src/features/fiatOnRamp/hooks' +import { + useFiatOnRampAggregatorOffRampWidgetQuery, + useFiatOnRampAggregatorWidgetQuery, +} from 'uniswap/src/features/fiatOnRamp/hooks/useFiatOnRampQueries' +import { getOptionalServiceProviderLogo } from 'uniswap/src/features/fiatOnRamp/utils' +import { useLocalizationContext } from 'uniswap/src/features/language/LocalizationContext' +import { pushNotification } from 'uniswap/src/features/notifications/slice/slice' +import { AppNotificationType } from 'uniswap/src/features/notifications/slice/types' +import { FiatOffRampEventName, FiatOnRampEventName, ModalName } from 'uniswap/src/features/telemetry/constants' +import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { forceFetchFiatOnRampTransactions } from 'uniswap/src/features/transactions/slice' +import { type FiatOnRampScreens } from 'uniswap/src/types/screens/mobile' +import { openUri } from 'uniswap/src/utils/linking' +import { ONE_SECOND_MS } from 'utilities/src/time/time' +import { useTimeout } from 'utilities/src/time/timing' +import { useActiveAccountAddressWithThrow } from 'wallet/src/features/wallet/hooks' + +// Design decision +const CONNECTING_TIMEOUT = 2 * ONE_SECOND_MS + +type Props = NativeStackScreenProps + +export function FiatOnRampConnectingScreen({ navigation }: Props): JSX.Element | null { + const { t } = useTranslation() + const dispatch = useDispatch() + const { addFiatSymbolToNumber } = useLocalizationContext() + const [timeoutElapsed, setTimeoutElapsed] = useState(false) + const activeAccountAddress = useActiveAccountAddressWithThrow() + const fiatToUSDConverter = useLocalFiatToUSDConverter() + + const { + isOffRamp, + selectedQuote, + quotesSections, + countryCode, + countryState, + baseCurrencyInfo, + quoteCurrency, + fiatAmount, + tokenAmount, + externalTransactionIdSuffix, + } = useFiatOnRampContext() + const serviceProvider = selectedQuote?.serviceProviderDetails + + const { externalTransactionId, dispatchAddTransaction } = useFiatOnRampTransactionCreator({ + ownerAddress: activeAccountAddress, + chainId: quoteCurrency.currencyInfo?.currency.chainId ?? UniverseChainId.Mainnet, + serviceProvider: serviceProvider?.serviceProvider, + idSuffix: externalTransactionIdSuffix, + }) + + const onError = useCallback((): void => { + dispatch( + pushNotification({ + type: AppNotificationType.Error, + errorMessage: t('common.error.general'), + }), + ) + navigation.goBack() + }, [dispatch, navigation, t]) + + const { + data: widgetData, + isLoading: widgetLoading, + error: widgetError, + } = useFiatOnRampAggregatorWidgetQuery( + !isOffRamp && serviceProvider && quoteCurrency.meldCurrencyCode && baseCurrencyInfo && fiatAmount + ? { + serviceProvider: serviceProvider.serviceProvider, + countryCode, + destinationCurrencyCode: quoteCurrency.meldCurrencyCode, + sourceAmount: fiatAmount, + sourceCurrencyCode: baseCurrencyInfo.code, + walletAddress: activeAccountAddress, + externalSessionId: externalTransactionId, + redirectUrl: `${uniswapUrls.redirectUrlBase}?screen=transaction&fiatOnRamp=true&userAddress=${activeAccountAddress}`, + } + : skipToken, + ) + + const { + data: offRampWidgetData, + isLoading: offRampWidgetLoading, + error: offRampWidgetError, + } = useFiatOnRampAggregatorOffRampWidgetQuery( + isOffRamp && serviceProvider && quoteCurrency.meldCurrencyCode && baseCurrencyInfo && tokenAmount + ? { + serviceProvider: serviceProvider.serviceProvider, + countryCode, + baseCurrencyCode: quoteCurrency.meldCurrencyCode, + sourceAmount: tokenAmount, + quoteCurrencyCode: baseCurrencyInfo.code, + refundWalletAddress: activeAccountAddress, + externalCustomerId: activeAccountAddress, + externalSessionId: externalTransactionId, + redirectUrl: `${uniswapUrls.redirectUrlBase}?screen=transaction&fiatOffRamp=true&userAddress=${activeAccountAddress}&externalTransactionId=${externalTransactionId}`, + } + : skipToken, + ) + useTimeout(() => { + setTimeoutElapsed(true) + }, CONNECTING_TIMEOUT) + + useEffect(() => { + if (!baseCurrencyInfo || !serviceProvider || widgetError || offRampWidgetError) { + onError() + return + } + async function navigateToWidget(widgetUrl: string): Promise { + if (serviceProvider && quoteCurrency.meldCurrencyCode && baseCurrencyInfo && quotesSections?.[0]?.data[0]) { + sendAnalyticsEvent( + isOffRamp ? FiatOffRampEventName.FiatOffRampWidgetOpened : FiatOnRampEventName.FiatOnRampWidgetOpened, + { + externalTransactionId, + serviceProvider: serviceProvider.serviceProvider, + preselectedServiceProvider: quotesSections[0]?.data?.[0]?.serviceProviderDetails?.serviceProvider, + countryCode, + countryState, + fiatCurrency: baseCurrencyInfo.code.toLowerCase(), + cryptoCurrency: quoteCurrency.meldCurrencyCode.toLowerCase(), + chainId: quoteCurrency.currencyInfo?.currency.chainId, + currencyAmount: tokenAmount, + amountUSD: fiatToUSDConverter(fiatAmount ?? 0), + }, + ) + } + dispatchAddTransaction({ isOffRamp }) + dispatch(forceFetchFiatOnRampTransactions()) + await openUri({ uri: widgetUrl, throwOnError: true }) + .then(() => { + // Close the modal only after closing uri link + dispatch(closeModal({ name: ModalName.FiatOnRampAggregator })) + }) + .catch(onError) + } + + if (!isOffRamp && timeoutElapsed && !widgetLoading && widgetData) { + navigateToWidget(widgetData.widgetUrl).catch(() => undefined) + } + + if (isOffRamp && timeoutElapsed && !offRampWidgetLoading && offRampWidgetData) { + navigateToWidget(offRampWidgetData.widgetUrl).catch(() => undefined) + } + }, [ + timeoutElapsed, + widgetData, + offRampWidgetData, + widgetLoading, + offRampWidgetLoading, + widgetError, + offRampWidgetError, + onError, + dispatchAddTransaction, + baseCurrencyInfo, + serviceProvider, + dispatch, + externalTransactionId, + quoteCurrency.meldCurrencyCode, + quotesSections, + countryCode, + countryState, + isOffRamp, + fiatAmount, + fiatToUSDConverter, + tokenAmount, + quoteCurrency, + ]) + + const isDarkMode = useIsDarkMode() + const logoUrl = getOptionalServiceProviderLogo(serviceProvider?.logos, isDarkMode) + + return ( + + {baseCurrencyInfo && serviceProvider && ( + + + + + } + serviceProviderName={serviceProvider.name} + /> + + {t('fiatOnRamp.connection.terms', { serviceProvider: serviceProvider.name })} + + + )} + + ) +} diff --git a/apps/mobile/src/screens/FiatOnRampModalState.ts b/apps/mobile/src/screens/FiatOnRampModalState.ts new file mode 100644 index 00000000..b1256319 --- /dev/null +++ b/apps/mobile/src/screens/FiatOnRampModalState.ts @@ -0,0 +1,10 @@ +import { FiatOnRampCurrency } from 'uniswap/src/features/fiatOnRamp/types' + +export type FiatOnRampModalState = { + prefilledCurrency?: FiatOnRampCurrency + isOfframp?: boolean + providers?: string[] + prefilledAmount?: string + prefilledIsTokenInputMode?: boolean + currencyCode?: string +} diff --git a/apps/mobile/src/screens/FiatOnRampScreen.tsx b/apps/mobile/src/screens/FiatOnRampScreen.tsx new file mode 100644 index 00000000..ccecd42e --- /dev/null +++ b/apps/mobile/src/screens/FiatOnRampScreen.tsx @@ -0,0 +1,692 @@ +/* oxlint-disable max-lines */ +/* oxlint-disable complexity */ +import { type NativeStackScreenProps } from '@react-navigation/native-stack' +import { FeatureFlags, useFeatureFlag } from '@universe/gating' +import { Image } from 'expo-image' +import React, { type ComponentProps, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { type TextInputProps } from 'react-native' +import { FadeIn, FadeOut, FadeOutDown } from 'react-native-reanimated' +import { useDispatch } from 'react-redux' +import { type FiatOnRampStackParamList } from 'src/app/navigation/types' +import { FiatOnRampCtaButton } from 'src/components/fiatOnRamp/CtaButton' +import { Screen } from 'src/components/layout/Screen' +import { + FiatOnRampAmountSection, + type FiatOnRampAmountSectionRef, +} from 'src/features/fiatOnRamp/FiatOnRampAmountSection' +import { useFiatOnRampContext } from 'src/features/fiatOnRamp/FiatOnRampContext' +import { FiatOnRampCountryListModal } from 'src/features/fiatOnRamp/FiatOnRampCountryListModal' +import { FiatOnRampTokenSelectorModal } from 'src/features/fiatOnRamp/FiatOnRampTokenSelector' +import { OffRampPopover } from 'src/features/fiatOnRamp/OffRampPopover' +import { Flex, useIsDarkMode, useIsShortMobileDevice } from 'ui/src' +import { AnimatedFlex } from 'ui/src/components/layout/AnimatedFlex' +import { useBottomSheetContext } from 'uniswap/src/components/modals/BottomSheetContext' +import { HandleBar } from 'uniswap/src/components/modals/HandleBar' +import { PillMultiToggle } from 'uniswap/src/components/pill/PillMultiToggle' +import { MAX_FIAT_INPUT_DECIMALS } from 'uniswap/src/constants/transactions' +import { usePortfolioBalances } from 'uniswap/src/features/dataApi/balances/balances' +import { FiatOnRampCountryPicker } from 'uniswap/src/features/fiatOnRamp/FiatOnRampCountryPicker' +import { + useFiatOnRampQuotes, + useFiatOnRampSupportedTokens, + useIsFORLoading, + useMeldFiatCurrencySupportInfo, + useParseFiatOnRampError, +} from 'uniswap/src/features/fiatOnRamp/hooks' +import { useFiatOnRampAggregatorGetCountryQuery } from 'uniswap/src/features/fiatOnRamp/hooks/useFiatOnRampQueries' +import { TokenSelectorBalanceDisplay } from 'uniswap/src/features/fiatOnRamp/TokenSelectorBalanceDisplay' +import { + type FiatOnRampCurrency, + type FORCurrencyOrBalance, + type FORServiceProvider, + RampDirection, + RampToggle, +} from 'uniswap/src/features/fiatOnRamp/types' +import UnsupportedTokenModal from 'uniswap/src/features/fiatOnRamp/UnsupportedTokenModal' +import { + getOptionalServiceProviderLogo, + isSupportedFORCurrency, + organizeQuotesIntoSections, +} from 'uniswap/src/features/fiatOnRamp/utils' +import { pushNotification } from 'uniswap/src/features/notifications/slice/slice' +import { AppNotificationType } from 'uniswap/src/features/notifications/slice/types' +import { FiatOffRampEventName, FiatOnRampEventName } from 'uniswap/src/features/telemetry/constants' +import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { type FORAmountEnteredProperties } from 'uniswap/src/features/telemetry/types' +import { + DecimalPadCalculatedSpaceId, + DecimalPadCalculateSpace, + DecimalPadInput, + type DecimalPadInputRef, +} from 'uniswap/src/features/transactions/components/DecimalPadInput/DecimalPadInput' +import { useUSDTokenUpdater } from 'uniswap/src/features/transactions/hooks/useUSDTokenUpdater' +import { CurrencyField } from 'uniswap/src/types/currency' +import { FiatOnRampScreens } from 'uniswap/src/types/screens/mobile' +import { currencyIdToAddress } from 'uniswap/src/utils/currencyId' +import { truncateToMaxDecimals } from 'utilities/src/format/truncateToMaxDecimals' +import { logger } from 'utilities/src/logger/logger' +import { isIOS, isWebPlatform } from 'utilities/src/platform' +import { usePrevious } from 'utilities/src/react/hooks' +import { DEFAULT_DELAY, useDebounce } from 'utilities/src/time/timing' +import { useWalletNavigation } from 'wallet/src/contexts/WalletNavigationContext' +import { useActiveAccountWithThrow } from 'wallet/src/features/wallet/hooks' + +type Props = NativeStackScreenProps + +const ON_SELECTION_CHANGE_WAIT_TIME_MS = 500 +const MAX_TOKEN_DECIMALS = 9 // limited for design purposes +const MAX_INPUT_LENGTH = MAX_TOKEN_DECIMALS + 2 + +function preloadServiceProviderLogos(serviceProviders: (FORServiceProvider | undefined)[], isDarkMode: boolean): void { + const urls = serviceProviders + .filter((sp): sp is FORServiceProvider => sp !== undefined) + .map((sp) => getOptionalServiceProviderLogo(sp.logos, isDarkMode)) + .filter((uri): uri is string => !!uri) + + if (urls.length > 0) { + Image.prefetch(urls).catch((err) => { + logger.warn('FiatOnRampScreen', 'preloadServiceProviderLogos', 'Error preloading service provider logos', err) + }) + } +} + +const PREDEFINED_AMOUNTS_SUPPORTED_CURRENCIES = ['usd', 'eur', 'gbp', 'aud', 'cad', 'sgd'] +const US_STATES_WITH_RESTRICTIONS = 'US-NY' + +// TokenSelectorBalanceDisplay height: 85 + FiatOnRampCtaButton height: 30 + padding: 10 + spacing: 8 +const DECIMAL_PAD_EXTRA_ELEMENTS_HEIGHT = 133 + +export function FiatOnRampScreen({ navigation }: Props): JSX.Element { + const [showUnsupportedTokenModal, setShowUnsupportedTokenModal] = useState(false) + const [unsupportedCurrency, setUnsupportedCurrency] = useState() + const { t } = useTranslation() + const dispatch = useDispatch() + const isDarkMode = useIsDarkMode() + const { + selectedQuote, + setSelectedQuote, + setQuotesSections, + countryCode, + setCountryCode, + countryState, + setCountryState, + fiatAmount, + setFiatAmount, + tokenAmount, + setTokenAmount, + setBaseCurrencyInfo, + quoteCurrency, + defaultCurrency, + setQuoteCurrency, + setIsOffRamp, + isOffRamp, + isTokenInputMode, + setIsTokenInputMode, + externalTransactionIdSuffix, + providers, + currencyCode, + } = useFiatOnRampContext() + + const [showTokenSelector, setShowTokenSelector] = useState(false) + const inputRef = useRef(null) + const [selectingCountry, setSelectingCountry] = useState(false) + const [decimalPadReady, setDecimalPadReady] = useState(false) + const decimalPadRef = useRef(null) + const selectionRef = useRef(undefined) + const amountUpdatedTimeRef = useRef(0) + const [value, setValue] = useState('') + const valueRef = useRef('') + + // Initialize value state with prefilled amount if available + useEffect(() => { + const initialValue = isTokenInputMode ? (tokenAmount?.toString() ?? '') : (fiatAmount?.toString() ?? '') + setValue(initialValue) + valueRef.current = initialValue + }, [isTokenInputMode, tokenAmount, fiatAmount]) + + const isShortMobileDevice = useIsShortMobileDevice() + const { isSheetReady } = useBottomSheetContext() + + // passed to memo(...) component + const onDecimalPadReady = useCallback(() => setDecimalPadReady(true), []) + + // passed to memo(...) component + const onDecimalPadTriggerInputShake = useCallback(() => { + inputRef.current?.triggerShakeAnimation() + }, []) + + const pendingSelectionTimeoutRef = useRef>(undefined) + + useEffect(() => { + return () => clearTimeout(pendingSelectionTimeoutRef.current) + }, []) + + // passed to memo(...) component + const resetSelection = useCallback(({ start, end }: { start: number; end?: number }): void => { + selectionRef.current = { start, end } + if (!isWebPlatform && inputRef.current) { + // Cancel any pending native selection update to prevent stale cursor positions + // when typing fast (the previous timeout would set the cursor to an outdated position). + clearTimeout(pendingSelectionTimeoutRef.current) + pendingSelectionTimeoutRef.current = setTimeout(() => { + inputRef.current?.textInputRef.current?.setNativeProps({ selection: { start, end } }) + }, 0) + } + }, []) + + const { appFiatCurrencySupportedInMeld, meldSupportedFiatCurrency, supportedFiatCurrencies } = + useMeldFiatCurrencySupportInfo({ + countryCode, + skip: false, + rampDirection: isOffRamp ? RampDirection.OFF_RAMP : RampDirection.ON_RAMP, + }) + + const debouncedFiatAmount = useDebounce(fiatAmount, DEFAULT_DELAY * 2) + const debouncedTokenAmount = useDebounce(tokenAmount, DEFAULT_DELAY * 2) + + const activeAccount = useActiveAccountWithThrow() + const { data: balancesById } = usePortfolioBalances({ evmAddress: activeAccount.address }) + const portfolioBalance = quoteCurrency.currencyInfo && balancesById?.[quoteCurrency.currencyInfo.currencyId] + const tokenMaxDecimals = Math.min(quoteCurrency.currencyInfo?.currency.decimals ?? 0, MAX_TOKEN_DECIMALS) + + const exceedsBalanceError = useMemo(() => { + if (!isOffRamp) { + return false + } + + if (isTokenInputMode) { + if (tokenAmount && tokenAmount > (portfolioBalance?.quantity || 0)) { + return true + } + } else { + if (fiatAmount && fiatAmount > (portfolioBalance?.balanceUSD || 0)) { + return true + } + } + + return false + }, [fiatAmount, tokenAmount, isOffRamp, portfolioBalance, isTokenInputMode]) + + useUSDTokenUpdater({ + isFiatInput: !isTokenInputMode, + exactAmountToken: tokenAmount ? tokenAmount.toString() : '', + exactAmountFiat: fiatAmount ? fiatAmount.toString() : '', + onFiatAmountUpdated: (amount: string) => { + setFiatAmount(parseFloat(amount)) + }, + onTokenAmountUpdated: (amount: string) => { + const truncatedAmount = truncateToMaxDecimals({ + value: amount, + maxDecimals: tokenMaxDecimals, + }) + setTokenAmount(parseFloat(truncatedAmount)) + }, + currency: quoteCurrency.currencyInfo?.currency, + }) + + const { + error: quotesError, + loading: quotesLoading, + quotes, + } = useFiatOnRampQuotes({ + baseCurrencyAmount: isOffRamp ? debouncedTokenAmount : debouncedFiatAmount, + baseCurrencyCode: meldSupportedFiatCurrency.code, + quoteCurrencyCode: quoteCurrency.meldCurrencyCode, + countryCode, + countryState, + rampDirection: isOffRamp ? RampDirection.OFF_RAMP : RampDirection.ON_RAMP, + balanceError: exceedsBalanceError, + }) + + const debouncedAmountsMatch = isTokenInputMode + ? tokenAmount === debouncedTokenAmount + : fiatAmount === debouncedFiatAmount + + // always enforce the amount used in the request to backend service + const hasValidAmount = isOffRamp ? !!tokenAmount : !!fiatAmount + + const isFORLoading = useIsFORLoading({ + hasValidAmount, + debouncedAmountsMatch, + quotesLoading, + exceedsBalanceError, + }) + + const { data: ipCountryData } = useFiatOnRampAggregatorGetCountryQuery() + + useEffect(() => { + if (ipCountryData) { + setCountryCode(ipCountryData.countryCode) + setCountryState(ipCountryData.state) + } + }, [ipCountryData, setCountryCode, setCountryState]) + + // preload service provider logos for given quotes for the next screen + const isExpoImageEnabled = useFeatureFlag(FeatureFlags.ExpoImage) + useEffect(() => { + if (isExpoImageEnabled && quotes) { + preloadServiceProviderLogos( + quotes.map((q) => q.serviceProviderDetails), + isDarkMode, + ) + } + }, [isExpoImageEnabled, quotes, isDarkMode]) + + const filteredQuotes = useMemo(() => { + if (!quotes) { + return undefined + } + + // If specific providers are provided, only show quotes from the specified providers + if (providers.length > 0) { + const providerFilteredQuotes = quotes.filter((quote) => + providers.includes(quote.serviceProviderDetails?.serviceProvider.toUpperCase() ?? ''), + ) + return providerFilteredQuotes.length > 0 ? providerFilteredQuotes : quotes + } + + return quotes + }, [quotes, providers]) + + const prevQuotes = usePrevious(filteredQuotes) + useEffect(() => { + if (filteredQuotes && (!selectedQuote || prevQuotes !== filteredQuotes)) { + const organizedQuotes = organizeQuotesIntoSections(filteredQuotes) + if (organizedQuotes) { + setQuotesSections(organizedQuotes.sections) + setSelectedQuote(organizedQuotes.initialQuote) + } + } + }, [prevQuotes, filteredQuotes, selectedQuote, setQuotesSections, setSelectedQuote]) + + useEffect(() => { + if (!filteredQuotes && (quotesError || !fiatAmount)) { + setQuotesSections(undefined) + setSelectedQuote(undefined) + } + }, [quotesError, filteredQuotes, setQuotesSections, setSelectedQuote, fiatAmount]) + + const onSelectCountry: ComponentProps['onSelectCountry'] = (country): void => { + dispatch( + pushNotification({ + type: AppNotificationType.ChooseCountry, + countryName: country.displayName, + countryCode: country.countryCode, + }), + ) + setSelectingCountry(false) + // UI does not allow to set the state + setCountryState(undefined) + setCountryCode(country.countryCode) + } + + // oxlint-disable-next-line max-params + const onChangeValue = ( + newAmount: string, + source: FORAmountEnteredProperties['source'], + newIsTokenInputMode?: boolean, + ): void => { + amountUpdatedTimeRef.current = Date.now() + sendAnalyticsEvent( + isOffRamp ? FiatOffRampEventName.FiatOffRampAmountEntered : FiatOnRampEventName.FiatOnRampAmountEntered, + { + source, + amount: parseFloat(newAmount), + cryptoCurrency: quoteCurrency.currencyInfo?.currency.symbol, + fiatCurrency: meldSupportedFiatCurrency.code, + chainId: quoteCurrency.currencyInfo?.currency.chainId, + isTokenInputMode, + externalTransactionIdSuffix, + }, + ) + + const currentIsTokenInputMode = newIsTokenInputMode !== undefined ? newIsTokenInputMode : isTokenInputMode + + const maxDecimals = currentIsTokenInputMode ? tokenMaxDecimals : MAX_FIAT_INPUT_DECIMALS + + const truncatedValue = truncateToMaxDecimals({ + value: newAmount, + maxDecimals, + }) + + valueRef.current = truncatedValue + setValue(truncatedValue) + + const parsedValue = truncatedValue ? parseFloat(truncatedValue) : undefined + + if (currentIsTokenInputMode) { + setTokenAmount(parsedValue) + } else { + setFiatAmount(parsedValue) + } + + // if user did not use Decimal Pad to enter value + if (source !== 'textInput') { + resetSelection({ start: valueRef.current.length, end: valueRef.current.length }) + } + decimalPadRef.current?.updateDisabledKeys() + + if (newIsTokenInputMode !== undefined && newIsTokenInputMode !== isTokenInputMode) { + setIsTokenInputMode(newIsTokenInputMode) + } + } + + const onToggleIsTokenInputMode = useCallback(() => { + const { sourceAmount, destinationAmount } = selectedQuote ?? {} + + // Use the exact amounts from the backend so that the newly populated amount is exactly what the quote returns + const fiatAmountFromQuote = isOffRamp ? destinationAmount : sourceAmount + const tokenAmountFromQuote = isOffRamp ? sourceAmount : destinationAmount + const newAmount = (isTokenInputMode ? fiatAmountFromQuote : tokenAmountFromQuote)?.toString() ?? '' + + const truncatedNewAmount = truncateToMaxDecimals({ + value: newAmount, + maxDecimals: isTokenInputMode ? MAX_FIAT_INPUT_DECIMALS : MAX_TOKEN_DECIMALS, + }) + + // update values + valueRef.current = truncatedNewAmount + setValue(truncatedNewAmount) + + // update cursor position and decimal pad disabled keys + resetSelection({ start: valueRef.current.length, end: valueRef.current.length }) + decimalPadRef.current?.updateDisabledKeys() + + // toggle input mode + setIsTokenInputMode((prev) => !prev) + }, [isOffRamp, isTokenInputMode, resetSelection, selectedQuote, setIsTokenInputMode]) + + const onContinue = (): void => { + if (filteredQuotes && quoteCurrency.currencyInfo?.currency) { + setBaseCurrencyInfo(meldSupportedFiatCurrency) + navigation.navigate(FiatOnRampScreens.ServiceProviders) + } + } + + const { + list: supportedTokensList, + loading: supportedTokensLoading, + error: supportedTokensError, + refetch: supportedTokensRefetch, + } = useFiatOnRampSupportedTokens({ + sourceCurrencyCode: meldSupportedFiatCurrency.code, + countryCode, + rampDirection: isOffRamp ? RampDirection.OFF_RAMP : RampDirection.ON_RAMP, + }) + + useEffect(() => { + if (!currencyCode || !supportedTokensList) { + return + } + + const matchingCurrency = supportedTokensList.find( + (token) => token.meldCurrencyCode?.toLowerCase() === currencyCode.toLowerCase(), + ) + + matchingCurrency && setQuoteCurrency(matchingCurrency) + }, [currencyCode, supportedTokensList, setQuoteCurrency]) + + const onSelectCurrency = (currency: FORCurrencyOrBalance): void => { + if (isTokenInputMode) { + resetAmount() + } else { + setSelectedQuote(undefined) + // This is done for formatting reasons. The existing value may change if max decimals of new currency is different + onChangeValue(value, 'changeAsset') + } + + setShowTokenSelector(false) + if (isSupportedFORCurrency(currency)) { + setQuoteCurrency(currency) + } else { + setUnsupportedCurrency(currency) + setShowUnsupportedTokenModal(true) + } + + if (currency.currencyInfo?.currency.symbol) { + sendAnalyticsEvent( + isOffRamp ? FiatOffRampEventName.FiatOffRampTokenSelected : FiatOnRampEventName.FiatOnRampTokenSelected, + { + token: currency.currencyInfo.currency.symbol.toLowerCase(), + isUnsupported: !isSupportedFORCurrency(currency), + chainId: currency.currencyInfo.currency.chainId, + externalTransactionIdSuffix, + }, + ) + } + } + + // We only support predefined amounts for certain currencies. + const predefinedAmountsSupported = PREDEFINED_AMOUNTS_SUPPORTED_CURRENCIES.includes( + meldSupportedFiatCurrency.code.toLowerCase(), + ) + + const notAvailableInThisRegion = + supportedFiatCurrencies?.length === 0 || + (!supportedTokensLoading && supportedTokensList?.length === 0) || + (US_STATES_WITH_RESTRICTIONS.includes(countryState || '') && filteredQuotes?.length === 0) + + const { errorText } = useParseFiatOnRampError({ + error: !notAvailableInThisRegion && quotesError, + currencyCode: meldSupportedFiatCurrency.code, + tokenCode: quoteCurrency.currencyInfo?.currency.symbol, + balanceError: exceedsBalanceError, + noQuotesReturned: filteredQuotes?.length === 0, + }) + + const onSelectionChange = useCallback((start: number, end: number) => { + if (Date.now() - amountUpdatedTimeRef.current < ON_SELECTION_CHANGE_WAIT_TIME_MS) { + // We only want to trigger this callback when the user is manually moving the cursor, + // but this function is also triggered when the input value is updated, + // which causes issues on Android. + // We use `amountUpdatedTimeRef` to check if the input value was updated recently, + // and if so, we assume that the user is actually typing and not manually moving the cursor. + return + } + selectionRef.current = { start, end } + decimalPadRef.current?.updateDisabledKeys() + }, []) + + const { navigateToSwapFlow } = useWalletNavigation() + const onAcceptUnsupportedTokenSwap = useCallback(() => { + setShowUnsupportedTokenModal(false) + + if (unsupportedCurrency?.currencyInfo) { + sendAnalyticsEvent(FiatOffRampEventName.FiatOffRampUnsupportedTokenSwap, { + token: unsupportedCurrency.currencyInfo.currency.symbol, + }) + + navigateToSwapFlow({ + currencyField: CurrencyField.INPUT, + currencyAddress: currencyIdToAddress(unsupportedCurrency.currencyInfo.currencyId), + currencyChainId: unsupportedCurrency.currencyInfo.currency.chainId, + }) + } + }, [navigateToSwapFlow, unsupportedCurrency]) + + const resetAmount = useCallback(() => { + setValue('') + setFiatAmount(undefined) + setTokenAmount(undefined) + valueRef.current = '' + resetSelection({ start: 0 }) + setSelectedQuote(undefined) + }, [setFiatAmount, setTokenAmount, resetSelection, setSelectedQuote]) + + const onPillToggle = (option: string | number): void => { + setIsOffRamp(option === RampToggle.SELL) + resetAmount() + setQuoteCurrency(defaultCurrency) + + sendAnalyticsEvent(FiatOffRampEventName.FORBuySellToggled, { + value: option === RampToggle.SELL ? RampToggle.SELL : RampToggle.BUY, + }) + } + + const buttonDisabled = + notAvailableInThisRegion || + isFORLoading || + !!quotesError || + !selectedQuote?.destinationAmount || + exceedsBalanceError + + return ( + + + + {isSheetReady && ( + + + + } + /> + + { + setSelectingCountry(true) + }} + /> + + + { + onChangeValue(val, 'chip', isOffRamp) + }} + onEnterAmount={(amount: string, newIsTokenInputMode?: boolean): void => { + if (amount.length > MAX_INPUT_LENGTH) { + onDecimalPadTriggerInputShake() + return + } + onChangeValue(amount, 'textInput', newIsTokenInputMode) + }} + onToggleIsTokenInputMode={onToggleIsTokenInputMode} + onSelectionChange={onSelectionChange} + onTokenSelectorPress={(): void => { + setShowTokenSelector(true) + }} + /> + + + + + {quoteCurrency.currencyInfo && ( + { + setShowTokenSelector(true) + }} + /> + )} + + { + if (newValue.length > MAX_INPUT_LENGTH) { + onDecimalPadTriggerInputShake() + return + } + onChangeValue(newValue, 'textInput') + }} + valueRef={valueRef} + onReady={onDecimalPadReady} + onTriggerInputShakeAnimation={onDecimalPadTriggerInputShake} + /> + + + + + )} + + {selectingCountry && countryCode && ( + { + setSelectingCountry(false) + }} + onSelectCountry={onSelectCountry} + /> + )} + {showTokenSelector && countryCode && ( + setShowTokenSelector(false)} + onRetry={supportedTokensRefetch} + onSelectCurrency={onSelectCurrency} + /> + )} + {showUnsupportedTokenModal && ( + { + setShowUnsupportedTokenModal(false) + setShowTokenSelector(true) + sendAnalyticsEvent(FiatOffRampEventName.FiatOffRampUnsupportedTokenBack, { + token: unsupportedCurrency?.currencyInfo?.currency.symbol, + }) + }} + onClose={(): void => { + setShowUnsupportedTokenModal(false) + }} + /> + )} + + ) +} diff --git a/apps/mobile/src/screens/FiatOnRampServiceProviders.tsx b/apps/mobile/src/screens/FiatOnRampServiceProviders.tsx new file mode 100644 index 00000000..6f3fc570 --- /dev/null +++ b/apps/mobile/src/screens/FiatOnRampServiceProviders.tsx @@ -0,0 +1,171 @@ +import { BottomSheetSectionList } from '@gorhom/bottom-sheet' +import { useFocusEffect } from '@react-navigation/native' +import { NativeStackScreenProps } from '@react-navigation/native-stack' +import React, { useCallback, useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { ListRenderItemInfo } from 'react-native' +import { FadeIn, FadeOut } from 'react-native-reanimated' +import { FiatOnRampStackParamList } from 'src/app/navigation/types' +import { BackButton } from 'src/components/buttons/BackButton' +import { Screen } from 'src/components/layout/Screen' +import { useFiatOnRampContext } from 'src/features/fiatOnRamp/FiatOnRampContext' +import { Flex, Inset, Text } from 'ui/src' +import { AnimatedFlex } from 'ui/src/components/layout/AnimatedFlex' +import { iconSizes } from 'ui/src/theme' +import { CurrencyLogo } from 'uniswap/src/components/CurrencyLogo/CurrencyLogo' +import { HandleBar } from 'uniswap/src/components/modals/HandleBar' +import { EdgeFade } from 'uniswap/src/features/fiatOnRamp/EdgeFade/EdgeFade' +import { FORQuoteItem } from 'uniswap/src/features/fiatOnRamp/FORQuoteItem' +import { PaymentMethodFilter } from 'uniswap/src/features/fiatOnRamp/PaymentMethodFilter/PaymentMethodFilter' +import { FORQuote } from 'uniswap/src/features/fiatOnRamp/types' +import { filterQuotesByPaymentMethod } from 'uniswap/src/features/fiatOnRamp/utils' +import { useLocalizationContext } from 'uniswap/src/features/language/LocalizationContext' +import { FiatOffRampEventName, FiatOnRampEventName } from 'uniswap/src/features/telemetry/constants' +import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { FiatOnRampScreens } from 'uniswap/src/types/screens/mobile' +import { NumberType } from 'utilities/src/format/types' + +type Props = NativeStackScreenProps + +const key = (item: FORQuote): string => item.serviceProviderDetails?.serviceProvider ?? '' + +function Footer(): JSX.Element { + const { t } = useTranslation() + return ( + <> + + {t('fiatOnRamp.quote.advice')} + + + + ) +} + +export function FiatOnRampServiceProvidersScreen({ navigation }: Props): JSX.Element { + const { t } = useTranslation() + const { + isOffRamp, + setSelectedQuote, + quotesSections, + baseCurrencyInfo, + paymentMethod, + setPaymentMethod, + fiatAmount, + quoteCurrency, + countryCode, + countryState, + externalTransactionIdSuffix, + } = useFiatOnRampContext() + const { convertFiatAmountFormatted } = useLocalizationContext() + + // Reset payment method when screen gains focus + useFocusEffect( + useCallback(() => { + setPaymentMethod(undefined) + }, [setPaymentMethod]), + ) + + const filteredQuotes = useMemo(() => { + if (!quotesSections) { + return undefined + } + return filterQuotesByPaymentMethod( + quotesSections.flatMap((section) => section.data), + paymentMethod, + ) + }, [quotesSections, paymentMethod]) + + const allQuotes = useMemo(() => { + return quotesSections?.flatMap((section) => section.data) + }, [quotesSections]) + + const renderItem = ({ item }: ListRenderItemInfo): JSX.Element => { + const isHidden = !filteredQuotes?.includes(item) + const onPress = (): void => { + setSelectedQuote(item) + if (baseCurrencyInfo && quoteCurrency.currencyInfo) { + sendAnalyticsEvent( + isOffRamp ? FiatOffRampEventName.FiatOffRampWidgetOpened : FiatOnRampEventName.FiatOnRampWidgetOpened, + { + countryCode, + countryState, + cryptoCurrency: quoteCurrency.currencyInfo.currencyId, + externalTransactionId: externalTransactionIdSuffix, + fiatCurrency: baseCurrencyInfo.symbol, + serviceProvider: item.serviceProviderDetails?.serviceProvider ?? '', + paymentMethodFilter: paymentMethod, + }, + ) + } + navigation.navigate(FiatOnRampScreens.Connecting) + } + return ( + + {baseCurrencyInfo && ( + + ) + } + + return ( + + + + + + + + {isOffRamp ? t('fiatOffRamp.checkout.title') : t('fiatOnRamp.checkout.title')} + + + + {convertFiatAmountFormatted(fiatAmount, NumberType.FiatTokenPrice)} + + {quoteCurrency.currencyInfo && ( + + )} + + + + {!!quotesSections?.length && quotesSections.length > 1 && ( + + + + + + )} + + + } + ListFooterComponent={} + focusHook={useFocusEffect} + keyExtractor={key} + keyboardDismissMode="on-drag" + keyboardShouldPersistTaps="always" + renderItem={renderItem} + sections={quotesSections ?? []} + showsVerticalScrollIndicator={false} + stickySectionHeadersEnabled={false} + windowSize={5} + /> + + +