From c2e03b737fb610a21c7d1febf3a2dcccfaffa52a Mon Sep 17 00:00:00 2001 From: AU_gdev_19 <64915515+Dargon789@users.noreply.github.com> Date: Tue, 23 Sep 2025 15:48:39 +0700 Subject: [PATCH 01/34] Create config.yml (#56) Signed-off-by: AU_gdev_19 <64915515+Dargon789@users.noreply.github.com> --- .circleci/config.yml | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 .circleci/config.yml diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 00000000000..d5d401c5189 --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,31 @@ +# Use the latest 2.1 version of CircleCI pipeline process engine. +# See: https://circleci.com/docs/configuration-reference +version: 2.1 + +# Define a job to be invoked later in a workflow. +# See: https://circleci.com/docs/jobs-steps/#jobs-overview & https://circleci.com/docs/configuration-reference/#jobs +jobs: + say-hello: + # Specify the execution environment. You can specify an image from Docker Hub or use one of our convenience images from CircleCI's Developer Hub. + # See: https://circleci.com/docs/executor-intro/ & https://circleci.com/docs/configuration-reference/#executor-job + docker: + # Specify the version you desire here + # See: https://circleci.com/developer/images/image/cimg/base + - image: cimg/base:current + + # Add steps to the job + # See: https://circleci.com/docs/jobs-steps/#steps-overview & https://circleci.com/docs/configuration-reference/#steps + steps: + # Checkout the code as the first step. + - checkout + - run: + name: "Say hello" + command: "echo Hello, World!" + +# Orchestrate jobs using workflows +# See: https://circleci.com/docs/workflows/ & https://circleci.com/docs/configuration-reference/#workflows +workflows: + say-hello-workflow: # This is the name of the workflow, feel free to change it to better match your workflow. + # Inside the workflow, you define the jobs you want to run. + jobs: + - say-hello From b7ece147ee4072d81d137dcb182f5b21268a7f46 Mon Sep 17 00:00:00 2001 From: AU_gdev_19 <64915515+Dargon789@users.noreply.github.com> Date: Sun, 28 Sep 2025 14:32:25 +0700 Subject: [PATCH 02/34] fix: apps/mobile/Gemfile & apps/mobile/Gemfile.lock to reduce vulnerabilities (#60) The following vulnerabilities are fixed with an upgrade: - https://snyk.io/vuln/SNYK-RUBY-REXML-12878608 - https://snyk.io/vuln/SNYK-RUBY-WEBRICK-10500756 - https://snyk.io/vuln/SNYK-RUBY-WEBRICK-8068535 Co-authored-by: snyk-bot --- apps/mobile/Gemfile | 4 +- apps/mobile/Gemfile.lock | 184 ++++++++++++++++++++------------------- 2 files changed, 98 insertions(+), 90 deletions(-) diff --git a/apps/mobile/Gemfile b/apps/mobile/Gemfile index a07e6ca69f3..a9e494ea9a1 100644 --- a/apps/mobile/Gemfile +++ b/apps/mobile/Gemfile @@ -1,8 +1,8 @@ source "https://rubygems.org" -gem 'fastlane', '2.214.0' +gem 'fastlane', '2.215.0' # Exclude problematic versions of cocoapods and activesupport that causes build failures. -gem 'cocoapods', '1.14.3' +gem 'cocoapods', '1.15.0' gem 'activesupport', '7.1.2' gem 'xcodeproj', '1.27.0' gem 'concurrent-ruby', '1.3.4' diff --git a/apps/mobile/Gemfile.lock b/apps/mobile/Gemfile.lock index b5891798a1a..c99f3366066 100644 --- a/apps/mobile/Gemfile.lock +++ b/apps/mobile/Gemfile.lock @@ -1,7 +1,9 @@ GEM remote: https://rubygems.org/ specs: - CFPropertyList (3.0.6) + CFPropertyList (3.0.7) + base64 + nkf rexml activesupport (7.1.2) base64 @@ -13,37 +15,40 @@ GEM minitest (>= 5.1) mutex_m tzinfo (~> 2.0) - addressable (2.8.6) - public_suffix (>= 2.0.2, < 6.0) + addressable (2.8.7) + public_suffix (>= 2.0.2, < 7.0) algoliasearch (1.27.5) httpclient (~> 2.8, >= 2.8.3) json (>= 1.5.1) - artifactory (3.0.15) + artifactory (3.0.17) atomos (0.1.3) - aws-eventstream (1.3.0) - aws-partitions (1.877.0) - aws-sdk-core (3.190.1) + aws-eventstream (1.4.0) + aws-partitions (1.1166.0) + aws-sdk-core (3.233.0) aws-eventstream (~> 1, >= 1.3.0) - aws-partitions (~> 1, >= 1.651.0) - aws-sigv4 (~> 1.8) + aws-partitions (~> 1, >= 1.992.0) + aws-sigv4 (~> 1.9) + base64 + bigdecimal jmespath (~> 1, >= 1.6.1) - aws-sdk-kms (1.75.0) - aws-sdk-core (~> 3, >= 3.188.0) - aws-sigv4 (~> 1.1) - aws-sdk-s3 (1.142.0) - aws-sdk-core (~> 3, >= 3.189.0) + logger + aws-sdk-kms (1.113.0) + aws-sdk-core (~> 3, >= 3.231.0) + aws-sigv4 (~> 1.5) + aws-sdk-s3 (1.199.1) + aws-sdk-core (~> 3, >= 3.231.0) aws-sdk-kms (~> 1) - aws-sigv4 (~> 1.8) - aws-sigv4 (1.8.0) + aws-sigv4 (~> 1.5) + aws-sigv4 (1.12.1) aws-eventstream (~> 1, >= 1.0.2) babosa (1.0.4) - base64 (0.2.0) - bigdecimal (3.1.9) + base64 (0.3.0) + bigdecimal (3.2.3) claide (1.1.0) - cocoapods (1.14.3) + cocoapods (1.15.0) addressable (~> 2.8) claide (>= 1.0.2, < 2.0) - cocoapods-core (= 1.14.3) + cocoapods-core (= 1.15.0) cocoapods-deintegrate (>= 1.0.3, < 2.0) cocoapods-downloader (>= 2.1, < 3.0) cocoapods-plugins (>= 1.0.0, < 2.0) @@ -58,7 +63,7 @@ GEM nap (~> 1.0) ruby-macho (>= 2.3.0, < 3.0) xcodeproj (>= 1.23.0, < 2.0) - cocoapods-core (1.14.3) + cocoapods-core (1.15.0) activesupport (>= 5.0, < 8) addressable (~> 2.8) algoliasearch (~> 1.0) @@ -82,19 +87,19 @@ GEM commander (4.6.0) highline (~> 2.0.0) concurrent-ruby (1.3.4) - connection_pool (2.5.0) + connection_pool (2.5.4) declarative (0.0.20) - digest-crc (0.6.5) + digest-crc (0.7.0) rake (>= 12.0.0, < 14.0.0) - domain_name (0.6.20231109) + domain_name (0.6.20240107) dotenv (2.8.1) - drb (2.2.1) + drb (2.2.3) emoji_regex (3.2.3) escape (0.0.4) - ethon (0.16.0) + ethon (0.15.0) ffi (>= 1.15.0) - excon (0.109.0) - faraday (1.10.3) + excon (0.112.0) + faraday (1.10.4) faraday-em_http (~> 1.0) faraday-em_synchrony (~> 1.0) faraday-excon (~> 1.1) @@ -110,20 +115,20 @@ GEM faraday (>= 0.8.0) http-cookie (~> 1.0.0) faraday-em_http (1.0.0) - faraday-em_synchrony (1.0.0) + faraday-em_synchrony (1.0.1) faraday-excon (1.1.0) faraday-httpclient (1.0.1) - faraday-multipart (1.0.4) - multipart-post (~> 2) - faraday-net_http (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.0) + faraday_middleware (1.2.1) faraday (~> 1.0) - fastimage (2.3.0) - fastlane (2.214.0) + fastimage (2.4.0) + fastlane (2.215.0) CFPropertyList (>= 2.3, < 4.0.0) addressable (>= 2.8, < 3.0.0) artifactory (~> 3.0) @@ -144,6 +149,7 @@ GEM google-apis-playcustomapp_v1 (~> 0.1) 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) @@ -155,116 +161,120 @@ GEM security (= 0.1.3) simctl (~> 1.6.3) terminal-notifier (>= 2.0.0, < 3.0.0) - terminal-table (>= 1.4.5, < 2.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.3.0) xcpretty-travis-formatter (>= 0.0.3) - fastlane-plugin-get_version_name (0.2.2) - fastlane-plugin-versioning_android (0.1.1) - ffi (1.17.1) + ffi (1.17.2) 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.2) + google-apis-androidpublisher_v3 (0.87.0) + google-apis-core (>= 0.15.0, < 2.a) + google-apis-core (0.18.0) addressable (~> 2.5, >= 2.5.1) - googleauth (>= 0.16.2, < 2.a) - httpclient (>= 2.8.1, < 3.a) + googleauth (~> 1.9) + httpclient (>= 2.8.3, < 3.a) mini_mime (~> 1.0) + mutex_m representable (~> 3.0) retriable (>= 2.0, < 4.a) - rexml - webrick - 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.29.0) - google-apis-core (>= 0.11.0, < 2.a) - google-cloud-core (1.6.1) + google-apis-iamcredentials_v1 (0.24.0) + google-apis-core (>= 0.15.0, < 2.a) + google-apis-playcustomapp_v1 (0.17.0) + google-apis-core (>= 0.15.0, < 2.a) + google-apis-storage_v1 (0.56.0) + google-apis-core (>= 0.15.0, < 2.a) + google-cloud-core (1.8.0) google-cloud-env (>= 1.0, < 3.a) google-cloud-errors (~> 1.0) - google-cloud-env (2.1.0) + google-cloud-env (2.3.1) + base64 (~> 0.2) faraday (>= 1.0, < 3.a) - google-cloud-errors (1.3.1) - google-cloud-storage (1.45.0) + google-cloud-errors (1.5.0) + google-cloud-storage (1.57.0) addressable (~> 2.8) digest-crc (~> 0.4) - google-apis-iamcredentials_v1 (~> 0.1) - google-apis-storage_v1 (~> 0.29.0) + google-apis-core (>= 0.18, < 2) + google-apis-iamcredentials_v1 (~> 0.18) + google-apis-storage_v1 (>= 0.42) google-cloud-core (~> 1.6) - googleauth (>= 0.16.2, < 2.a) + googleauth (~> 1.9) mini_mime (~> 1.0) - googleauth (1.9.1) + google-logging-utils (0.2.0) + googleauth (1.15.0) faraday (>= 1.0, < 3.a) - google-cloud-env (~> 2.1) - jwt (>= 1.4, < 3.0) + google-cloud-env (~> 2.2) + google-logging-utils (~> 0.1) + jwt (>= 1.4, < 4.0) multi_json (~> 1.11) os (>= 0.9, < 2.0) signet (>= 0.16, < 2.a) highline (2.0.3) - http-cookie (1.0.5) + http-cookie (1.0.8) domain_name (~> 0.5) - httpclient (2.8.3) + httpclient (2.9.0) + mutex_m i18n (1.14.7) concurrent-ruby (~> 1.0) jmespath (1.6.2) - json (2.7.1) - jwt (2.7.1) - mini_magick (4.12.0) + json (2.15.0) + jwt (2.10.2) + base64 + logger (1.7.0) + mini_magick (4.13.2) mini_mime (1.1.5) - minitest (5.25.4) + minitest (5.25.5) molinillo (0.8.0) - multi_json (1.15.0) - multipart-post (2.3.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.2.1) + naturally (2.3.0) netrc (0.11.0) + nkf (0.2.0) optparse (0.1.1) os (1.1.4) - plist (3.7.1) + plist (3.7.2) public_suffix (4.0.7) - rake (13.1.0) + 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) - rexml (3.4.1) + rexml (3.4.4) rouge (2.0.7) ruby-macho (2.5.1) ruby2_keywords (0.0.5) - rubyzip (2.3.2) + rubyzip (2.4.1) security (0.1.3) - signet (0.18.0) + signet (0.21.0) addressable (~> 2.8) faraday (>= 0.17.5, < 3.a) - jwt (>= 1.5, < 3.0) + jwt (>= 1.5, < 4.0) multi_json (~> 1.10) simctl (1.6.10) CFPropertyList naturally terminal-notifier (2.0.0) - terminal-table (1.8.0) - unicode-display_width (~> 1.1, >= 1.1.1) + 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.4.1) - ethon (>= 0.9.0) + 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 (1.8.0) - webrick (1.8.1) + unicode-display_width (2.6.0) word_wrap (1.0.0) xcodeproj (1.27.0) CFPropertyList (>= 2.3.3, < 4.0) @@ -283,12 +293,10 @@ PLATFORMS DEPENDENCIES activesupport (= 7.1.2) - cocoapods (= 1.14.3) + cocoapods (= 1.15.0) concurrent-ruby (= 1.3.4) - fastlane (= 2.214.0) - fastlane-plugin-get_version_name - fastlane-plugin-versioning_android + fastlane (= 2.215.0) xcodeproj (= 1.27.0) BUNDLED WITH - 2.4.10 + 2.3.26 From 58d9f6c20cf4711bd922c35668c9e4d3c7f7b513 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 28 Sep 2025 19:39:15 +0700 Subject: [PATCH 03/34] build(deps): bump the npm_and_yarn group across 6 directories with 5 updates (#61) Bumps the npm_and_yarn group with 2 updates in the /apps/extension directory: [webpack](https://github.com/webpack/webpack) and [webpack-dev-server](https://github.com/webpack/webpack-dev-server). Bumps the npm_and_yarn group with 1 update in the /apps/mobile directory: [react-native-mmkv](https://github.com/mrousavy/react-native-mmkv). Bumps the npm_and_yarn group with 3 updates in the /apps/web directory: [webpack](https://github.com/webpack/webpack), [graphql](https://github.com/graphql/graphql-js) and [hono](https://github.com/honojs/hono). Bumps the npm_and_yarn group with 2 updates in the /packages/uniswap directory: [react-native-mmkv](https://github.com/mrousavy/react-native-mmkv) and [graphql](https://github.com/graphql/graphql-js). Bumps the npm_and_yarn group with 1 update in the /packages/utilities directory: [graphql](https://github.com/graphql/graphql-js). Bumps the npm_and_yarn group with 1 update in the /packages/wallet directory: [graphql](https://github.com/graphql/graphql-js). Updates `webpack` from 5.90.0 to 5.94.0 - [Release notes](https://github.com/webpack/webpack/releases) - [Commits](https://github.com/webpack/webpack/compare/v5.90.0...v5.94.0) Updates `webpack-dev-server` from 4.15.1 to 5.2.1 - [Release notes](https://github.com/webpack/webpack-dev-server/releases) - [Changelog](https://github.com/webpack/webpack-dev-server/blob/main/CHANGELOG.md) - [Commits](https://github.com/webpack/webpack-dev-server/compare/v4.15.1...v5.2.1) Updates `react-native-mmkv` from 2.10.1 to 2.11.0 - [Release notes](https://github.com/mrousavy/react-native-mmkv/releases) - [Commits](https://github.com/mrousavy/react-native-mmkv/compare/v2.10.1...v2.11.0) Updates `webpack` from 5.90.0 to 5.94.0 - [Release notes](https://github.com/webpack/webpack/releases) - [Commits](https://github.com/webpack/webpack/compare/v5.90.0...v5.94.0) Updates `graphql` from 16.6.0 to 16.8.1 - [Release notes](https://github.com/graphql/graphql-js/releases) - [Commits](https://github.com/graphql/graphql-js/compare/v16.6.0...v16.8.1) Updates `hono` from 4.8.4 to 4.9.7 - [Release notes](https://github.com/honojs/hono/releases) - [Commits](https://github.com/honojs/hono/compare/v4.8.4...v4.9.7) Updates `react-native-mmkv` from 2.10.1 to 2.11.0 - [Release notes](https://github.com/mrousavy/react-native-mmkv/releases) - [Commits](https://github.com/mrousavy/react-native-mmkv/compare/v2.10.1...v2.11.0) Updates `graphql` from 16.6.0 to 16.8.1 - [Release notes](https://github.com/graphql/graphql-js/releases) - [Commits](https://github.com/graphql/graphql-js/compare/v16.6.0...v16.8.1) Updates `graphql` from 16.6.0 to 16.8.1 - [Release notes](https://github.com/graphql/graphql-js/releases) - [Commits](https://github.com/graphql/graphql-js/compare/v16.6.0...v16.8.1) Updates `graphql` from 16.6.0 to 16.8.1 - [Release notes](https://github.com/graphql/graphql-js/releases) - [Commits](https://github.com/graphql/graphql-js/compare/v16.6.0...v16.8.1) --- updated-dependencies: - dependency-name: webpack dependency-version: 5.94.0 dependency-type: direct:development dependency-group: npm_and_yarn - dependency-name: webpack-dev-server dependency-version: 5.2.1 dependency-type: direct:development dependency-group: npm_and_yarn - dependency-name: react-native-mmkv dependency-version: 2.11.0 dependency-type: direct:production dependency-group: npm_and_yarn - dependency-name: webpack dependency-version: 5.94.0 dependency-type: direct:development dependency-group: npm_and_yarn - dependency-name: graphql dependency-version: 16.8.1 dependency-type: direct:production dependency-group: npm_and_yarn - dependency-name: hono dependency-version: 4.9.7 dependency-type: direct:production dependency-group: npm_and_yarn - dependency-name: react-native-mmkv dependency-version: 2.11.0 dependency-type: direct:production dependency-group: npm_and_yarn - dependency-name: graphql dependency-version: 16.8.1 dependency-type: direct:production dependency-group: npm_and_yarn - dependency-name: graphql dependency-version: 16.8.1 dependency-type: direct:production dependency-group: npm_and_yarn - dependency-name: graphql dependency-version: 16.8.1 dependency-type: direct:production dependency-group: npm_and_yarn ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- apps/extension/package.json | 4 ++-- apps/mobile/package.json | 2 +- apps/web/package.json | 6 +++--- packages/uniswap/package.json | 4 ++-- packages/utilities/package.json | 2 +- packages/wallet/package.json | 2 +- 6 files changed, 10 insertions(+), 10 deletions(-) diff --git a/apps/extension/package.json b/apps/extension/package.json index ad4bff5873a..9dcc7292896 100644 --- a/apps/extension/package.json +++ b/apps/extension/package.json @@ -93,9 +93,9 @@ "swc-loader": "0.2.6", "tamagui-loader": "1.125.17", "typescript": "5.3.3", - "webpack": "5.90.0", + "webpack": "5.94.0", "webpack-cli": "5.1.4", - "webpack-dev-server": "4.15.1" + "webpack-dev-server": "5.2.1" }, "private": true, "scripts": { diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 70aa1633b50..8d69f608761 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -152,7 +152,7 @@ "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.10.1", + "react-native-mmkv": "2.11.0", "react-native-onesignal": "5.2.9", "react-native-pager-view": "6.5.1", "react-native-passkey": "3.1.0", diff --git a/apps/web/package.json b/apps/web/package.json index 95063c98f64..9761dcb163d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -150,7 +150,7 @@ "vitest": "3.2.1", "vitest-fetch-mock": "0.4.5", "wait-on": "8.0.2", - "webpack": "5.90.0", + "webpack": "5.94.0", "wrangler": "4.28.0" }, "dependencies": { @@ -222,8 +222,8 @@ "expo-crypto": "12.8.1", "fancy-canvas": "2.1.0", "framer-motion": "10.17.6", - "graphql": "16.6.0", - "hono": "4.8.4", + "graphql": "16.8.1", + "hono": "4.9.7", "html-entities": "2.6.0", "i18next": "23.10.0", "jotai": "1.3.7", diff --git a/packages/uniswap/package.json b/packages/uniswap/package.json index 134290c7a7c..b0ef401bf29 100644 --- a/packages/uniswap/package.json +++ b/packages/uniswap/package.json @@ -76,7 +76,7 @@ "expo-haptics": "14.0.1", "expo-web-browser": "14.0.2", "fuse.js": "6.5.3", - "graphql": "16.6.0", + "graphql": "16.8.1", "i18next": "23.10.0", "i18next-resources-to-backend": "1.2.1", "idb-keyval": "6.2.1", @@ -96,7 +96,7 @@ "react-native-fast-image": "8.6.3", "react-native-gesture-handler": "2.22.1", "react-native-localize": "2.2.6", - "react-native-mmkv": "2.10.1", + "react-native-mmkv": "2.11.0", "react-native-reanimated": "3.16.7", "react-native-svg": "15.11.2", "react-native-webview": "13.13.5", diff --git a/packages/utilities/package.json b/packages/utilities/package.json index 5d6ff4e591c..59fcda15023 100644 --- a/packages/utilities/package.json +++ b/packages/utilities/package.json @@ -24,7 +24,7 @@ "@uniswap/sdk-core": "7.7.2", "dayjs": "1.11.7", "expo-localization": "16.0.1", - "graphql": "16.6.0", + "graphql": "16.8.1", "jsbi": "3.2.5", "promise": "8.3.0", "react": "18.3.1", diff --git a/packages/wallet/package.json b/packages/wallet/package.json index da490de9e11..7a619b7c796 100644 --- a/packages/wallet/package.json +++ b/packages/wallet/package.json @@ -35,7 +35,7 @@ "dayjs": "1.11.7", "ethers": "5.7.2", "fuse.js": "6.5.3", - "graphql": "16.6.0", + "graphql": "16.8.1", "i18next": "23.10.0", "jsbi": "3.2.5", "lodash": "4.17.21", From 607e2db9562cc148fa60c2c5cfdd32ceb75f515f Mon Sep 17 00:00:00 2001 From: AU_gdev_19 <64915515+Dargon789@users.noreply.github.com> Date: Tue, 30 Sep 2025 02:43:18 +0700 Subject: [PATCH 04/34] Potential fix for code scanning alert no. 19: Incomplete regular expression for hostnames (#63) Signed-off-by: AU_gdev_19 <64915515+Dargon789@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- apps/web/src/pages/Landing/Landing.e2e.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/pages/Landing/Landing.e2e.test.ts b/apps/web/src/pages/Landing/Landing.e2e.test.ts index aec2b7a703b..e213ceaa6c1 100644 --- a/apps/web/src/pages/Landing/Landing.e2e.test.ts +++ b/apps/web/src/pages/Landing/Landing.e2e.test.ts @@ -53,7 +53,7 @@ test.describe('Landing Page', () => { await page.unrouteAll({ behavior: 'ignoreErrors' }) }) test('renders UK compliance banner in UK', async ({ page }) => { - await page.route(/(?:interface|beta).gateway.uniswap.org\/v1\/amplitude-proxy/, async (route) => { + await page.route(/(?:interface|beta)\.gateway\.uniswap\.org\/v1\/amplitude-proxy/, async (route) => { const requestBody = JSON.stringify(await route.request().postDataJSON()) const originalResponse = await route.fetch() const byteSize = new Blob([requestBody]).size From 036022cf0ef73728dd0d13d6855a16103ba349a6 Mon Sep 17 00:00:00 2001 From: AU_gdev_19 <64915515+Dargon789@users.noreply.github.com> Date: Tue, 30 Sep 2025 03:29:29 +0700 Subject: [PATCH 05/34] Update issue templates (#64) * Update issue templates * Update .github/ISSUE_TEMPLATE/feature_request.md Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Signed-off-by: AU_gdev_19 <64915515+Dargon789@users.noreply.github.com> --------- Signed-off-by: AU_gdev_19 <64915515+Dargon789@users.noreply.github.com> Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- .github/ISSUE_TEMPLATE/bug_report.md | 2 ++ .github/ISSUE_TEMPLATE/custom.md | 10 ++++++++++ .github/ISSUE_TEMPLATE/feature_request.md | 20 ++++++++++++++++++++ 3 files changed, 32 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/custom.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index b01ad10c152..feac4b614be 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -3,6 +3,8 @@ name: Bug Report about: Report a bug or unexpected behavior in the Uniswap interfaces. title: "[Bug] " labels: bug +assignees: '' + --- ## 📱 Interface Affected diff --git a/.github/ISSUE_TEMPLATE/custom.md b/.github/ISSUE_TEMPLATE/custom.md new file mode 100644 index 00000000000..48d5f81fa42 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/custom.md @@ -0,0 +1,10 @@ +--- +name: Custom issue template +about: Describe this issue template's purpose here. +title: '' +labels: '' +assignees: '' + +--- + + diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 00000000000..36014cde565 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: 'enhancement' +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. From 87893de63935254c9c8e4b2d6ff8d41d7bf7e4aa Mon Sep 17 00:00:00 2001 From: AU_gdev_19 <64915515+Dargon789@users.noreply.github.com> Date: Wed, 1 Oct 2025 02:22:52 +0700 Subject: [PATCH 06/34] Potential fix for code scanning alert no. 17: Incomplete regular expression for hostnames (#65) Signed-off-by: AU_gdev_19 <64915515+Dargon789@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- apps/web/src/playwright/fixtures/graphql.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/playwright/fixtures/graphql.ts b/apps/web/src/playwright/fixtures/graphql.ts index 5f2ed08e1e0..59823342aac 100644 --- a/apps/web/src/playwright/fixtures/graphql.ts +++ b/apps/web/src/playwright/fixtures/graphql.ts @@ -58,7 +58,7 @@ export const test = base.extend({ } } - await page.route(/(?:interface|beta).(gateway|api).uniswap.org\/v1\/graphql/, async (route) => { + await page.route(/(?:interface|beta)\.(gateway|api)\.uniswap\.org\/v1\/graphql/, async (route) => { const request = route.request() const postData = request.postData() if (!postData) { From 8e3b4a5e184b498423d5b25ca5ceb5a9258c92c2 Mon Sep 17 00:00:00 2001 From: AU_gdev_19 <64915515+Dargon789@users.noreply.github.com> Date: Wed, 1 Oct 2025 03:30:14 +0700 Subject: [PATCH 07/34] Update tag_and_release.yml (#66) Configure tag_and_release GitHub Actions workflow with explicit write permissions and annotate the GITHUB_TOKEN usage. CI: Add explicit contents, issues, and pull-requests write permissions to the workflow Chores: Add comment noting alternative use of a personal access token for GITHUB_TOKEN Signed-off-by: AU_gdev_19 <64915515+Dargon789@users.noreply.github.com> --- .github/workflows/tag_and_release.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tag_and_release.yml b/.github/workflows/tag_and_release.yml index d96e0d46fef..681f171f631 100644 --- a/.github/workflows/tag_and_release.yml +++ b/.github/workflows/tag_and_release.yml @@ -3,7 +3,10 @@ on: push: branches: - 'main' - +permissions: + contents: write + issues: write + pull-requests: write jobs: deploy-to-prod: runs-on: ubuntu-latest @@ -33,7 +36,7 @@ jobs: - name: 🪽 Release uses: actions/create-release@c9ba6969f07ed90fae07e2e66100dd03f9b1a50e env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Or use your PAT with: tag_name: ${{ steps.github-tag-action.outputs.new_tag }} release_name: Release ${{ steps.github-tag-action.outputs.new_tag }} From 4b5c40f67b3a55a3ddb505fe2dcc53b4dc6d9d1c Mon Sep 17 00:00:00 2001 From: AU_gdev_19 <64915515+Dargon789@users.noreply.github.com> Date: Wed, 1 Oct 2025 09:17:20 +0700 Subject: [PATCH 08/34] Update tag_and_release.yml (#67) Signed-off-by: AU_gdev_19 <64915515+Dargon789@users.noreply.github.com> --- .github/workflows/tag_and_release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tag_and_release.yml b/.github/workflows/tag_and_release.yml index 681f171f631..ec6b8721bc6 100644 --- a/.github/workflows/tag_and_release.yml +++ b/.github/workflows/tag_and_release.yml @@ -34,7 +34,7 @@ jobs: tag_prefix: "" - name: 🪽 Release - uses: actions/create-release@c9ba6969f07ed90fae07e2e66100dd03f9b1a50e + uses: actions/create-release@8e3b4a5e184b498423d5b25ca5ceb5a9258c92c2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Or use your PAT with: From 008555b99b527a799ea1cd33771b932bd7866383 Mon Sep 17 00:00:00 2001 From: AU_019 <64915515+Dargon789@users.noreply.github.com> Date: Sun, 2 Feb 2025 01:24:29 +0700 Subject: [PATCH 09/34] Potential fix for code scanning alert no. 10: Incomplete regular expression for hostnames Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Signed-off-by: AU_019 <64915515+Dargon789@users.noreply.github.com> --- apps/web/cypress/support/commands.ts | 168 +++++++++++++++++++++++++++ 1 file changed, 168 insertions(+) create mode 100644 apps/web/cypress/support/commands.ts diff --git a/apps/web/cypress/support/commands.ts b/apps/web/cypress/support/commands.ts new file mode 100644 index 00000000000..476905a0daa --- /dev/null +++ b/apps/web/cypress/support/commands.ts @@ -0,0 +1,168 @@ +import 'cypress-hardhat/lib/browser' + +import { Eip1193 } from 'cypress-hardhat/lib/browser/eip1193' +import { FeatureFlagClient, FeatureFlags, getFeatureFlagName } from 'uniswap/src/features/gating/flags' +import { ALLOW_ANALYTICS_ATOM_KEY } from 'utilities/src/telemetry/analytics/constants' +import { UserState, initialState } from '../../src/state/user/reducer' +import { setInitialUserState } from '../utils/user-state' + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Cypress { + interface ApplicationWindow { + ethereum: Eip1193 + } + interface Chainable { + /** + * Wait for a specific event to be sent to amplitude. If the event is found, the subject will be the event. + * + * @param {string} eventName - The type of the event to search for e.g. SwapEventName.SWAP_TRANSACTION_COMPLETED + * @param {number} timeout - The maximum amount of time (in ms) to wait for the event. + * @returns {Chainable} + */ + waitForAmplitudeEvent(eventName: string, requiredProperties?: string[]): Chainable + /** + * Intercepts a specific graphql operation and responds with the given fixture. + * @param {string} operationName - The name of the graphql operation to intercept. + * @param {string} fixturePath - The path to the fixture to respond with. + */ + interceptGraphqlOperation(operationName: string, fixturePath: string): Chainable + /** + * Intercepts a quote request and responds with the given fixture. + * @param {string} fixturePath - The path to the fixture to respond with. + */ + interceptQuoteRequest(fixturePath: string): Chainable + } + interface Cypress { + eagerlyConnect?: boolean + } + interface VisitOptions { + featureFlags?: Array<{ flag: FeatureFlags; value: boolean }> + /** + * Initial user state. + * @default {@type import('../utils/user-state').CONNECTED_WALLET_USER_STATE} + */ + userState?: Partial + /** + * If false, prevents the app from eagerly connecting to the injected provider. + * @default true + */ + eagerlyConnect?: false + } + } +} + +export function registerCommands() { + // sets up the injected provider to be a mock ethereum provider with the given mnemonic/index + // eslint-disable-next-line no-undef + Cypress.Commands.overwrite( + 'visit', + (original, url: string | Partial, options?: Partial) => { + if (typeof url !== 'string') { + throw new Error('Invalid arguments. The first argument to cy.visit must be the path.') + } + + // Parse overrides + const flagsOn: FeatureFlags[] = [] + const flagsOff: FeatureFlags[] = [] + options?.featureFlags?.forEach((f) => { + if (f.value) { + flagsOn.push(f.flag) + } else { + flagsOff.push(f.flag) + } + }) + + // Format into URL parameters + const overrideParams = new URLSearchParams() + if (flagsOn.length > 0) { + overrideParams.append( + 'featureFlagOverride', + flagsOn.map((flag) => getFeatureFlagName(flag, FeatureFlagClient.Web)).join(','), + ) + } + if (flagsOff.length > 0) { + overrideParams.append( + 'featureFlagOverrideOff', + flagsOn.map((flag) => getFeatureFlagName(flag, FeatureFlagClient.Web)).join(','), + ) + } + + return cy.provider().then((provider) => + original({ + ...options, + url: + [...overrideParams.entries()].length === 0 + ? url + : url.includes('?') + ? `${url}&${overrideParams.toString()}` + : `${url}?${overrideParams.toString()}`, + onBeforeLoad(win) { + options?.onBeforeLoad?.(win) + + setInitialUserState(win, { + ...initialState, + ...(options?.userState ?? {}), + }) + + win.ethereum = provider + win.Cypress.eagerlyConnect = options?.eagerlyConnect ?? true + win.localStorage.setItem(ALLOW_ANALYTICS_ATOM_KEY, 'true') + win.localStorage.setItem('showUniswapExtensionLaunchAtom', 'false') + }, + }), + ) + }, + ) + + Cypress.Commands.add('waitForAmplitudeEvent', (eventName, requiredProperties) => { + function findAndDiscardEventsUpToTarget() { + const events = Cypress.env('amplitudeEventCache') + const targetEventIndex = events.findIndex((event) => { + if (event.event_type !== eventName) { + return false + } + if (requiredProperties) { + return requiredProperties.every((prop) => event.event_properties[prop]) + } + return true + }) + + if (targetEventIndex !== -1) { + const event = events[targetEventIndex] + Cypress.env('amplitudeEventCache', events.slice(targetEventIndex + 1)) + return cy.wrap(event) + } else { + // If not found, retry after waiting for more events to be sent. + return cy.wait('@amplitude').then(findAndDiscardEventsUpToTarget) + } + } + return findAndDiscardEventsUpToTarget() + }) + + Cypress.env('graphqlInterceptions', new Map()) + + Cypress.Commands.add('interceptGraphqlOperation', (operationName, fixturePath) => { + const graphqlInterceptions = Cypress.env('graphqlInterceptions') + cy.intercept(/(?:interface|beta).gateway.uniswap.org\/v1\/graphql/, (req) => { + req.headers['origin'] = 'https://app.uniswap.org' + const currentOperationName = req.body.operationName + + if (graphqlInterceptions.has(currentOperationName)) { + const fixturePath = graphqlInterceptions.get(currentOperationName) + req.reply({ fixture: fixturePath }) + } else { + req.continue() + } + }).as(operationName) + + graphqlInterceptions.set(operationName, fixturePath) + }) + + Cypress.Commands.add('interceptQuoteRequest', (fixturePath) => { + return cy.intercept(/(?:interface|beta)\.gateway\.uniswap\.org\/v2\/quote/, (req) => { + req.headers['origin'] = 'https://app.uniswap.org' + req.reply({ fixture: fixturePath }) + }) + }) +} From 085f13dfe805ed43222615349fa29b319583d880 Mon Sep 17 00:00:00 2001 From: AU_019 <64915515+Dargon789@users.noreply.github.com> Date: Sat, 1 Feb 2025 19:32:20 +0700 Subject: [PATCH 10/34] Create SECURITY.md Signed-off-by: AU_019 <64915515+Dargon789@users.noreply.github.com> --- SECURITY.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 SECURITY.md diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000000..034e8480320 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,21 @@ +# Security Policy + +## Supported Versions + +Use this section to tell people about which versions of your project are +currently being supported with security updates. + +| Version | Supported | +| ------- | ------------------ | +| 5.1.x | :white_check_mark: | +| 5.0.x | :x: | +| 4.0.x | :white_check_mark: | +| < 4.0 | :x: | + +## Reporting a Vulnerability + +Use this section to tell people how to report a vulnerability. + +Tell them where to go, how often they can expect to get an update on a +reported vulnerability, what to expect if the vulnerability is accepted or +declined, etc. From a6b4970e8ffd383db9de1bdd280ba5b90ed3aeb3 Mon Sep 17 00:00:00 2001 From: AU_019 <64915515+Dargon789@users.noreply.github.com> Date: Sat, 1 Feb 2025 19:43:24 +0700 Subject: [PATCH 11/34] Potential fix for code scanning alert no. 11: Incomplete string escaping or encoding Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Signed-off-by: AU_019 <64915515+Dargon789@users.noreply.github.com> --- apps/web/src/components/Logo/DoubleLogo.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/Logo/DoubleLogo.tsx b/apps/web/src/components/Logo/DoubleLogo.tsx index 610c4e8205c..9d3dfc98b7e 100644 --- a/apps/web/src/components/Logo/DoubleLogo.tsx +++ b/apps/web/src/components/Logo/DoubleLogo.tsx @@ -47,7 +47,7 @@ function LogolessPlaceholder({ return ( - {currency?.symbol?.toUpperCase().replace('$', '').replace(/\s+/g, '').slice(0, 3)} + {currency?.symbol?.toUpperCase().replace(/\$/g, '').replace(/\s+/g, '').slice(0, 3)} {showNetworkLogo && ( From 8a4ae07b83f18c96635d6dc2d812bcc9753f1b99 Mon Sep 17 00:00:00 2001 From: AU_019 <64915515+Dargon789@users.noreply.github.com> Date: Sat, 25 Jan 2025 03:21:50 +0700 Subject: [PATCH 12/34] Create static.yml Signed-off-by: AU_019 <64915515+Dargon789@users.noreply.github.com> --- .github/workflows/static.yml | 43 ++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 .github/workflows/static.yml diff --git a/.github/workflows/static.yml b/.github/workflows/static.yml new file mode 100644 index 00000000000..f2c9e97c91d --- /dev/null +++ b/.github/workflows/static.yml @@ -0,0 +1,43 @@ +# Simple workflow for deploying static content to GitHub Pages +name: Deploy static content to Pages + +on: + # Runs on pushes targeting the default branch + push: + branches: ["main"] + + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: + +# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages +permissions: + contents: read + pages: write + id-token: write + +# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. +# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. +concurrency: + group: "pages" + cancel-in-progress: false + +jobs: + # Single deploy job since we're just deploying + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Setup Pages + uses: actions/configure-pages@v5 + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + # Upload entire repository + path: '.' + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 From 46b0af5555e498ccc1ac24f1a954c97741603033 Mon Sep 17 00:00:00 2001 From: AU_019 <64915515+Dargon789@users.noreply.github.com> Date: Sat, 25 Jan 2025 03:17:58 +0700 Subject: [PATCH 13/34] Create jekyll-gh-pages.yml Signed-off-by: AU_019 <64915515+Dargon789@users.noreply.github.com> --- .github/workflows/jekyll-gh-pages.yml | 51 +++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 .github/workflows/jekyll-gh-pages.yml diff --git a/.github/workflows/jekyll-gh-pages.yml b/.github/workflows/jekyll-gh-pages.yml new file mode 100644 index 00000000000..e31d81c5864 --- /dev/null +++ b/.github/workflows/jekyll-gh-pages.yml @@ -0,0 +1,51 @@ +# Sample workflow for building and deploying a Jekyll site to GitHub Pages +name: Deploy Jekyll with GitHub Pages dependencies preinstalled + +on: + # Runs on pushes targeting the default branch + push: + branches: ["main"] + + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: + +# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages +permissions: + contents: read + pages: write + id-token: write + +# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. +# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. +concurrency: + group: "pages" + cancel-in-progress: false + +jobs: + # Build job + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Setup Pages + uses: actions/configure-pages@v5 + - name: Build with Jekyll + uses: actions/jekyll-build-pages@v1 + with: + source: ./ + destination: ./_site + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + + # Deployment job + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + needs: build + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 From bdef85180fee744e0d32cdb3da7085b14302c3cf Mon Sep 17 00:00:00 2001 From: snyk-bot Date: Sat, 15 Feb 2025 21:21:05 +0000 Subject: [PATCH 14/34] fix: packages/ui/package.json to reduce vulnerabilities The following vulnerabilities are fixed with an upgrade: - https://snyk.io/vuln/SNYK-JS-ELLIPTIC-8720086 - https://snyk.io/vuln/SNYK-JS-ELLIPTIC-8187303 - https://snyk.io/vuln/SNYK-JS-ELLIPTIC-7577916 - https://snyk.io/vuln/SNYK-JS-ELLIPTIC-7577917 - https://snyk.io/vuln/SNYK-JS-ELLIPTIC-7577918 --- packages/ui/package.json | 68 +++++++++++++++++++--------------------- 1 file changed, 32 insertions(+), 36 deletions(-) diff --git a/packages/ui/package.json b/packages/ui/package.json index d7f21662fd3..b2be1a7682d 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -2,47 +2,41 @@ "name": "ui", "version": "0.0.0", "dependencies": { - "@gorhom/bottom-sheet": "4.6.4", - "@react-native-masked-view/masked-view": "0.3.2", - "@shopify/flash-list": "1.7.3", - "@shopify/react-native-skia": "1.12.4", - "@storybook/react": "8.5.2", - "@tamagui/animations-react-native": "1.125.17", - "@tamagui/font-inter": "1.125.17", - "@tamagui/helpers-icon": "1.125.17", - "@tamagui/portal": "1.125.17", - "@tamagui/react-native-media-driver": "1.125.17", - "@tamagui/remove-scroll": "1.125.17", - "@tamagui/theme-base": "1.125.17", - "@tanstack/react-query": "5.77.2", - "@testing-library/react-hooks": "8.0.1", - "ethers": "5.7.2", - "expo-blur": "14.0.3", - "expo-linear-gradient": "14.0.2", + "@gorhom/bottom-sheet": "4.5.1", + "@react-native-masked-view/masked-view": "0.2.9", + "@shopify/flash-list": "1.6.3", + "@shopify/react-native-skia": "1.4.2", + "@storybook/react": "8.4.2", + "@tamagui/animations-react-native": "1.114.4", + "@tamagui/font-inter": "1.114.4", + "@tamagui/helpers-icon": "1.114.4", + "@tamagui/react-native-media-driver": "1.114.4", + "@tamagui/remove-scroll": "1.114.4", + "@tamagui/theme-base": "1.114.4", + "@testing-library/react-hooks": "7.0.2", + "ethers": "6.0.0", + "expo-linear-gradient": "12.7.2", "i18next": "23.10.0", "qrcode": "1.5.1", - "react": "18.3.1", - "react-i18next": "14.1.0", - "react-native": "0.77.2", + "react": "18.2.0", + "react-native": "0.73.6", "react-native-fast-image": "8.6.3", - "react-native-gesture-handler": "2.22.1", + "react-native-gesture-handler": "2.19.0", "react-native-image-colors": "1.5.2", - "react-native-keyboard-controller": "1.17.5", - "react-native-reanimated": "3.16.7", - "react-native-safe-area-context": "5.1.0", - "react-native-svg": "15.11.2", - "react-native-webview": "13.13.5", - "tamagui": "1.125.17", + "react-native-reanimated": "3.15.0", + "react-native-safe-area-context": "4.9.0", + "react-native-svg": "15.1.0", + "react-native-webview": "11.23.1", + "tamagui": "1.114.4", "utilities": "workspace:^", "uuid": "9.0.0", "wcag-contrast": "3.0.0" }, "devDependencies": { - "@storybook/test": "8.5.2", - "@tamagui/animations-moti": "1.125.17", - "@tamagui/core": "1.125.17", - "@testing-library/react-native": "13.0.0", - "@types/chrome": "0.0.304", + "@tamagui/animations-moti": "1.114.4", + "@tamagui/build": "1.114.4", + "@tamagui/core": "1.114.4", + "@testing-library/react-native": "11.5.0", "@types/qrcode": "1.5.5", "@uniswap/eslint-config": "workspace:^", "camelcase": "6.3.0", @@ -63,15 +57,17 @@ "module:jsx": "src", "private": true, "scripts": { - "build:icons": "bunx tsx ./src/scripts/componentize-icons.ts && biome check --write --unsafe", - "build:icons:missing": "bun run build:icons --skip-existing", + "build": "tamagui-build --ignore-base-url && node -r esbuild-register ./src/scripts/remove-declaration-files-from-utilities.ts", + "clean": "tamagui-build clean", + "build:icons": "node -r esbuild-register ./src/scripts/componentize-icons.ts", + "build:icons:missing": "yarn build:icons --skip-existing", "check:deps:usage": "depcheck", "lint": "eslint src --max-warnings=0", - "format": "biome check . --linter-enabled=false", + "format": "../../scripts/prettier.sh", "lint:fix": "eslint src --fix", "test": "jest && echo 'ignoring'", "typecheck": "tsc -b", - "watch": "bun run build --watch" + "watch": "yarn build --watch" }, "sideEffects": [ "*.css" From d061a4d1739df56068f7885c85e7e51755b07b9e Mon Sep 17 00:00:00 2001 From: AU_gdev_19 <64915515+Dargon789@users.noreply.github.com> Date: Wed, 1 Oct 2025 21:53:05 +0700 Subject: [PATCH 15/34] Update tag_and_release.yml (#68) Refactor the tag_and_release workflow to trigger only on tag pushes, tighten permissions, restructure the release job, and switch from the actions/create-release action to a GitHub CLI invocation. Signed-off-by: AU_gdev_19 <64915515+Dargon789@users.noreply.github.com> --- .github/workflows/tag_and_release.yml | 30 ++++++++++++++++++++------- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/.github/workflows/tag_and_release.yml b/.github/workflows/tag_and_release.yml index ec6b8721bc6..f05c7a5689b 100644 --- a/.github/workflows/tag_and_release.yml +++ b/.github/workflows/tag_and_release.yml @@ -33,11 +33,27 @@ jobs: custom_tag: ${{ steps.version.outputs.content }} tag_prefix: "" - - name: 🪽 Release - uses: actions/create-release@8e3b4a5e184b498423d5b25ca5ceb5a9258c92c2 + - name: 🪽 Create release + + on: + push: + tags: + - v* + + permissions: + contents: write + + jobs: + release: + name: Release pushed tag + runs-on: ubuntu-24.04 + steps: + - name: Create release env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Or use your PAT - with: - tag_name: ${{ steps.github-tag-action.outputs.new_tag }} - release_name: Release ${{ steps.github-tag-action.outputs.new_tag }} - body: ${{ steps.release-notes.outputs.content }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + tag: ${{ github.ref_name }} + run: | + gh release create "$tag" \ + --repo="$GITHUB_REPOSITORY" \ + --title="${GITHUB_REPOSITORY#*/} ${tag#v}" \ + --generate-notes From b9e1703afed8bdd80c2047ff04ef4cf65124df2d Mon Sep 17 00:00:00 2001 From: AU_gdev_19 <64915515+Dargon789@users.noreply.github.com> Date: Thu, 9 Oct 2025 18:09:55 +0000 Subject: [PATCH 16/34] Create config.yml (#72) Summary by Sourcery CI: Introduce CircleCI 2.1 pipeline with a docker-based say-hello job and workflow Signed-off-by: AU_gdev_19 <64915515+Dargon789@users.noreply.github.com> --- .circleci/config.yml | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 .circleci/config.yml diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 00000000000..d5d401c5189 --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,31 @@ +# Use the latest 2.1 version of CircleCI pipeline process engine. +# See: https://circleci.com/docs/configuration-reference +version: 2.1 + +# Define a job to be invoked later in a workflow. +# See: https://circleci.com/docs/jobs-steps/#jobs-overview & https://circleci.com/docs/configuration-reference/#jobs +jobs: + say-hello: + # Specify the execution environment. You can specify an image from Docker Hub or use one of our convenience images from CircleCI's Developer Hub. + # See: https://circleci.com/docs/executor-intro/ & https://circleci.com/docs/configuration-reference/#executor-job + docker: + # Specify the version you desire here + # See: https://circleci.com/developer/images/image/cimg/base + - image: cimg/base:current + + # Add steps to the job + # See: https://circleci.com/docs/jobs-steps/#steps-overview & https://circleci.com/docs/configuration-reference/#steps + steps: + # Checkout the code as the first step. + - checkout + - run: + name: "Say hello" + command: "echo Hello, World!" + +# Orchestrate jobs using workflows +# See: https://circleci.com/docs/workflows/ & https://circleci.com/docs/configuration-reference/#workflows +workflows: + say-hello-workflow: # This is the name of the workflow, feel free to change it to better match your workflow. + # Inside the workflow, you define the jobs you want to run. + jobs: + - say-hello From 0b9e0dc6018f9849382563b6f2aa95e74e510272 Mon Sep 17 00:00:00 2001 From: AU_gdev_19 <64915515+Dargon789@users.noreply.github.com> Date: Thu, 9 Oct 2025 18:38:42 +0000 Subject: [PATCH 17/34] Create config.yml (#75) Add a CircleCI configuration file with a basic job and workflow CI: Add .circleci/config.yml using CircleCI 2.1 configuration format Define a Docker-based "say-hello" job that checks out the code and prints "Hello, World!" Create "say-hello-workflow" to orchestrate and run the "say-hello" job Signed-off-by: AU_gdev_19 <64915515+Dargon789@users.noreply.github.com> --- .circleci/config.yml | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 .circleci/config.yml diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 00000000000..d5d401c5189 --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,31 @@ +# Use the latest 2.1 version of CircleCI pipeline process engine. +# See: https://circleci.com/docs/configuration-reference +version: 2.1 + +# Define a job to be invoked later in a workflow. +# See: https://circleci.com/docs/jobs-steps/#jobs-overview & https://circleci.com/docs/configuration-reference/#jobs +jobs: + say-hello: + # Specify the execution environment. You can specify an image from Docker Hub or use one of our convenience images from CircleCI's Developer Hub. + # See: https://circleci.com/docs/executor-intro/ & https://circleci.com/docs/configuration-reference/#executor-job + docker: + # Specify the version you desire here + # See: https://circleci.com/developer/images/image/cimg/base + - image: cimg/base:current + + # Add steps to the job + # See: https://circleci.com/docs/jobs-steps/#steps-overview & https://circleci.com/docs/configuration-reference/#steps + steps: + # Checkout the code as the first step. + - checkout + - run: + name: "Say hello" + command: "echo Hello, World!" + +# Orchestrate jobs using workflows +# See: https://circleci.com/docs/workflows/ & https://circleci.com/docs/configuration-reference/#workflows +workflows: + say-hello-workflow: # This is the name of the workflow, feel free to change it to better match your workflow. + # Inside the workflow, you define the jobs you want to run. + jobs: + - say-hello From 158f5146c430d8c1abaa56f93893adb74b3628c9 Mon Sep 17 00:00:00 2001 From: "snyk-io[bot]" <141718529+snyk-io[bot]@users.noreply.github.com> Date: Thu, 9 Oct 2025 18:16:15 +0000 Subject: [PATCH 18/34] fix: apps/mobile/Gemfile & apps/mobile/Gemfile.lock to reduce vulnerabilities The following vulnerabilities are fixed with an upgrade: - https://snyk.io/vuln/SNYK-RUBY-REXML-12878608 - https://snyk.io/vuln/SNYK-RUBY-WEBRICK-10500756 - https://snyk.io/vuln/SNYK-RUBY-WEBRICK-8068535 - https://snyk.io/vuln/SNYK-RUBY-REXML-13110060 --- apps/mobile/Gemfile | 4 +- apps/mobile/Gemfile.lock | 184 ++++++++++++++++++++------------------- 2 files changed, 98 insertions(+), 90 deletions(-) diff --git a/apps/mobile/Gemfile b/apps/mobile/Gemfile index a07e6ca69f3..a9e494ea9a1 100644 --- a/apps/mobile/Gemfile +++ b/apps/mobile/Gemfile @@ -1,8 +1,8 @@ source "https://rubygems.org" -gem 'fastlane', '2.214.0' +gem 'fastlane', '2.215.0' # Exclude problematic versions of cocoapods and activesupport that causes build failures. -gem 'cocoapods', '1.14.3' +gem 'cocoapods', '1.15.0' gem 'activesupport', '7.1.2' gem 'xcodeproj', '1.27.0' gem 'concurrent-ruby', '1.3.4' diff --git a/apps/mobile/Gemfile.lock b/apps/mobile/Gemfile.lock index b5891798a1a..1cc3a294c88 100644 --- a/apps/mobile/Gemfile.lock +++ b/apps/mobile/Gemfile.lock @@ -1,7 +1,9 @@ GEM remote: https://rubygems.org/ specs: - CFPropertyList (3.0.6) + CFPropertyList (3.0.7) + base64 + nkf rexml activesupport (7.1.2) base64 @@ -13,37 +15,40 @@ GEM minitest (>= 5.1) mutex_m tzinfo (~> 2.0) - addressable (2.8.6) - public_suffix (>= 2.0.2, < 6.0) + addressable (2.8.7) + public_suffix (>= 2.0.2, < 7.0) algoliasearch (1.27.5) httpclient (~> 2.8, >= 2.8.3) json (>= 1.5.1) - artifactory (3.0.15) + artifactory (3.0.17) atomos (0.1.3) - aws-eventstream (1.3.0) - aws-partitions (1.877.0) - aws-sdk-core (3.190.1) + aws-eventstream (1.4.0) + aws-partitions (1.1170.0) + aws-sdk-core (3.233.0) aws-eventstream (~> 1, >= 1.3.0) - aws-partitions (~> 1, >= 1.651.0) - aws-sigv4 (~> 1.8) + aws-partitions (~> 1, >= 1.992.0) + aws-sigv4 (~> 1.9) + base64 + bigdecimal jmespath (~> 1, >= 1.6.1) - aws-sdk-kms (1.75.0) - aws-sdk-core (~> 3, >= 3.188.0) - aws-sigv4 (~> 1.1) - aws-sdk-s3 (1.142.0) - aws-sdk-core (~> 3, >= 3.189.0) + logger + aws-sdk-kms (1.113.0) + aws-sdk-core (~> 3, >= 3.231.0) + aws-sigv4 (~> 1.5) + aws-sdk-s3 (1.199.1) + aws-sdk-core (~> 3, >= 3.231.0) aws-sdk-kms (~> 1) - aws-sigv4 (~> 1.8) - aws-sigv4 (1.8.0) + aws-sigv4 (~> 1.5) + aws-sigv4 (1.12.1) aws-eventstream (~> 1, >= 1.0.2) babosa (1.0.4) - base64 (0.2.0) - bigdecimal (3.1.9) + base64 (0.3.0) + bigdecimal (3.3.1) claide (1.1.0) - cocoapods (1.14.3) + cocoapods (1.15.0) addressable (~> 2.8) claide (>= 1.0.2, < 2.0) - cocoapods-core (= 1.14.3) + cocoapods-core (= 1.15.0) cocoapods-deintegrate (>= 1.0.3, < 2.0) cocoapods-downloader (>= 2.1, < 3.0) cocoapods-plugins (>= 1.0.0, < 2.0) @@ -58,7 +63,7 @@ GEM nap (~> 1.0) ruby-macho (>= 2.3.0, < 3.0) xcodeproj (>= 1.23.0, < 2.0) - cocoapods-core (1.14.3) + cocoapods-core (1.15.0) activesupport (>= 5.0, < 8) addressable (~> 2.8) algoliasearch (~> 1.0) @@ -82,19 +87,19 @@ GEM commander (4.6.0) highline (~> 2.0.0) concurrent-ruby (1.3.4) - connection_pool (2.5.0) + connection_pool (2.5.4) declarative (0.0.20) - digest-crc (0.6.5) + digest-crc (0.7.0) rake (>= 12.0.0, < 14.0.0) - domain_name (0.6.20231109) + domain_name (0.6.20240107) dotenv (2.8.1) - drb (2.2.1) + drb (2.2.3) emoji_regex (3.2.3) escape (0.0.4) - ethon (0.16.0) + ethon (0.15.0) ffi (>= 1.15.0) - excon (0.109.0) - faraday (1.10.3) + excon (0.112.0) + faraday (1.10.4) faraday-em_http (~> 1.0) faraday-em_synchrony (~> 1.0) faraday-excon (~> 1.1) @@ -110,20 +115,20 @@ GEM faraday (>= 0.8.0) http-cookie (~> 1.0.0) faraday-em_http (1.0.0) - faraday-em_synchrony (1.0.0) + faraday-em_synchrony (1.0.1) faraday-excon (1.1.0) faraday-httpclient (1.0.1) - faraday-multipart (1.0.4) - multipart-post (~> 2) - faraday-net_http (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.0) + faraday_middleware (1.2.1) faraday (~> 1.0) - fastimage (2.3.0) - fastlane (2.214.0) + fastimage (2.4.0) + fastlane (2.215.0) CFPropertyList (>= 2.3, < 4.0.0) addressable (>= 2.8, < 3.0.0) artifactory (~> 3.0) @@ -144,6 +149,7 @@ GEM google-apis-playcustomapp_v1 (~> 0.1) 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) @@ -155,116 +161,120 @@ GEM security (= 0.1.3) simctl (~> 1.6.3) terminal-notifier (>= 2.0.0, < 3.0.0) - terminal-table (>= 1.4.5, < 2.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.3.0) xcpretty-travis-formatter (>= 0.0.3) - fastlane-plugin-get_version_name (0.2.2) - fastlane-plugin-versioning_android (0.1.1) - ffi (1.17.1) + ffi (1.17.2) 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.2) + google-apis-androidpublisher_v3 (0.87.0) + google-apis-core (>= 0.15.0, < 2.a) + google-apis-core (0.18.0) addressable (~> 2.5, >= 2.5.1) - googleauth (>= 0.16.2, < 2.a) - httpclient (>= 2.8.1, < 3.a) + googleauth (~> 1.9) + httpclient (>= 2.8.3, < 3.a) mini_mime (~> 1.0) + mutex_m representable (~> 3.0) retriable (>= 2.0, < 4.a) - rexml - webrick - 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.29.0) - google-apis-core (>= 0.11.0, < 2.a) - google-cloud-core (1.6.1) + google-apis-iamcredentials_v1 (0.24.0) + google-apis-core (>= 0.15.0, < 2.a) + google-apis-playcustomapp_v1 (0.17.0) + google-apis-core (>= 0.15.0, < 2.a) + google-apis-storage_v1 (0.57.0) + google-apis-core (>= 0.15.0, < 2.a) + google-cloud-core (1.8.0) google-cloud-env (>= 1.0, < 3.a) google-cloud-errors (~> 1.0) - google-cloud-env (2.1.0) + google-cloud-env (2.3.1) + base64 (~> 0.2) faraday (>= 1.0, < 3.a) - google-cloud-errors (1.3.1) - google-cloud-storage (1.45.0) + google-cloud-errors (1.5.0) + google-cloud-storage (1.57.0) addressable (~> 2.8) digest-crc (~> 0.4) - google-apis-iamcredentials_v1 (~> 0.1) - google-apis-storage_v1 (~> 0.29.0) + google-apis-core (>= 0.18, < 2) + google-apis-iamcredentials_v1 (~> 0.18) + google-apis-storage_v1 (>= 0.42) google-cloud-core (~> 1.6) - googleauth (>= 0.16.2, < 2.a) + googleauth (~> 1.9) mini_mime (~> 1.0) - googleauth (1.9.1) + google-logging-utils (0.2.0) + googleauth (1.15.0) faraday (>= 1.0, < 3.a) - google-cloud-env (~> 2.1) - jwt (>= 1.4, < 3.0) + google-cloud-env (~> 2.2) + google-logging-utils (~> 0.1) + jwt (>= 1.4, < 4.0) multi_json (~> 1.11) os (>= 0.9, < 2.0) signet (>= 0.16, < 2.a) highline (2.0.3) - http-cookie (1.0.5) + http-cookie (1.0.8) domain_name (~> 0.5) - httpclient (2.8.3) + httpclient (2.9.0) + mutex_m i18n (1.14.7) concurrent-ruby (~> 1.0) jmespath (1.6.2) - json (2.7.1) - jwt (2.7.1) - mini_magick (4.12.0) + json (2.15.1) + jwt (2.10.2) + base64 + logger (1.7.0) + mini_magick (4.13.2) mini_mime (1.1.5) - minitest (5.25.4) + minitest (5.26.0) molinillo (0.8.0) - multi_json (1.15.0) - multipart-post (2.3.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.2.1) + naturally (2.3.0) netrc (0.11.0) + nkf (0.2.0) optparse (0.1.1) os (1.1.4) - plist (3.7.1) + plist (3.7.2) public_suffix (4.0.7) - rake (13.1.0) + 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) - rexml (3.4.1) + rexml (3.4.4) rouge (2.0.7) ruby-macho (2.5.1) ruby2_keywords (0.0.5) - rubyzip (2.3.2) + rubyzip (2.4.1) security (0.1.3) - signet (0.18.0) + signet (0.21.0) addressable (~> 2.8) faraday (>= 0.17.5, < 3.a) - jwt (>= 1.5, < 3.0) + jwt (>= 1.5, < 4.0) multi_json (~> 1.10) simctl (1.6.10) CFPropertyList naturally terminal-notifier (2.0.0) - terminal-table (1.8.0) - unicode-display_width (~> 1.1, >= 1.1.1) + 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.4.1) - ethon (>= 0.9.0) + 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 (1.8.0) - webrick (1.8.1) + unicode-display_width (2.6.0) word_wrap (1.0.0) xcodeproj (1.27.0) CFPropertyList (>= 2.3.3, < 4.0) @@ -283,12 +293,10 @@ PLATFORMS DEPENDENCIES activesupport (= 7.1.2) - cocoapods (= 1.14.3) + cocoapods (= 1.15.0) concurrent-ruby (= 1.3.4) - fastlane (= 2.214.0) - fastlane-plugin-get_version_name - fastlane-plugin-versioning_android + fastlane (= 2.215.0) xcodeproj (= 1.27.0) BUNDLED WITH - 2.4.10 + 2.3.27 From 1cbe82b0b79b3e6b72732c300f29dd0acb767957 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 9 Oct 2025 18:16:45 +0000 Subject: [PATCH 19/34] build(deps): bump the npm_and_yarn group across 7 directories with 5 updates Bumps the npm_and_yarn group with 2 updates in the /apps/extension directory: [webpack](https://github.com/webpack/webpack) and [webpack-dev-server](https://github.com/webpack/webpack-dev-server). Bumps the npm_and_yarn group with 1 update in the /apps/mobile directory: [react-native-mmkv](https://github.com/mrousavy/react-native-mmkv). Bumps the npm_and_yarn group with 3 updates in the /apps/web directory: [webpack](https://github.com/webpack/webpack), [graphql](https://github.com/graphql/graphql-js) and [hono](https://github.com/honojs/hono). Bumps the npm_and_yarn group with 1 update in the /packages/api directory: [graphql](https://github.com/graphql/graphql-js). Bumps the npm_and_yarn group with 2 updates in the /packages/uniswap directory: [react-native-mmkv](https://github.com/mrousavy/react-native-mmkv) and [graphql](https://github.com/graphql/graphql-js). Bumps the npm_and_yarn group with 1 update in the /packages/utilities directory: [graphql](https://github.com/graphql/graphql-js). Bumps the npm_and_yarn group with 1 update in the /packages/wallet directory: [graphql](https://github.com/graphql/graphql-js). Updates `webpack` from 5.90.0 to 5.94.0 - [Release notes](https://github.com/webpack/webpack/releases) - [Commits](https://github.com/webpack/webpack/compare/v5.90.0...v5.94.0) Updates `webpack-dev-server` from 4.15.1 to 5.2.1 - [Release notes](https://github.com/webpack/webpack-dev-server/releases) - [Changelog](https://github.com/webpack/webpack-dev-server/blob/main/CHANGELOG.md) - [Commits](https://github.com/webpack/webpack-dev-server/compare/v4.15.1...v5.2.1) Updates `react-native-mmkv` from 2.10.1 to 2.11.0 - [Release notes](https://github.com/mrousavy/react-native-mmkv/releases) - [Commits](https://github.com/mrousavy/react-native-mmkv/compare/v2.10.1...v2.11.0) Updates `webpack` from 5.90.0 to 5.94.0 - [Release notes](https://github.com/webpack/webpack/releases) - [Commits](https://github.com/webpack/webpack/compare/v5.90.0...v5.94.0) Updates `graphql` from 16.6.0 to 16.8.1 - [Release notes](https://github.com/graphql/graphql-js/releases) - [Commits](https://github.com/graphql/graphql-js/compare/v16.6.0...v16.8.1) Updates `hono` from 4.8.4 to 4.9.7 - [Release notes](https://github.com/honojs/hono/releases) - [Commits](https://github.com/honojs/hono/compare/v4.8.4...v4.9.7) Updates `graphql` from 16.6.0 to 16.8.1 - [Release notes](https://github.com/graphql/graphql-js/releases) - [Commits](https://github.com/graphql/graphql-js/compare/v16.6.0...v16.8.1) Updates `react-native-mmkv` from 2.10.1 to 2.11.0 - [Release notes](https://github.com/mrousavy/react-native-mmkv/releases) - [Commits](https://github.com/mrousavy/react-native-mmkv/compare/v2.10.1...v2.11.0) Updates `graphql` from 16.6.0 to 16.8.1 - [Release notes](https://github.com/graphql/graphql-js/releases) - [Commits](https://github.com/graphql/graphql-js/compare/v16.6.0...v16.8.1) Updates `graphql` from 16.6.0 to 16.8.1 - [Release notes](https://github.com/graphql/graphql-js/releases) - [Commits](https://github.com/graphql/graphql-js/compare/v16.6.0...v16.8.1) Updates `graphql` from 16.6.0 to 16.8.1 - [Release notes](https://github.com/graphql/graphql-js/releases) - [Commits](https://github.com/graphql/graphql-js/compare/v16.6.0...v16.8.1) --- updated-dependencies: - dependency-name: webpack dependency-version: 5.94.0 dependency-type: direct:development dependency-group: npm_and_yarn - dependency-name: webpack-dev-server dependency-version: 5.2.1 dependency-type: direct:development dependency-group: npm_and_yarn - dependency-name: react-native-mmkv dependency-version: 2.11.0 dependency-type: direct:production dependency-group: npm_and_yarn - dependency-name: webpack dependency-version: 5.94.0 dependency-type: direct:development dependency-group: npm_and_yarn - dependency-name: graphql dependency-version: 16.8.1 dependency-type: direct:production dependency-group: npm_and_yarn - dependency-name: hono dependency-version: 4.9.7 dependency-type: direct:production dependency-group: npm_and_yarn - dependency-name: graphql dependency-version: 16.8.1 dependency-type: direct:production dependency-group: npm_and_yarn - dependency-name: react-native-mmkv dependency-version: 2.11.0 dependency-type: direct:production dependency-group: npm_and_yarn - dependency-name: graphql dependency-version: 16.8.1 dependency-type: direct:production dependency-group: npm_and_yarn - dependency-name: graphql dependency-version: 16.8.1 dependency-type: direct:production dependency-group: npm_and_yarn - dependency-name: graphql dependency-version: 16.8.1 dependency-type: direct:production dependency-group: npm_and_yarn ... Signed-off-by: dependabot[bot] --- apps/extension/package.json | 4 ++-- apps/mobile/package.json | 2 +- apps/web/package.json | 6 +++--- packages/api/package.json | 2 +- packages/uniswap/package.json | 4 ++-- packages/utilities/package.json | 2 +- packages/wallet/package.json | 2 +- 7 files changed, 11 insertions(+), 11 deletions(-) diff --git a/apps/extension/package.json b/apps/extension/package.json index 5a62068364d..ac67f4fe010 100644 --- a/apps/extension/package.json +++ b/apps/extension/package.json @@ -97,9 +97,9 @@ "swc-loader": "0.2.6", "tamagui-loader": "1.125.17", "typescript": "5.3.3", - "webpack": "5.90.0", + "webpack": "5.94.0", "webpack-cli": "5.1.4", - "webpack-dev-server": "4.15.1" + "webpack-dev-server": "5.2.1" }, "private": true, "scripts": { diff --git a/apps/mobile/package.json b/apps/mobile/package.json index b39462fe118..422cbd68341 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -153,7 +153,7 @@ "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.10.1", + "react-native-mmkv": "2.11.0", "react-native-onesignal": "5.2.9", "react-native-pager-view": "6.5.1", "react-native-passkey": "3.1.0", diff --git a/apps/web/package.json b/apps/web/package.json index 264da854f4e..757d51e367f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -141,7 +141,7 @@ "vitest": "3.2.1", "vitest-fetch-mock": "0.4.5", "wait-on": "8.0.2", - "webpack": "5.90.0", + "webpack": "5.94.0", "wrangler": "4.28.0" }, "dependencies": { @@ -213,8 +213,8 @@ "ethers": "5.7.2", "fancy-canvas": "2.1.0", "framer-motion": "10.17.6", - "graphql": "16.6.0", - "hono": "4.8.4", + "graphql": "16.8.1", + "hono": "4.9.7", "html-entities": "2.6.0", "i18next": "23.10.0", "jotai": "1.3.7", diff --git a/packages/api/package.json b/packages/api/package.json index 1f537020b17..40f39ddccc2 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -8,7 +8,7 @@ "@universe/config": "workspace:^", "@universe/sessions": "workspace:^", "expo-secure-store": "14.0.1", - "graphql": "16.6.0", + "graphql": "16.8.1", "react": "18.3.1", "utilities": "workspace:^" }, diff --git a/packages/uniswap/package.json b/packages/uniswap/package.json index f4db7bdb1b3..fa142a1b1fd 100644 --- a/packages/uniswap/package.json +++ b/packages/uniswap/package.json @@ -75,7 +75,7 @@ "expo-haptics": "14.0.1", "expo-web-browser": "14.0.2", "fuse.js": "6.5.3", - "graphql": "16.6.0", + "graphql": "16.8.1", "i18next": "23.10.0", "i18next-resources-to-backend": "1.2.1", "idb-keyval": "6.2.1", @@ -95,7 +95,7 @@ "react-native-fast-image": "8.6.3", "react-native-gesture-handler": "2.22.1", "react-native-localize": "2.2.6", - "react-native-mmkv": "2.10.1", + "react-native-mmkv": "2.11.0", "react-native-reanimated": "3.16.7", "react-native-svg": "15.11.2", "react-native-webview": "13.13.5", diff --git a/packages/utilities/package.json b/packages/utilities/package.json index 94ce33a3b3f..116f59393d3 100644 --- a/packages/utilities/package.json +++ b/packages/utilities/package.json @@ -24,7 +24,7 @@ "@uniswap/sdk-core": "7.7.2", "dayjs": "1.11.7", "expo-localization": "16.0.1", - "graphql": "16.6.0", + "graphql": "16.8.1", "jsbi": "3.2.5", "promise": "8.3.0", "react": "18.3.1", diff --git a/packages/wallet/package.json b/packages/wallet/package.json index 3e0634f44ef..c84d751abf2 100644 --- a/packages/wallet/package.json +++ b/packages/wallet/package.json @@ -36,7 +36,7 @@ "dayjs": "1.11.7", "ethers": "5.7.2", "fuse.js": "6.5.3", - "graphql": "16.6.0", + "graphql": "16.8.1", "i18next": "23.10.0", "jsbi": "3.2.5", "lodash": "4.17.21", From 9ec565f326ffce0392b3d907c2f82dab21facee0 Mon Sep 17 00:00:00 2001 From: AU_gdev_19 <64915515+Dargon789@users.noreply.github.com> Date: Fri, 10 Oct 2025 02:04:35 +0700 Subject: [PATCH 20/34] Create notify vercel.yml Signed-off-by: AU_gdev_19 <64915515+Dargon789@users.noreply.github.com> --- .github/workflows/notify vercel.yml | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 .github/workflows/notify vercel.yml diff --git a/.github/workflows/notify vercel.yml b/.github/workflows/notify vercel.yml new file mode 100644 index 00000000000..ae9288a9d30 --- /dev/null +++ b/.github/workflows/notify vercel.yml @@ -0,0 +1,4 @@ +- name: 'notify vercel' + uses: 'vercel/repository-dispatch/actions/status@v1' + with: + name: Vercel - interface-web: notify vercel From 6fec7e2a8bc47755cbaed7c0c9e6d4621ae30ca9 Mon Sep 17 00:00:00 2001 From: AU_gdev_19 <64915515+Dargon789@users.noreply.github.com> Date: Fri, 10 Oct 2025 03:02:52 +0700 Subject: [PATCH 21/34] Delete .github/workflows/notify vercel.yml Signed-off-by: AU_gdev_19 <64915515+Dargon789@users.noreply.github.com> --- .github/workflows/notify vercel.yml | 4 ---- 1 file changed, 4 deletions(-) delete mode 100644 .github/workflows/notify vercel.yml diff --git a/.github/workflows/notify vercel.yml b/.github/workflows/notify vercel.yml deleted file mode 100644 index ae9288a9d30..00000000000 --- a/.github/workflows/notify vercel.yml +++ /dev/null @@ -1,4 +0,0 @@ -- name: 'notify vercel' - uses: 'vercel/repository-dispatch/actions/status@v1' - with: - name: Vercel - interface-web: notify vercel From 3f6d466632146384b383f9f1ee521ddfcdbecf20 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 20 Oct 2025 19:04:58 +0000 Subject: [PATCH 22/34] build(deps-dev): bump playwright Bumps the npm_and_yarn group with 1 update in the /apps/web directory: [playwright](https://github.com/microsoft/playwright). Updates `playwright` from 1.49.1 to 1.55.1 - [Release notes](https://github.com/microsoft/playwright/releases) - [Commits](https://github.com/microsoft/playwright/compare/v1.49.1...v1.55.1) --- updated-dependencies: - dependency-name: playwright dependency-version: 1.55.1 dependency-type: direct:development dependency-group: npm_and_yarn ... Signed-off-by: dependabot[bot] --- apps/web/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/package.json b/apps/web/package.json index 757d51e367f..4009a64efb4 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -116,7 +116,7 @@ "jest-styled-components": "7.2.0", "lint-staged": "14.0.1", "madge": "6.1.0", - "playwright": "1.49.1", + "playwright": "1.55.1", "postinstall-postinstall": "2.1.0", "process": "0.11.10", "prop-types": "15.8.1", From 8d28ba55d28236c8c6afe8aac85e1392c0925173 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 24 Oct 2025 23:40:39 +0000 Subject: [PATCH 23/34] build(deps): bump hono Bumps the npm_and_yarn group with 1 update in the /apps/web directory: [hono](https://github.com/honojs/hono). Updates `hono` from 4.9.7 to 4.10.3 - [Release notes](https://github.com/honojs/hono/releases) - [Commits](https://github.com/honojs/hono/compare/v4.9.7...v4.10.3) --- updated-dependencies: - dependency-name: hono dependency-version: 4.10.3 dependency-type: direct:production dependency-group: npm_and_yarn ... Signed-off-by: dependabot[bot] --- apps/web/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/package.json b/apps/web/package.json index 4009a64efb4..adf5afbb5bc 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -214,7 +214,7 @@ "fancy-canvas": "2.1.0", "framer-motion": "10.17.6", "graphql": "16.8.1", - "hono": "4.9.7", + "hono": "4.10.3", "html-entities": "2.6.0", "i18next": "23.10.0", "jotai": "1.3.7", From 7f595cd180db8a7eeac4f0a7c14be589439efe1e Mon Sep 17 00:00:00 2001 From: Dargon789 <64915515+Dargon789@users.noreply.github.com> Date: Sun, 26 Oct 2025 15:54:12 +0000 Subject: [PATCH 24/34] Create docker.yml (#87) Add a new GitHub Actions workflow under .circleci/docker.yml to automate Docker image building, pushing, and signing with Buildx, caching, and cosign on scheduled and event-driven triggers New Features: Add Docker GitHub Actions workflow to build and push container images to the registry Integrate cosign to sign published images outside of pull requests Enhancements: Use Docker Buildx for multi-platform builds with GitHub Actions cache Extract and apply Docker metadata (tags and labels) via docker/metadata-action CI: Trigger the Docker workflow on a daily cron, master branch pushes, pull requests, and semver tag creations Signed-off-by: Dargon789 <64915515+Dargon789@users.noreply.github.com> --- .circleci/docker.yml | 100 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 .circleci/docker.yml diff --git a/.circleci/docker.yml b/.circleci/docker.yml new file mode 100644 index 00000000000..e994f94e708 --- /dev/null +++ b/.circleci/docker.yml @@ -0,0 +1,100 @@ +name: Docker + +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. + +on: + schedule: + - cron: '21 12 * * *' + push: + branches: [ "master" ] + # Publish semver tags as releases. + tags: [ 'v*.*.*' ] + pull_request: + branches: [ "master" ] + +env: + # Use docker.io for Docker Hub if empty + REGISTRY: ghcr.io + # github.repository as / + IMAGE_NAME: ${{ github.repository }} + + +jobs: + build: + - name: Build the Docker image + run: docker build . --file path/to/Dockerfile --tag my-image-name:$(date +%s) + + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + # This is used to complete the identity challenge + # with sigstore/fulcio when running outside of PRs. + id-token: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + # Install the cosign tool except on PR + # https://github.com/sigstore/cosign-installer + - name: Install cosign + if: github.event_name != 'pull_request' + uses: sigstore/cosign-installer@59acb6260d9c0ba8f4a2f9d9b48431a222b68e20 #v3.5.0 + with: + cosign-release: 'v2.2.4' + + # Set up BuildKit Docker container builder to be able to build + # multi-platform images and export cache + # https://github.com/docker/setup-buildx-action + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0 + + # Login against a Docker registry except on PR + # https://github.com/docker/login-action + - name: Log into registry ${{ env.REGISTRY }} + if: github.event_name != 'pull_request' + uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d # v3.0.0 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # Extract metadata (tags, labels) for Docker + # https://github.com/docker/metadata-action + - name: Extract Docker metadata + id: meta + uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + + # Build and push Docker image with Buildx (don't push on PR) + # https://github.com/docker/build-push-action + - name: Build and push Docker image + id: build-and-push + uses: docker/build-push-action@v5.0.0 + with: + context: ./ + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + # Sign the resulting Docker image digest except on PRs. + # This will only write to the public Rekor transparency log when the Docker + # repository is public to avoid leaking data. If you would like to publish + # transparency data even for private images, pass --force to cosign below. + # https://github.com/sigstore/cosign + - name: Sign the published Docker image + if: ${{ github.event_name != 'pull_request' }} + env: + # https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-an-intermediate-environment-variable + TAGS: ${{ steps.meta.outputs.tags }} + DIGEST: ${{ steps.build-and-push.outputs.digest }} + # This step uses the identity token to provision an ephemeral certificate + # against the sigstore community Fulcio instance. + run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST} From 557b3bd02c5ccc6df9fe4c93e80447ac3879ee88 Mon Sep 17 00:00:00 2001 From: Uniswap Labs Service Account Date: Tue, 28 Oct 2025 19:55:37 +0000 Subject: [PATCH 25/34] ci(release): publish latest release --- .gitignore | 7 +- RELEASE | 52 +- VERSION | 2 +- apps/extension/package.json | 1 + apps/extension/src/app/apollo.tsx | 10 +- .../src/app/components/AutoLockProvider.tsx | 3 +- .../src/app/core/StatsigProvider.tsx | 3 +- .../app/core/initStatSigForBrowserScripts.tsx | 3 +- .../useShouldShowBiometricUnlock.ts | 3 +- .../useShouldShowBiometricUnlockEnrollment.ts | 3 +- .../SignTypedDataRequestContent.tsx | 3 +- .../src/app/features/dappRequests/saga.ts | 3 +- .../src/app/features/home/HomeScreen.tsx | 3 +- .../features/home/PortfolioActionButtons.tsx | 3 +- .../src/app/features/home/PortfolioHeader.tsx | 2 +- .../onboarding/import/SelectWallets.tsx | 3 +- .../onboarding/scan/ScanToOnboard.tsx | 2 +- .../app/features/settings/SettingsScreen.tsx | 3 +- .../useIsExtensionPasskeyImportEnabled.ts | 3 +- .../src/entrypoints/sidepanel/main.tsx | 3 + apps/extension/tsconfig.json | 3 + .../deeplinks/deeplink-comprehensive.yaml | 128 +- .../flows/restore/restore-new-device.yaml | 21 +- .../shared-flows/navigate-to-explore.yaml | 23 +- apps/mobile/package.json | 1 + apps/mobile/src/app/App.tsx | 25 +- .../app/MobileWalletNavigationProvider.tsx | 3 +- .../app/modals/BridgedAssetWarningWrapper.tsx | 3 +- apps/mobile/src/app/navigation/navigation.tsx | 3 +- .../tabs/CustomTabBar/CustomTabBar.tsx | 1 + .../navigation/tabs/SwapLongPressModal.tsx | 3 +- .../PriceExplorer/PriceExplorer.tsx | 19 +- .../src/components/PriceExplorer/Text.tsx | 43 +- .../__snapshots__/Text.test.tsx.snap | 9 +- .../components/PriceExplorer/useFiatDelta.tsx | 116 ++ .../src/components/PriceExplorer/usePrice.tsx | 2 +- .../PriceExplorer/usePriceHistory.ts | 2 +- .../ModalWithOverlay/ModalWithOverlay.tsx | 3 +- .../WalletConnectRequestModal.tsx | 3 +- .../Requests/ScanSheet/WalletConnectModal.tsx | 3 +- .../Requests/ScanSheet/util.test.ts | 84 ++ .../src/components/Requests/ScanSheet/util.ts | 29 +- .../src/components/Requests/Uwulink/utils.ts | 14 +- .../TokenDetailsBridgedAssetSection.tsx | 4 +- .../src/components/accounts/AccountHeader.tsx | 5 +- .../components/activity/ActivityContent.tsx | 11 +- .../src/components/carousel/Carousel.tsx | 4 +- .../ExploreSections/ExploreSections.tsx | 3 +- .../search/ExploreScreenSearchResultsList.tsx | 3 +- .../src/components/home/HomeExploreTab.tsx | 3 +- apps/mobile/src/components/home/TokensTab.tsx | 3 +- apps/mobile/src/components/home/hooks.tsx | 17 +- .../home/introCards/FundWalletModal.tsx | 3 +- .../introCards/OnboardingIntroCardStack.tsx | 3 +- .../src/components/layout/TabHelpers.tsx | 3 +- .../components/loading/parts/WaveLoader.tsx | 1 - .../datadog/DatadogProviderWrapper.tsx | 10 +- .../src/features/deepLinking/configUtils.ts | 4 +- .../src/features/deepLinking/deepLinkUtils.ts | 2 +- .../deepLinking/handleDeepLinkSaga.test.ts | 6 +- .../deepLinking/handleDeepLinkSaga.ts | 3 +- .../deepLinking/handleOnRampReturnLinkSaga.ts | 3 +- .../deepLinking/handleTransactionLinkSaga.ts | 3 +- .../features/lockScreen/LockScreenModal.tsx | 3 +- .../src/features/send/SendFormButton.tsx | 3 +- .../src/features/wallet/useWalletRestore.ts | 3 +- .../walletConnect/batchedTransactionSaga.ts | 3 +- .../mobile/src/features/walletConnect/saga.ts | 3 +- apps/mobile/src/screens/ActivityScreen.tsx | 11 +- apps/mobile/src/screens/AppLoadingScreen.tsx | 3 +- apps/mobile/src/screens/ExploreScreen.tsx | 5 +- .../src/screens/HomeScreen/HomeScreen.tsx | 3 +- .../HomeScreen/HomeScreenQuickActions.tsx | 3 +- .../src/screens/Import/ImportMethodScreen.tsx | 3 +- .../screens/Import/OnDeviceRecoveryScreen.tsx | 3 +- .../screens/Import/RestoreMethodScreen.tsx | 3 +- .../src/screens/Import/SelectWalletScreen.tsx | 3 +- .../src/screens/Onboarding/LandingScreen.tsx | 5 +- apps/mobile/src/screens/SettingsScreen.tsx | 3 +- .../mobile/src/screens/TokenDetailsScreen.tsx | 9 +- .../ViewPrivateKeys/ViewPrivateKeysScreen.tsx | 3 +- apps/mobile/tsconfig.json | 3 + apps/web/.eslintrc.js | 26 + apps/web/package.json | 3 +- apps/web/project.json | 4 +- .../images/portfolio_page_promo/dark.png | Bin 0 -> 232460 bytes .../images/portfolio_page_promo/light.png | Bin 0 -> 279063 bytes apps/web/public/pools-sitemap.xml | 20 + apps/web/public/tokens-sitemap.xml | 90 ++ apps/web/src/appGraphql/data/util.tsx | 6 + .../images/portfolio-page-promo/dark.svg | 778 +++++++++++ .../images/portfolio-page-promo/light.svg | 778 +++++++++++ apps/web/src/assets/svg/Emblem/A.svg | 4 + apps/web/src/assets/svg/Emblem/B.svg | 3 + apps/web/src/assets/svg/Emblem/C.svg | 18 + apps/web/src/assets/svg/Emblem/D.svg | 4 + apps/web/src/assets/svg/Emblem/E.svg | 3 + apps/web/src/assets/svg/Emblem/F.svg | 3 + apps/web/src/assets/svg/Emblem/G.svg | 3 + apps/web/src/assets/svg/Emblem/default.svg | 3 + .../AccountDrawer/AuthenticatedHeader.tsx | 3 +- .../AccountDrawer/DisconnectButton.tsx | 3 +- .../Activity/ActivityTab.anvil.e2e.test.ts | 1 + .../MiniPortfolio/ExtensionDeeplinks.tsx | 2 +- .../MiniPortfolio/Pools/PoolsTab.tsx | 2 +- .../MiniPortfolio/PortfolioRow.tsx | 60 +- .../ActivityTable/ActivityAddressCell.tsx | 27 + .../ActivityTable/ActivityAmountCell.tsx | 255 ++++ .../ActivityTable/ActivityTable.tsx | 123 ++ .../ActivityTable/AddressWithAvatar.tsx | 42 + .../src/components/ActivityTable/TimeCell.tsx | 15 + .../ActivityTable/TokenAmountDisplay.tsx | 32 + .../ActivityTable/TransactionTypeCell.tsx | 30 + .../ActivityTable/activityTableModels.ts | 61 + .../src/components/ActivityTable/registry.ts | 246 ++++ .../BridgingPopularTokensBanner.tsx | 108 ++ .../src/components/Banner/shared/Banners.tsx | 13 +- .../D3LiquidityRangeChart.tsx | 10 +- .../components/D3LiquidityMinMaxInput.tsx | 17 +- .../D3LiquidityRangeChart/store/types.ts | 2 +- .../D3LiquidityRangeChart/utils/tickUtils.ts | 2 +- .../D3LiquidityRangeInput.tsx | 7 +- .../Charts/LiquidityChart/index.tsx | 2 +- .../LiquidityPositionRangeChart.tsx | 2 +- .../LiquidityRangeInput.tsx | 2 +- .../Charts/LiquidityRangeInput/hooks.ts | 2 +- .../components/Charts/PriceChart/index.tsx | 66 +- apps/web/src/components/Expand/index.tsx | 59 +- .../FeatureFlagModal/FeatureFlagModal.tsx | 24 +- .../src/components/HelpModal/HelpContent.tsx | 3 +- .../src/components/HelpModal/HelpModal.tsx | 26 +- .../components/Liquidity/ClaimFeeModal.tsx | 2 +- .../components/Liquidity/Create/AddHook.tsx | 2 +- .../components/Liquidity/Create/EditStep.tsx | 2 +- .../Liquidity/Create/FormWrapper.tsx | 2 +- .../Create/PositionOutOfRangeError.tsx | 2 +- .../Liquidity/Create/RangeSelectionStep.tsx | 6 +- .../Liquidity/Create/SelectTokenStep.tsx | 16 +- .../Create/hooks/useDepositInfo.test.ts | 6 +- .../Liquidity/Create/hooks/useDepositInfo.tsx | 2 +- .../hooks/useDerivedPositionInfo.test.ts | 8 +- .../Create/hooks/useDerivedPositionInfo.tsx | 5 +- .../Create/hooks/useLPSlippageValues.test.ts | 6 +- .../Create/hooks/useLPSlippageValues.ts | 5 +- ...seNativeTokenPercentageBufferExperiment.ts | 3 +- .../src/components/Liquidity/Create/types.ts | 14 +- apps/web/src/components/Liquidity/Deposit.tsx | 2 +- .../Liquidity/DisplayCurrentPrice.tsx | 3 +- .../Liquidity/FeeTierSearchModal.tsx | 3 +- .../Liquidity/LiquidityPositionCard.tsx | 5 +- .../Liquidity/LiquidityPositionFeeStats.tsx | 2 +- .../Liquidity/LiquidityPositionInfo.test.tsx | 2 +- .../Liquidity/LiquidityPositionInfo.tsx | 2 +- .../LiquidityPositionInfoBadges.test.tsx | 2 +- .../Liquidity/LiquidityPositionInfoBadges.tsx | 2 +- .../LiquidityPositionStatusIndicator.test.tsx | 2 +- .../LiquidityPositionStatusIndicator.tsx | 2 +- .../Liquidity/PositionPageActionButtons.tsx | 2 +- .../components/Liquidity/PositionsHeader.tsx | 2 +- .../src/components/Liquidity/TokenInfo.tsx | 2 +- .../web/src/components/Liquidity/analytics.ts | 2 +- .../web/src/components/Liquidity/constants.ts | 2 +- .../hooks/useAllFeeTierPoolData.test.tsx | 2 +- .../Liquidity/hooks/useAllFeeTierPoolData.ts | 2 +- apps/web/src/components/Liquidity/types.ts | 2 +- .../Liquidity/utils/currency.test.ts | 2 +- .../components/Liquidity/utils/currency.ts | 2 +- .../Liquidity/utils/feeTiers.test.ts | 2 +- .../components/Liquidity/utils/feeTiers.ts | 2 +- ...etPoolIdOrAddressFromCreatePositionInfo.ts | 2 +- .../Liquidity/utils/getPositionUrl.test.ts | 2 +- .../Liquidity/utils/getPositionUrl.ts | 2 +- .../utils/hasLPFoTTransferError.test.ts | 2 +- .../Liquidity/utils/hasLPFoTTransferError.ts | 2 +- .../Liquidity/utils/parseFromRest.test.ts | 2 +- .../Liquidity/utils/parseFromRest.ts | 2 +- .../Liquidity/utils/priceRangeInfo.test.ts | 2 +- .../Liquidity/utils/priceRangeInfo.ts | 2 +- .../Liquidity/utils/protocolVersion.test.ts | 2 +- .../Liquidity/utils/protocolVersion.ts | 2 +- .../NavBar/CompanyMenu/MenuDropdown.tsx | 27 +- .../NavBar/CompanyMenu/MobileMenuDrawer.tsx | 22 +- .../components/NavBar/CompanyMenu/index.tsx | 2 +- .../NavBar/DownloadApp/Modal/GetStarted.tsx | 3 +- .../NavBar/DownloadApp/Modal/index.tsx | 3 +- .../DownloadApp/NewUserCTAButton.test.tsx | 11 +- .../NavBar/DownloadApp/NewUserCTAButton.tsx | 3 +- .../NavBar/LegalAndPrivacyMenu/index.tsx | 6 +- .../NavBar/NavDropdown/NavDropdown.tsx | 7 +- .../NavBar/SearchBar/SearchModal.tsx | 3 +- .../src/components/NavBar/SearchBar/index.tsx | 3 +- .../NavBar/SearchBar/useIsSearchBarVisible.ts | 3 +- .../components/NavBar/Tabs/TabsContent.tsx | 3 +- apps/web/src/components/NavBar/index.tsx | 3 +- .../Pools/PoolDetails/ChartSection/index.tsx | 4 +- .../Pools/PoolDetails/PoolDetailsHeader.tsx | 3 +- .../PoolDetails/PoolDetailsStatsButtons.tsx | 2 +- .../components/Pools/PoolTable/PoolTable.tsx | 3 +- apps/web/src/components/Popups/PopupItem.tsx | 1 + .../components/Popups/ToastRegularSimple.tsx | 6 +- .../src/components/StatusIcon/index.test.tsx | 11 +- apps/web/src/components/Table/index.tsx | 72 +- .../Tokens/TokenDetails/BalanceSummary.tsx | 7 +- .../TokenDetails/BridgedAssetSection.tsx | 3 +- .../components/Tokens/TokenDetails/Delta.tsx | 26 +- .../__snapshots__/Delta.test.tsx.snap | 10 +- .../components/Tokens/TokenDetails/index.tsx | 2 +- .../WalletModal/DownloadWalletOption.tsx | 3 +- .../WalletModal/UniswapWalletOptions.test.tsx | 30 +- .../WalletModal/WalletConnectorOption.tsx | 3 +- .../UniswapWalletOptions.test.tsx.snap | 4 +- apps/web/src/components/WalletModal/index.tsx | 3 +- .../Web3Provider/WebUniswapContext.tsx | 3 +- .../Web3Provider/rejectableConnector.ts | 52 + .../Web3Provider/wagmiAutoConnect.ts | 4 +- .../components/Web3Provider/wagmiConfig.ts | 5 +- .../Web3Status/RecentlyConnectedModal.tsx | 3 +- apps/web/src/components/Web3Status/index.tsx | 3 +- apps/web/src/dev/DevFlagsBox.tsx | 5 +- .../src/featureFlags/flags/outageBanner.ts | 3 +- .../useFeatureFlagUrlOverrides.tsx | 3 +- .../features/accounts/store/provider.test.tsx | 8 +- .../src/features/accounts/store/provider.tsx | 3 +- .../src/features/accounts/store/updater.tsx | 3 +- .../connection/connectors/solana.test.ts | 3 +- .../hooks/useOrderedWalletConnectors.test.ts | 14 +- .../hooks/useOrderedWalletConnectors.ts | 3 +- .../providers/ExternalWalletProvider.tsx | 3 +- apps/web/src/hooks/useConfirmModalState.ts | 20 +- .../hooks/useIsUniswapExtensionConnected.ts | 3 +- .../src/hooks/useIsUniswapXSupportedChain.ts | 3 +- .../hooks/useLpIncentivesFormattedEarnings.ts | 5 +- apps/web/src/hooks/usePoolTickData.ts | 2 +- apps/web/src/hooks/usePositionTokenURI.ts | 2 +- apps/web/src/index.tsx | 5 +- .../hooks/routing/useRoutingAPIArguments.ts | 3 +- apps/web/src/lib/utils/analytics.ts | 8 +- .../pages/App/WalletConnection.e2e.test.ts | 2 +- .../CreateLiquidityContextProvider.tsx | 5 +- .../CreatePosition.anvil.e2e.test.ts | 7 +- .../CreatePosition/CreatePosition.e2e.test.ts | 8 +- .../pages/CreatePosition/CreatePosition.tsx | 2 +- .../CreatePosition/CreatePositionModal.tsx | 2 +- .../CreatePositionTxContext.test.ts | 2 +- .../CreatePositionTxContext.tsx | 2 +- apps/web/src/pages/Errors.anvil.e2e.test.ts | 18 +- apps/web/src/pages/Explore/ProtocolFilter.tsx | 2 +- apps/web/src/pages/Explore/redirects.tsx | 3 +- .../useExternallyConnectableExtensionId.ts | 3 +- .../IncreaseLiquidity.anvil.e2e.test.ts | 2 +- .../IncreaseLiquidityForm.tsx | 2 +- .../IncreaseLiquidityReview.tsx | 2 +- .../IncreaseLiquidityTxContext.tsx | 2 +- .../hooks/useDerivedIncreaseLiquidityInfo.ts | 2 +- .../web/src/pages/Landing/sections/Footer.tsx | 6 +- apps/web/src/pages/Landing/sections/Hero.tsx | 2 +- apps/web/src/pages/LegacyPool/redirects.tsx | 2 +- .../web/src/pages/MigrateV2/MigrateV2Pair.tsx | 2 +- .../MigrateV3/MigrateV3.anvil.e2e.test.ts | 2 +- .../src/pages/MigrateV3/MigrateV3.e2e.test.ts | 2 +- .../MigrateV3/MigrateV3LiquidityTxContext.tsx | 2 +- .../MigrateV3/hooks/useInitialPosition.ts | 2 +- apps/web/src/pages/MigrateV3/index.tsx | 2 +- apps/web/src/pages/PoolDetails/index.tsx | 3 +- .../src/pages/Portfolio/Activity/Activity.tsx | 137 +- .../pages/Portfolio/Activity/Filters/utils.ts | 104 +- .../pages/Portfolio/ConnectWalletBanner.tsx | 124 ++ .../Portfolio/ConnectWalletBottomOverlay.tsx | 47 + .../src/pages/Portfolio/ConnectWalletView.tsx | 41 - .../web/src/pages/Portfolio/Header/Header.tsx | 30 +- .../ConnectedAddressDisplay.tsx | 39 + .../DemoAddressDisplay.tsx | 37 + .../PortfolioAddressDisplay.tsx | 9 + .../Portfolio/Header/hooks/useIsConnected.ts | 7 + apps/web/src/pages/Portfolio/NFTs/NFTCard.tsx | 144 ++ apps/web/src/pages/Portfolio/NFTs/Nfts.tsx | 75 + .../Portfolio/NFTs/utils/filterNfts.test.ts | 257 ++++ .../pages/Portfolio/NFTs/utils/filterNfts.ts | 32 + apps/web/src/pages/Portfolio/Nfts.tsx | 30 - apps/web/src/pages/Portfolio/Portfolio.tsx | 101 +- .../src/pages/Portfolio/PortfolioContent.tsx | 42 + .../Portfolio/PortfolioDisconnectedView.tsx | 105 ++ .../Tokens/Table/TokensContextMenuWrapper.tsx | 4 +- .../Portfolio/Tokens/Table/TokensTable.tsx | 56 + .../Table/{Table.tsx => TokensTableInner.tsx} | 73 +- .../Tokens/Table/columns/Balance.tsx | 2 +- .../web/src/pages/Portfolio/Tokens/Tokens.tsx | 121 +- .../hooks/useTransformTokenTableData.ts | 74 +- .../Tokens/utils/filterTokensBySearch.test.ts | 280 ++++ .../Tokens/utils/filterTokensBySearch.ts | 28 + .../Portfolio/hooks/usePortfolioAddress.ts | 13 + .../Positions/ClaimFees.anvil.e2e.test.ts | 2 +- apps/web/src/pages/Positions/PositionPage.tsx | 5 +- apps/web/src/pages/Positions/TopPools.tsx | 3 +- .../src/pages/Positions/V2PositionPage.tsx | 5 +- apps/web/src/pages/Positions/index.tsx | 5 +- .../RemoveLiquidity.anvil.e2e.test.ts | 2 +- .../RemoveLiquidityModalContext.tsx | 2 +- .../RemoveLiquidity/RemoveLiquidityReview.tsx | 2 +- .../hooks/useRemoveLiquidityTxAndGasInfo.ts | 2 +- apps/web/src/pages/RouteDefinitions.tsx | 3 +- apps/web/src/pages/Swap/Buy/Buy.e2e.test.ts | 3 + apps/web/src/pages/Swap/Buy/hooks.ts | 3 +- .../web/src/pages/Swap/Fees.anvil.e2e.test.ts | 1 + apps/web/src/pages/Swap/Fees.e2e.test.ts | 4 +- apps/web/src/pages/Swap/Limit/LimitForm.tsx | 3 +- .../src/pages/Swap/Logging.anvil.e2e.test.ts | 1 + .../Swap/Send/NewAddressSpeedBump.test.tsx | 3 +- apps/web/src/pages/Swap/Send/SendForm.tsx | 3 +- .../web/src/pages/Swap/Swap.anvil.e2e.test.ts | 22 +- apps/web/src/pages/Swap/index.tsx | 46 +- .../pages/Swap/settings/useWebSwapSettings.ts | 3 +- .../web/src/playwright/anvil/anvil-manager.ts | 4 +- apps/web/src/playwright/fixtures/anvil.ts | 13 +- .../web/src/playwright/fixtures/tradingApi.ts | 12 +- apps/web/src/setupTests.ts | 7 +- .../state/activity/polling/transactions.ts | 3 +- .../src/state/explore/protocolStats.test.tsx | 9 +- apps/web/src/state/explore/topPools.ts | 2 +- apps/web/src/state/limit/hooks.ts | 3 +- .../state/routing/useRoutingAPITrade.test.ts | 3 +- .../state/sagas/liquidity/liquiditySaga.ts | 3 +- apps/web/src/state/sagas/root.ts | 2 + apps/web/src/state/sagas/transactions/5792.ts | 4 +- .../src/state/sagas/transactions/solana.ts | 72 +- .../src/state/sagas/transactions/swapSaga.ts | 104 +- .../src/state/sagas/transactions/uniswapx.ts | 2 +- .../web/src/state/sagas/transactions/utils.ts | 45 +- .../src/state/sagas/transactions/wrapSaga.ts | 4 +- apps/web/src/state/swap/hooks.test.tsx | 114 +- apps/web/src/state/swap/hooks.tsx | 25 +- apps/web/src/state/transactions/types.ts | 6 +- .../hooks/useMismatchAccount.ts | 3 +- .../hooks/useWalletGetCapabilitiesMutation.ts | 3 +- .../src/utils/computeSurroundingTicks.test.ts | 2 +- apps/web/src/utils/computeSurroundingTicks.ts | 2 +- apps/web/tsconfig.json | 3 + apps/web/vite.config.mts | 2 +- bun.lock | 300 ++-- config/jest-presets/jest/jest-preset.js | 2 +- config/jest-presets/jest/setup.js | 54 +- config/vitest-presets/vitest/vitest-preset.js | 1 - dangerfile.ts | 2 +- nx.json | 30 +- package.json | 41 +- packages/api/package.json | 3 +- .../api/src/clients/graphql/queries.graphql | 6 + .../api/src/clients/graphql/schema.graphql | 59 +- .../createNotificationsApiClient.ts | 49 + .../api/src/clients/notifications/types.ts | 38 + .../api/src/clients/trading/tradeTypes.ts | 4 +- packages/api/src/connectRpc/utils.ts | 2 +- packages/api/src/index.ts | 12 +- packages/biome-config/base.jsonc | 12 +- packages/gating/.eslintrc.js | 46 + packages/gating/README.md | 3 + packages/gating/package.json | 31 + packages/gating/project.json | 16 + .../src}/LocalOverrideAdapterWrapper.ts | 2 +- .../features/gating => gating/src}/configs.ts | 61 +- .../gating => gating/src}/constants.ts | 0 .../gating => gating/src}/experiments.ts | 10 + .../features/gating => gating/src}/flags.ts | 2 + .../src}/getStatsigEnvName.ts | 0 .../features/gating => gating/src}/hooks.ts | 10 +- packages/gating/src/index.ts | 86 ++ .../src}/sdk/statsig.native.ts | 6 +- .../gating => gating/src}/sdk/statsig.ts | 2 +- .../features/gating => gating/src}/utils.ts | 2 +- packages/gating/tsconfig.json | 19 + packages/gating/tsconfig.lint.json | 8 + packages/notifications/.eslintrc.js | 45 + packages/notifications/README.md | 73 + packages/notifications/package.json | 18 + packages/notifications/project.json | 16 + packages/notifications/src/index.ts | 0 packages/notifications/tsconfig.json | 12 + packages/notifications/tsconfig.lint.json | 8 + .../graphics/bridged-assets-v2-web-banner.png | Bin 0 -> 128449 bytes packages/ui/src/assets/index.ts | 1 + .../components/swipeablecards/BaseCard.tsx | 2 +- .../src/components/switch/Switch.native.tsx | 2 +- packages/ui/src/loading/Shine.native.tsx | 2 +- packages/ui/src/loading/Skeleton.native.tsx | 1 - .../ui/src/loading/SpinningLoader.native.tsx | 2 +- packages/uniswap/jest-package-mocks.js | 6 +- packages/uniswap/package.json | 10 +- .../BridgedAsset/BridgedAssetModal.tsx | 10 +- .../components/BridgedAsset/WormholeModal.tsx | 17 +- .../src/components/BridgedAsset/utils.ts | 23 - .../AmountInputPresets/AmountInputPresets.tsx | 37 +- .../AmountInputPresets/types.ts | 13 +- .../CurrencyInputPanel/CurrencyInputPanel.tsx | 31 +- .../CurrencyInputPanelHeader.tsx | 31 +- .../TokenSelector/TokenSelector.tsx | 3 +- .../TokenSelector/TokenSelectorList.tsx | 3 +- .../components/TokenSelector/hooks.test.ts | 31 +- .../details/TransactionDetailsModal.test.tsx | 3 +- .../SwapTransactionDetails.test.tsx | 3 +- .../TransferTransactionDetails.test.tsx | 3 +- .../gating/DynamicConfigDropdown.tsx | 4 +- .../src/components/gating/GatingOverrides.tsx | 23 +- .../uniswap/src/components/gating/Rows.tsx | 3 +- .../gating/dynamicConfigOverrides.tsx | 2 +- .../lists/items/pools/PoolOptionItem.tsx | 2 +- .../items/pools/PoolOptionItemContextMenu.tsx | 2 +- .../usePoolSearchResultsToPoolOptions.tsx | 2 +- .../items/pools/usePoolStatsToPoolOptions.tsx | 2 +- .../src/components/lists/items/types.ts | 2 +- .../uniswap/src/components/nfts/NftView.tsx | 6 +- .../uniswap/src/components/nfts/NftsList.tsx | 7 +- .../src/components/nfts/NftsList.web.tsx | 66 +- .../NotificationToast.native.tsx | 4 +- .../apiClients/tradingApi/TradingApiClient.ts | 3 +- .../apiClients/uniswapApi/useGasFeeQuery.ts | 2 +- .../conversionTracking/useConversionProxy.ts | 3 +- .../useConversionTracking.ts | 3 +- packages/uniswap/src/data/rest/getPair.ts | 19 - packages/uniswap/src/data/rest/getPools.ts | 4 +- .../uniswap/src/data/rest/getPoolsRewards.ts | 4 +- .../uniswap/src/data/rest/getPortfolio.ts | 5 +- packages/uniswap/src/data/rest/getPosition.ts | 4 +- .../uniswap/src/data/rest/getPositions.ts | 6 +- .../data/rest/portfolioBalanceOverrides.ts | 21 +- .../src/data/rest/searchTokensAndPools.ts | 61 +- .../activity/formatTransactionsByDate.ts | 21 +- .../src/features/behaviorHistory/slice.ts | 6 + .../src/features/chains/evm/info/avalanche.ts | 2 +- .../src/features/chains/evm/info/celo.ts | 2 +- .../src/features/chains/evm/info/mainnet.ts | 2 +- .../src/features/chains/evm/info/monad.ts | 2 +- .../src/features/chains/evm/info/polygon.ts | 2 +- .../src/features/chains/gasDefaults.ts | 2 +- .../chains/hooks/useFeatureFlaggedChainIds.ts | 3 +- .../features/chains/hooks/useNewChainIds.ts | 3 +- .../chains/hooks/useOrderedChainIds.ts | 3 +- packages/uniswap/src/features/chains/types.ts | 2 +- .../src/features/dataApi/balances/balances.ts | 31 +- .../features/dataApi/balances/balancesRest.ts | 3 +- .../tokenProjects/tokenProjects.test.tsx | 14 +- .../utils/tokenProjectToCurrencyInfos.test.ts | 2 + .../utils/tokenProjectToCurrencyInfos.ts | 5 +- .../uniswap/src/features/dataApi/types.ts | 5 + .../utils/gqlTokenToCurrencyInfo.test.ts | 2 + .../dataApi/utils/gqlTokenToCurrencyInfo.ts | 5 +- .../hooks/useFormatChartFiatDelta.ts | 31 + .../priceChart/formatters/shared/types.ts | 37 + .../priceChart/formatters/shared/utils.ts | 93 ++ .../formatters/stablecoinFormatter.ts | 84 ++ .../formatters/standardFormatter.ts | 88 ++ .../priceChart/priceChartConversion.test.ts | 1204 +++++++++++++++++ .../priceChart/priceChartConversion.ts | 42 + .../hooks/useForceUpgradeStatus.ts | 3 +- .../hooks/useForceUpgradeTranslations.ts | 8 +- packages/uniswap/src/features/gas/hooks.ts | 3 +- .../gas/hooks/useMaxAmountSpend.test.ts | 3 +- .../features/gas/hooks/useMaxAmountSpend.ts | 3 +- packages/uniswap/src/features/gas/utils.ts | 8 +- .../gating/StatsigProviderWrapper.tsx | 8 +- .../src/features/gating/statsigBaseConfig.ts | 3 +- .../uniswap/src/features/gating/typeGuards.ts | 2 +- .../uniswap/src/features/language/hooks.tsx | 2 +- .../nfts/hooks/useNftContextMenuItems.tsx | 3 +- .../passkey/hooks/useEmbeddedWalletBaseUrl.ts | 3 +- .../PortfolioBalance/PortfolioBalance.tsx | 35 +- .../uniswap/src/features/portfolio/api.ts | 3 +- .../isInstantTokenBalanceUpdateEnabled.ts | 3 +- .../rest/fetchOnChainBalancesRest.test.ts | 66 +- .../rest/fetchOnChainBalancesRest.ts | 87 +- ...estQueriesViaOnchainOverrideVariantSaga.ts | 16 +- .../src/features/providers/rpcUrlSelector.ts | 3 +- .../features/search/SearchHistoryResult.ts | 2 +- .../search/SearchModal/analytics/analytics.ts | 2 +- .../SearchModal/hooks/useFilterCallbacks.ts | 86 +- .../src/features/settings/hooks.test.ts | 3 +- .../smartWallet/mismatch/MismatchContext.tsx | 3 +- .../telemetry/constants/trace/element.ts | 2 + .../telemetry/constants/trace/section.ts | 1 + .../features/telemetry/constants/uniswap.ts | 7 +- .../uniswap/src/features/telemetry/types.ts | 4 + .../utils/logExperimentQualifyingEvent.ts | 9 + .../useBlockaidFeeComparisonAnalytics.ts | 3 +- .../DecimalPadInput/DecimalPad.native.tsx | 2 +- .../TransactionSettingsRow.tsx | 3 +- .../slippage/useSlippageSettings.ts | 2 +- .../transactions/components/settings/types.ts | 2 +- .../hooks/useGetCanSignPermits.ts | 3 +- .../hooks/useGetSwapDelegationAddress.ts | 3 +- .../hooks/usePollingIntervalByChain.ts | 4 +- .../features/transactions/liquidity/types.ts | 2 +- .../src/features/transactions/steps/types.ts | 49 + .../SlippageInfo/SlippageInfoCaption.tsx | 2 +- .../hooks/useSwapFormButtonText.ts | 3 +- .../TradeRoutingPreferenceScreen.tsx | 4 +- .../AnimatedTokenFlip.tsx | 2 +- .../GradientContainer.native.tsx | 2 +- .../GradientContainer.web.tsx | 2 +- .../useReceiptSuccessHandler.ts | 24 +- .../hooks/receiptFetching/utils.ts | 78 ++ .../SwapFormDecimalPad.native.tsx | 31 +- .../swap/hooks/useIsForFiltersEnabled.ts | 3 +- .../hooks/useIsUnichainFlashblocksEnabled.ts | 129 +- .../swap/hooks/useNeedsBridgedAssetWarning.ts | 10 +- .../swap/hooks/usePriceUXEnabled.ts | 3 +- .../transactions/swap/plan/planSaga.ts | 243 ++++ .../swap/plan/planStepTransformer.ts | 62 + .../features/transactions/swap/plan/utils.ts | 35 + .../hooks/useCreateSwapReviewCallbacks.tsx | 3 +- .../services/swapTxAndGasInfoService/hooks.ts | 3 +- .../swapFormStore/hooks/useDerivedSwapInfo.ts | 3 +- .../SwapTxStoreContextProvider.tsx | 3 +- .../hooks/useTransactionRequestInfo.test.ts | 4 +- .../hooks/useTransactionRequestInfo.ts | 4 +- .../features/transactions/swap/types/trade.ts | 1 + .../swap/utils/getIsWebForNudgeEnabled.ts | 3 +- .../transactions/swap/utils/protocols.test.ts | 6 +- .../transactions/swap/utils/protocols.ts | 4 +- .../transactions/swap/utils/routing.ts | 12 +- .../features/transactions/swap/utils/trade.ts | 10 + .../swap/utils/tradingApi.test.ts | 5 +- .../transactions/swap/utils/tradingApi.ts | 3 +- .../features/unitags/ClaimUnitagContent.tsx | 18 +- .../src/i18n/locales/source/en-US.json | 26 +- .../src/i18n/locales/translations/af-ZA.json | 3 +- .../src/i18n/locales/translations/ar-SA.json | 3 +- .../src/i18n/locales/translations/ca-ES.json | 3 +- .../src/i18n/locales/translations/da-DK.json | 3 +- .../src/i18n/locales/translations/el-GR.json | 3 +- .../src/i18n/locales/translations/es-ES.json | 28 +- .../src/i18n/locales/translations/fi-FI.json | 3 +- .../src/i18n/locales/translations/fil-PH.json | 28 +- .../src/i18n/locales/translations/fr-FR.json | 28 +- .../src/i18n/locales/translations/he-IL.json | 3 +- .../src/i18n/locales/translations/hi-IN.json | 3 +- .../src/i18n/locales/translations/hu-HU.json | 3 +- .../src/i18n/locales/translations/id-ID.json | 28 +- .../src/i18n/locales/translations/it-IT.json | 3 +- .../src/i18n/locales/translations/ja-JP.json | 28 +- .../src/i18n/locales/translations/ko-KR.json | 28 +- .../src/i18n/locales/translations/ms-MY.json | 3 +- .../src/i18n/locales/translations/nl-NL.json | 28 +- .../src/i18n/locales/translations/pl-PL.json | 3 +- .../src/i18n/locales/translations/pt-PT.json | 28 +- .../src/i18n/locales/translations/ru-RU.json | 28 +- .../src/i18n/locales/translations/sl-SI.json | 3 +- .../src/i18n/locales/translations/sr-SP.json | 3 +- .../src/i18n/locales/translations/sv-SE.json | 3 +- .../src/i18n/locales/translations/sw-TZ.json | 3 +- .../src/i18n/locales/translations/tr-TR.json | 28 +- .../src/i18n/locales/translations/uk-UA.json | 3 +- .../src/i18n/locales/translations/ur-PK.json | 3 +- .../src/i18n/locales/translations/vi-VN.json | 28 +- .../src/i18n/locales/translations/zh-CN.json | 28 +- .../src/i18n/locales/translations/zh-TW.json | 28 +- packages/uniswap/src/state/oldTypes.ts | 2 +- .../src/test/fixtures/gql/assets/tokens.ts | 2 + packages/uniswap/src/test/fixtures/testIDs.ts | 2 + packages/uniswap/src/test/mocks/gql/mocks.ts | 2 + packages/uniswap/src/utils/datadog.web.ts | 17 +- .../search/doesTokenMatchSearchTerm.test.ts | 419 ++++++ .../utils/search/doesTokenMatchSearchTerm.ts | 37 + ...etPossibleChainMatchFromSearchWord.test.ts | 437 ++++++ .../getPossibleChainMatchFromSearchWord.ts | 47 + .../parseChainFromTokenSearchQuery.test.ts | 138 ++ .../search/parseChainFromTokenSearchQuery.ts | 81 ++ packages/uniswap/tsconfig.json | 3 + packages/wallet/package.json | 3 +- .../components/landing/LandingBackground.tsx | 4 +- .../smartWallet/smartAccounts/hooks.ts | 3 +- .../src/features/gating/userPropertyHooks.ts | 2 +- .../smartWallet/hooks/useNetworkBalances.tsx | 2 +- .../contexts/WalletUniswapContext.tsx | 3 +- .../services/featureFlagService.ts | 4 +- .../services/featureFlagServiceImpl.ts | 4 +- .../transactionConfigServiceImpl.test.ts | 2 +- .../services/transactionConfigServiceImpl.ts | 3 +- .../executeTransaction/tryGetNonce.ts | 12 +- .../transactions/send/TokenSelectorPanel.tsx | 1 - .../transactions/swap/confirmation.ts | 3 +- .../transactions/swap/executeSwapSaga.ts | 3 +- .../swap/hooks/useSwapHandlers.ts | 3 +- .../swap/modals/QueuedOrderModal.tsx | 3 +- .../swap/prepareAndSignSwapSaga.test.ts | 3 +- .../swap/prepareAndSignSwapSaga.ts | 3 +- .../swap/settings/SwapProtection.tsx | 2 +- .../transactions/swap/swapSaga.test.ts | 3 +- .../features/transactions/swap/swapSaga.ts | 22 +- .../watcher/transactionFinalizationSaga.ts | 3 +- .../watchOnChainTransactionSaga.test.ts | 3 +- .../watcher/watchOnChainTransactionSaga.ts | 3 +- packages/wallet/tsconfig.json | 3 + scripts/clean.sh | 88 +- scripts/remove-local-packages.sh | 14 + .../src/generators/package/files/biome.json | 6 - .../generators/package/files/tsconfig.json | 2 +- .../src/generators/package/package.ts | 28 +- tsconfig.base.json | 4 +- tsconfig.json | 6 + 598 files changed, 11278 insertions(+), 2162 deletions(-) create mode 100644 apps/mobile/src/components/PriceExplorer/useFiatDelta.tsx create mode 100644 apps/web/public/images/portfolio_page_promo/dark.png create mode 100644 apps/web/public/images/portfolio_page_promo/light.png create mode 100644 apps/web/src/assets/images/portfolio-page-promo/dark.svg create mode 100644 apps/web/src/assets/images/portfolio-page-promo/light.svg create mode 100644 apps/web/src/assets/svg/Emblem/A.svg create mode 100644 apps/web/src/assets/svg/Emblem/B.svg create mode 100644 apps/web/src/assets/svg/Emblem/C.svg create mode 100644 apps/web/src/assets/svg/Emblem/D.svg create mode 100644 apps/web/src/assets/svg/Emblem/E.svg create mode 100644 apps/web/src/assets/svg/Emblem/F.svg create mode 100644 apps/web/src/assets/svg/Emblem/G.svg create mode 100644 apps/web/src/assets/svg/Emblem/default.svg create mode 100644 apps/web/src/components/ActivityTable/ActivityAddressCell.tsx create mode 100644 apps/web/src/components/ActivityTable/ActivityAmountCell.tsx create mode 100644 apps/web/src/components/ActivityTable/ActivityTable.tsx create mode 100644 apps/web/src/components/ActivityTable/AddressWithAvatar.tsx create mode 100644 apps/web/src/components/ActivityTable/TimeCell.tsx create mode 100644 apps/web/src/components/ActivityTable/TokenAmountDisplay.tsx create mode 100644 apps/web/src/components/ActivityTable/TransactionTypeCell.tsx create mode 100644 apps/web/src/components/ActivityTable/activityTableModels.ts create mode 100644 apps/web/src/components/ActivityTable/registry.ts create mode 100644 apps/web/src/components/Banner/BridgingPopularTokens/BridgingPopularTokensBanner.tsx create mode 100644 apps/web/src/components/Web3Provider/rejectableConnector.ts create mode 100644 apps/web/src/pages/Portfolio/ConnectWalletBanner.tsx create mode 100644 apps/web/src/pages/Portfolio/ConnectWalletBottomOverlay.tsx delete mode 100644 apps/web/src/pages/Portfolio/ConnectWalletView.tsx create mode 100644 apps/web/src/pages/Portfolio/Header/PortfolioAddressDisplay/ConnectedAddressDisplay.tsx create mode 100644 apps/web/src/pages/Portfolio/Header/PortfolioAddressDisplay/DemoAddressDisplay.tsx create mode 100644 apps/web/src/pages/Portfolio/Header/PortfolioAddressDisplay/PortfolioAddressDisplay.tsx create mode 100644 apps/web/src/pages/Portfolio/Header/hooks/useIsConnected.ts create mode 100644 apps/web/src/pages/Portfolio/NFTs/NFTCard.tsx create mode 100644 apps/web/src/pages/Portfolio/NFTs/Nfts.tsx create mode 100644 apps/web/src/pages/Portfolio/NFTs/utils/filterNfts.test.ts create mode 100644 apps/web/src/pages/Portfolio/NFTs/utils/filterNfts.ts delete mode 100644 apps/web/src/pages/Portfolio/Nfts.tsx create mode 100644 apps/web/src/pages/Portfolio/PortfolioContent.tsx create mode 100644 apps/web/src/pages/Portfolio/PortfolioDisconnectedView.tsx create mode 100644 apps/web/src/pages/Portfolio/Tokens/Table/TokensTable.tsx rename apps/web/src/pages/Portfolio/Tokens/Table/{Table.tsx => TokensTableInner.tsx} (66%) create mode 100644 apps/web/src/pages/Portfolio/Tokens/utils/filterTokensBySearch.test.ts create mode 100644 apps/web/src/pages/Portfolio/Tokens/utils/filterTokensBySearch.ts create mode 100644 apps/web/src/pages/Portfolio/hooks/usePortfolioAddress.ts create mode 100644 packages/api/src/clients/notifications/createNotificationsApiClient.ts create mode 100644 packages/api/src/clients/notifications/types.ts create mode 100644 packages/gating/.eslintrc.js create mode 100644 packages/gating/README.md create mode 100644 packages/gating/package.json create mode 100644 packages/gating/project.json rename packages/{uniswap/src/features/gating => gating/src}/LocalOverrideAdapterWrapper.ts (95%) rename packages/{uniswap/src/features/gating => gating/src}/configs.ts (81%) rename packages/{uniswap/src/features/gating => gating/src}/constants.ts (100%) rename packages/{uniswap/src/features/gating => gating/src}/experiments.ts (83%) rename packages/{uniswap/src/features/gating => gating/src}/flags.ts (98%) rename packages/{uniswap/src/features/gating => gating/src}/getStatsigEnvName.ts (100%) rename packages/{uniswap/src/features/gating => gating/src}/hooks.ts (95%) create mode 100644 packages/gating/src/index.ts rename packages/{uniswap/src/features/gating => gating/src}/sdk/statsig.native.ts (83%) rename packages/{uniswap/src/features/gating => gating/src}/sdk/statsig.ts (92%) rename packages/{uniswap/src/features/gating => gating/src}/utils.ts (92%) create mode 100644 packages/gating/tsconfig.json create mode 100644 packages/gating/tsconfig.lint.json create mode 100644 packages/notifications/.eslintrc.js create mode 100644 packages/notifications/README.md create mode 100644 packages/notifications/package.json create mode 100644 packages/notifications/project.json create mode 100644 packages/notifications/src/index.ts create mode 100644 packages/notifications/tsconfig.json create mode 100644 packages/notifications/tsconfig.lint.json create mode 100644 packages/ui/src/assets/graphics/bridged-assets-v2-web-banner.png delete mode 100644 packages/uniswap/src/components/BridgedAsset/utils.ts delete mode 100644 packages/uniswap/src/data/rest/getPair.ts create mode 100644 packages/uniswap/src/features/fiatCurrency/hooks/useFormatChartFiatDelta.ts create mode 100644 packages/uniswap/src/features/fiatCurrency/priceChart/formatters/shared/types.ts create mode 100644 packages/uniswap/src/features/fiatCurrency/priceChart/formatters/shared/utils.ts create mode 100644 packages/uniswap/src/features/fiatCurrency/priceChart/formatters/stablecoinFormatter.ts create mode 100644 packages/uniswap/src/features/fiatCurrency/priceChart/formatters/standardFormatter.ts create mode 100644 packages/uniswap/src/features/fiatCurrency/priceChart/priceChartConversion.test.ts create mode 100644 packages/uniswap/src/features/fiatCurrency/priceChart/priceChartConversion.ts create mode 100644 packages/uniswap/src/features/telemetry/utils/logExperimentQualifyingEvent.ts create mode 100644 packages/uniswap/src/features/transactions/swap/plan/planSaga.ts create mode 100644 packages/uniswap/src/features/transactions/swap/plan/planStepTransformer.ts create mode 100644 packages/uniswap/src/features/transactions/swap/plan/utils.ts create mode 100644 packages/uniswap/src/utils/search/doesTokenMatchSearchTerm.test.ts create mode 100644 packages/uniswap/src/utils/search/doesTokenMatchSearchTerm.ts create mode 100644 packages/uniswap/src/utils/search/getPossibleChainMatchFromSearchWord.test.ts create mode 100644 packages/uniswap/src/utils/search/getPossibleChainMatchFromSearchWord.ts create mode 100644 packages/uniswap/src/utils/search/parseChainFromTokenSearchQuery.test.ts create mode 100644 packages/uniswap/src/utils/search/parseChainFromTokenSearchQuery.ts create mode 100755 scripts/remove-local-packages.sh delete mode 100644 tools/uniswap-nx/src/generators/package/files/biome.json diff --git a/.gitignore b/.gitignore index 3b3a172871a..b3c573b208b 100644 --- a/.gitignore +++ b/.gitignore @@ -76,9 +76,10 @@ CLAUDE.local.md # lefthook .lefthook/ - - +# Nx .nx/cache .nx/workspace-data -.cursor/rules/nx-rules.mdc .github/instructions/nx.instructions.md + +# Spec Workflow MCP +.spec-workflow/ diff --git a/RELEASE b/RELEASE index 80f8e3ca3e2..12680356c4b 100644 --- a/RELEASE +++ b/RELEASE @@ -1,6 +1,6 @@ IPFS hash of the deployment: -- CIDv0: `QmZmRjJXcRL1HVbuFBLX23qqmQydzmqGsLmb94qrqTU9A7` -- CIDv1: `bafybeifjzfti2rq27be42zfwqnturszxqyecs4zxklxxr4pejgcgi72eja` +- CIDv0: `QmUF5sYAgjigJ6D7nE6p12SG1rFrwYLidKxeX52qBN1Pce` +- CIDv1: `bafybeicxxeljdmh6f2s3xo43piks2h7ibgm6hbfoo3ftc5wjfmqfsngghm` The latest release is always mirrored at [app.uniswap.org](https://app.uniswap.org). @@ -10,14 +10,54 @@ You can also access the Uniswap Interface from an IPFS gateway. Your Uniswap settings are never remembered across different URLs. IPFS gateways: -- https://bafybeifjzfti2rq27be42zfwqnturszxqyecs4zxklxxr4pejgcgi72eja.ipfs.dweb.link/ -- [ipfs://QmZmRjJXcRL1HVbuFBLX23qqmQydzmqGsLmb94qrqTU9A7/](ipfs://QmZmRjJXcRL1HVbuFBLX23qqmQydzmqGsLmb94qrqTU9A7/) +- https://bafybeicxxeljdmh6f2s3xo43piks2h7ibgm6hbfoo3ftc5wjfmqfsngghm.ipfs.dweb.link/ +- [ipfs://QmUF5sYAgjigJ6D7nE6p12SG1rFrwYLidKxeX52qBN1Pce/](ipfs://QmUF5sYAgjigJ6D7nE6p12SG1rFrwYLidKxeX52qBN1Pce/) -## 5.115.0 (2025-10-24) +## 5.116.0 (2025-10-28) ### Features -* **web:** special case metamask dual vm connection flow (#24756) (#24789) b21eafd +* **web:** add activity table to the tab with real data (#23506) f00228c +* **web:** Add createRejectableMockConnector util to force tx rejection (#24574) 3b3b2b7 +* **web:** add demo account support for activity tab (#24639) 9ec0194 +* **web:** add disconnected portfolio view (#23690) 7a1b085 +* **web:** add fiat to price chart (#23577) fab99ce +* **web:** add hidden tokens table rows (#23535) 291fab3 +* **web:** add loading state to tokens table (#23544) ed5ced8 +* **web:** add more & better filtering + transaction parsing (#24579) 205c03d +* **web:** add v2 bridged asset banner (#24734) 4666868 +* **web:** disconnected view B version (#24630) 46ca828 +* **web:** Help Modal styling nits (#24547) ae252e6 +* **web:** NFTs tab (#23604) a438b54 +* **web:** small style nits for Company menu (#24318) 4d71e08 +* **web:** special case metamask dual vm connection flow (#24756) faabc72 +* **web:** tokens table search (#23509) b83fc75 +* **web:** update CompanyMenu arrangement on tablet width (#24312) 758f68d + + +### Bug Fixes + +* **web:** default to mainnet for limits flow [STAGING] (#24885) 5a8e150 +* **web:** Fix CreatePosition e2e anvil test (#24573) d68b011 +* **web:** Fix e2e anvil tests missing quote stub (#24590) 838d5bd +* **web:** Fix limit order chain switch bug (#23064) b11176d +* **web:** Fix Swap e2e anvil tests (#24662) 26adf5c +* **web:** fixes pools tab loader skeletons (#24472) 2f887aa +* **web:** Increase anvil manager timeout (#24623) 466eb69 +* **web:** log interface swap finalization results for flashblocks (#24869) bf30270 +* **web:** support chain filtering query params (#24754) 4bc3729 +* **web:** update the create flow to display the latest dependnet amount (#24676) 168c20a +* **web:** Use Mainnet instead of Base for e2e test commands (#24589) ff7dfee + + +### Continuous Integration + +* **web:** update sitemaps 4e8124b + + +### Tests + +* **web:** Disable anvil snapshots by default (#24666) 1a2903c diff --git a/VERSION b/VERSION index 885f8abd813..a4fafce7ea0 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -web/5.115.0 \ No newline at end of file +web/5.116.0 \ No newline at end of file diff --git a/apps/extension/package.json b/apps/extension/package.json index 3409945e3e4..02974e473d0 100644 --- a/apps/extension/package.json +++ b/apps/extension/package.json @@ -22,6 +22,7 @@ "@uniswap/v3-sdk": "3.25.2", "@uniswap/v4-sdk": "1.21.2", "@universe/api": "workspace:^", + "@universe/gating": "workspace:^", "@wxt-dev/module-react": "1.1.3", "confusing-browser-globals": "1.0.11", "dotenv-webpack": "8.0.1", diff --git a/apps/extension/src/app/apollo.tsx b/apps/extension/src/app/apollo.tsx index 41416c70604..e006c3ed06a 100644 --- a/apps/extension/src/app/apollo.tsx +++ b/apps/extension/src/app/apollo.tsx @@ -1,8 +1,7 @@ import { ApolloProvider } from '@apollo/client/react/context' -import { PropsWithChildren, useEffect } from 'react' +import { PropsWithChildren } from 'react' import { localStorage } from 'redux-persist-webextension-storage' import { getReduxStore } from 'src/store/store' -import { initializePortfolioQueryOverrides } from 'uniswap/src/data/rest/portfolioBalanceOverrides' // biome-ignore lint/style/noRestrictedImports: Direct wallet import needed for Apollo client setup in extension context import { usePersistedApolloClient } from 'wallet/src/data/apollo/usePersistedApolloClient' @@ -16,14 +15,9 @@ export function GraphqlProvider({ children }: PropsWithChildren): JSX.E reduxStore: getReduxStore(), }) - useEffect(() => { - if (apolloClient) { - initializePortfolioQueryOverrides({ store: getReduxStore(), apolloClient }) - } - }, [apolloClient]) - if (!apolloClient) { return <> } + return {children} } diff --git a/apps/extension/src/app/components/AutoLockProvider.tsx b/apps/extension/src/app/components/AutoLockProvider.tsx index 4d7989e0fc7..8f26d940352 100644 --- a/apps/extension/src/app/components/AutoLockProvider.tsx +++ b/apps/extension/src/app/components/AutoLockProvider.tsx @@ -1,9 +1,8 @@ +import { FeatureFlags, useFeatureFlag } from '@universe/gating' import { PropsWithChildren, useEffect } from 'react' import { useSelector } from 'react-redux' import { ExtensionState } from 'src/store/extensionReducer' import { useIsChromeWindowFocusedWithTimeout } from 'uniswap/src/extension/useIsChromeWindowFocused' -import { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' import { deviceAccessTimeoutToMs } from 'uniswap/src/features/settings/constants' import { ExtensionEventName } from 'uniswap/src/features/telemetry/constants' import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' diff --git a/apps/extension/src/app/core/StatsigProvider.tsx b/apps/extension/src/app/core/StatsigProvider.tsx index 63f55e72d4f..7c6aeb01b76 100644 --- a/apps/extension/src/app/core/StatsigProvider.tsx +++ b/apps/extension/src/app/core/StatsigProvider.tsx @@ -1,10 +1,9 @@ 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 { StatsigCustomAppValue } from 'uniswap/src/features/gating/constants' import { StatsigProviderWrapper } from 'uniswap/src/features/gating/StatsigProviderWrapper' -import { StatsigUser } from 'uniswap/src/features/gating/sdk/statsig' import { initializeDatadog } from 'uniswap/src/utils/datadog' import { uniqueIdQuery } from 'utilities/src/device/uniqueIdQuery' diff --git a/apps/extension/src/app/core/initStatSigForBrowserScripts.tsx b/apps/extension/src/app/core/initStatSigForBrowserScripts.tsx index 3e3ffaaa9ba..7a495477c72 100644 --- a/apps/extension/src/app/core/initStatSigForBrowserScripts.tsx +++ b/apps/extension/src/app/core/initStatSigForBrowserScripts.tsx @@ -1,6 +1,5 @@ +import { StatsigClient, StatsigCustomAppValue, StatsigUser } from '@universe/gating' import { config } from 'uniswap/src/config' -import { StatsigCustomAppValue } from 'uniswap/src/features/gating/constants' -import { StatsigClient, StatsigUser } from 'uniswap/src/features/gating/sdk/statsig' import { statsigBaseConfig } from 'uniswap/src/features/gating/statsigBaseConfig' import { getUniqueId } from 'utilities/src/device/uniqueId' import { logger } from 'utilities/src/logger/logger' diff --git a/apps/extension/src/app/features/biometricUnlock/useShouldShowBiometricUnlock.ts b/apps/extension/src/app/features/biometricUnlock/useShouldShowBiometricUnlock.ts index 3491a94d6bb..54582813431 100644 --- a/apps/extension/src/app/features/biometricUnlock/useShouldShowBiometricUnlock.ts +++ b/apps/extension/src/app/features/biometricUnlock/useShouldShowBiometricUnlock.ts @@ -1,7 +1,6 @@ import { useQuery } from '@tanstack/react-query' +import { DynamicConfigs, ExtensionBiometricUnlockConfigKey, useDynamicConfigValue } from '@universe/gating' import { biometricUnlockCredentialQuery } from 'src/app/features/biometricUnlock/biometricUnlockCredentialQuery' -import { DynamicConfigs, ExtensionBiometricUnlockConfigKey } from 'uniswap/src/features/gating/configs' -import { useDynamicConfigValue } from 'uniswap/src/features/gating/hooks' export function useShouldShowBiometricUnlock(): boolean { const isEnabled = useDynamicConfigValue({ diff --git a/apps/extension/src/app/features/biometricUnlock/useShouldShowBiometricUnlockEnrollment.ts b/apps/extension/src/app/features/biometricUnlock/useShouldShowBiometricUnlockEnrollment.ts index 7c496296c1d..f642af2d9ad 100644 --- a/apps/extension/src/app/features/biometricUnlock/useShouldShowBiometricUnlockEnrollment.ts +++ b/apps/extension/src/app/features/biometricUnlock/useShouldShowBiometricUnlockEnrollment.ts @@ -1,8 +1,7 @@ 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' -import { DynamicConfigs, ExtensionBiometricUnlockConfigKey } from 'uniswap/src/features/gating/configs' -import { useDynamicConfigValue } from 'uniswap/src/features/gating/hooks' export function useShouldShowBiometricUnlockEnrollment({ flow }: { flow: 'onboarding' | 'settings' }): boolean { const { t } = useTranslation() diff --git a/apps/extension/src/app/features/dappRequests/requestContent/SignTypeData/SignTypedDataRequestContent.tsx b/apps/extension/src/app/features/dappRequests/requestContent/SignTypeData/SignTypedDataRequestContent.tsx index 19606509983..b015627b61b 100644 --- a/apps/extension/src/app/features/dappRequests/requestContent/SignTypeData/SignTypedDataRequestContent.tsx +++ b/apps/extension/src/app/features/dappRequests/requestContent/SignTypeData/SignTypedDataRequestContent.tsx @@ -1,3 +1,4 @@ +import { FeatureFlags, useFeatureFlag } from '@universe/gating' import { useTranslation } from 'react-i18next' import { DappRequestContent } from 'src/app/features/dappRequests/DappRequestContent' import { ActionCanNotBeCompletedContent } from 'src/app/features/dappRequests/requestContent/ActionCanNotBeCompleted/ActionCanNotBeCompletedContent' @@ -11,8 +12,6 @@ import { EIP712Message, isEIP712TypedData } from 'src/app/features/dappRequests/ import { isPermit2, isUniswapXSwapRequest } from 'src/app/features/dappRequests/types/Permit2Types' import { Flex, Text } from 'ui/src' import { toSupportedChainId } from 'uniswap/src/features/chains/utils' -import { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' import { useHasAccountMismatchCallback } from 'uniswap/src/features/smartWallet/mismatch/hooks' import { ExplorerDataType, getExplorerLink } from 'uniswap/src/utils/linking' import { isEVMAddressWithChecksum } from 'utilities/src/addresses/evm/evm' diff --git a/apps/extension/src/app/features/dappRequests/saga.ts b/apps/extension/src/app/features/dappRequests/saga.ts index 8ce3f50de43..7a511bca103 100644 --- a/apps/extension/src/app/features/dappRequests/saga.ts +++ b/apps/extension/src/app/features/dappRequests/saga.ts @@ -1,6 +1,7 @@ /* eslint-disable max-lines */ import { 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 { DappInfo, dappStore } from 'src/app/features/dapp/store' @@ -45,8 +46,6 @@ import getCalldataInfoFromTransaction from 'src/background/utils/getCalldataInfo 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 { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { getFeatureFlag } from 'uniswap/src/features/gating/hooks' 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' diff --git a/apps/extension/src/app/features/home/HomeScreen.tsx b/apps/extension/src/app/features/home/HomeScreen.tsx index f7a5e10a591..00145f31da7 100644 --- a/apps/extension/src/app/features/home/HomeScreen.tsx +++ b/apps/extension/src/app/features/home/HomeScreen.tsx @@ -1,5 +1,6 @@ import { useApolloClient } from '@apollo/client' import { SharedEventName } from '@uniswap/analytics-events' +import { FeatureFlags, useFeatureFlag } from '@universe/gating' import { memo, useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { useDispatch, useSelector } from 'react-redux' @@ -21,8 +22,6 @@ import { navigate } from 'src/app/navigation/state' import { Flex, Loader, styled, Text, TouchableArea } from 'ui/src' import { SMART_WALLET_UPGRADE_VIDEO } from 'ui/src/assets' import { NFTS_TAB_DATA_DEPENDENCIES } from 'uniswap/src/components/nfts/constants' -import { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' 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' diff --git a/apps/extension/src/app/features/home/PortfolioActionButtons.tsx b/apps/extension/src/app/features/home/PortfolioActionButtons.tsx index a0ac5b56037..9a7a08625ce 100644 --- a/apps/extension/src/app/features/home/PortfolioActionButtons.tsx +++ b/apps/extension/src/app/features/home/PortfolioActionButtons.tsx @@ -1,4 +1,5 @@ import { SharedEventName } from '@uniswap/analytics-events' +import { FeatureFlags, useFeatureFlag } from '@universe/gating' import { cloneElement, memo, useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import { useInterfaceBuyNavigator } from 'src/app/features/for/utils' @@ -7,8 +8,6 @@ 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 { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' import { ElementName } from 'uniswap/src/features/telemetry/constants' import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' import { TestnetModeModal } from 'uniswap/src/features/testnets/TestnetModeModal' diff --git a/apps/extension/src/app/features/home/PortfolioHeader.tsx b/apps/extension/src/app/features/home/PortfolioHeader.tsx index 45df41829bc..b951fd5c50a 100644 --- a/apps/extension/src/app/features/home/PortfolioHeader.tsx +++ b/apps/extension/src/app/features/home/PortfolioHeader.tsx @@ -55,7 +55,7 @@ const RotatingSettingsIcon = ({ onPressSettings }: { onPressSettings(): void }): }), ) } - }, [isScreenFocused, pressProgress]) + }, [isScreenFocused]) const onBegin = (): void => { pressProgress.value = withTiming(1) diff --git a/apps/extension/src/app/features/onboarding/import/SelectWallets.tsx b/apps/extension/src/app/features/onboarding/import/SelectWallets.tsx index dcf3940bb5e..3c8597824a0 100644 --- a/apps/extension/src/app/features/onboarding/import/SelectWallets.tsx +++ b/apps/extension/src/app/features/onboarding/import/SelectWallets.tsx @@ -1,4 +1,5 @@ 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' @@ -9,8 +10,6 @@ import { Flex, ScrollView, SpinningLoader, Square, Text, Tooltip, TouchableArea import { WalletFilled } from 'ui/src/components/icons' import { iconSizes } from 'ui/src/theme' import { uniswapUrls } from 'uniswap/src/constants/urls' -import { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' import Trace from 'uniswap/src/features/telemetry/Trace' import { ExtensionOnboardingFlow, ExtensionOnboardingScreens } from 'uniswap/src/types/screens/extension' import { openUri } from 'uniswap/src/utils/linking' diff --git a/apps/extension/src/app/features/onboarding/scan/ScanToOnboard.tsx b/apps/extension/src/app/features/onboarding/scan/ScanToOnboard.tsx index 30080a5b13f..3e01afb4a99 100644 --- a/apps/extension/src/app/features/onboarding/scan/ScanToOnboard.tsx +++ b/apps/extension/src/app/features/onboarding/scan/ScanToOnboard.tsx @@ -174,7 +174,7 @@ export function ScanToOnboard(): JSX.Element { ) return () => cancelAnimation(qrScale) - }, [isLoadingUUID, qrScale]) + }, [isLoadingUUID]) // Using useAnimatedStyle and AnimatedFlex because tamagui scale animation not working const qrAnimatedStyle = useAnimatedStyle(() => { return { diff --git a/apps/extension/src/app/features/settings/SettingsScreen.tsx b/apps/extension/src/app/features/settings/SettingsScreen.tsx index 4dd4947e716..ed9a068a3c1 100644 --- a/apps/extension/src/app/features/settings/SettingsScreen.tsx +++ b/apps/extension/src/app/features/settings/SettingsScreen.tsx @@ -1,3 +1,4 @@ +import { FeatureFlags, useFeatureFlag } from '@universe/gating' import { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { useDispatch, useSelector } from 'react-redux' @@ -31,8 +32,6 @@ import { resetUniswapBehaviorHistory } from 'uniswap/src/features/behaviorHistor 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 { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' import { useCurrentLanguageInfo } from 'uniswap/src/features/language/hooks' import { PasskeyManagementModal } from 'uniswap/src/features/passkey/PasskeyManagementModal' import { setCurrentFiatCurrency, setIsTestnetModeEnabled } from 'uniswap/src/features/settings/slice' diff --git a/apps/extension/src/app/hooks/useIsExtensionPasskeyImportEnabled.ts b/apps/extension/src/app/hooks/useIsExtensionPasskeyImportEnabled.ts index 8abf941051b..823ac6ba868 100644 --- a/apps/extension/src/app/hooks/useIsExtensionPasskeyImportEnabled.ts +++ b/apps/extension/src/app/hooks/useIsExtensionPasskeyImportEnabled.ts @@ -1,5 +1,4 @@ -import { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' +import { FeatureFlags, useFeatureFlag } from '@universe/gating' export function useIsExtensionPasskeyImportEnabled(): boolean { return useFeatureFlag(FeatureFlags.EmbeddedWallet) diff --git a/apps/extension/src/entrypoints/sidepanel/main.tsx b/apps/extension/src/entrypoints/sidepanel/main.tsx index 4cbeaaa35d0..5465a71e672 100644 --- a/apps/extension/src/entrypoints/sidepanel/main.tsx +++ b/apps/extension/src/entrypoints/sidepanel/main.tsx @@ -11,8 +11,10 @@ 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' import { initializeScrollWatcher } from 'uniswap/src/components/modals/ScrollLock' +import { initializePortfolioQueryOverrides } from 'uniswap/src/data/rest/portfolioBalanceOverrides' import { logger } from 'utilities/src/logger/logger' // biome-ignore lint/suspicious/noExplicitAny: Global polyfill cleanup requires any type for runtime modification ;(globalThis as any).regeneratorRuntime = undefined @@ -44,6 +46,7 @@ export function makeSidebar(): void { } StoreSynchronization.init(ExtensionAppLocation.SidePanel) + initializePortfolioQueryOverrides({ store: getReduxStore() }) initSidebar() initializeScrollWatcher() } diff --git a/apps/extension/tsconfig.json b/apps/extension/tsconfig.json index 007f0494490..831ce04a0d1 100644 --- a/apps/extension/tsconfig.json +++ b/apps/extension/tsconfig.json @@ -19,6 +19,9 @@ }, { "path": "../../packages/api" + }, + { + "path": "../../packages/gating" } ], "compilerOptions": { diff --git a/apps/mobile/.maestro/flows/deeplinks/deeplink-comprehensive.yaml b/apps/mobile/.maestro/flows/deeplinks/deeplink-comprehensive.yaml index 7986a843f72..71df2ffe3af 100644 --- a/apps/mobile/.maestro/flows/deeplinks/deeplink-comprehensive.yaml +++ b/apps/mobile/.maestro/flows/deeplinks/deeplink-comprehensive.yaml @@ -12,7 +12,7 @@ env: - runScript: file: ../../scripts/performance/dist/actions/start-flow.js env: - FLOW_NAME: 'deeplink-comprehensive' + FLOW_NAME: "deeplink-comprehensive" # Run prerequisite flows (tracked as sub-flows) - runFlow: ../../shared-flows/start.yaml @@ -22,15 +22,15 @@ env: - runScript: file: ../../scripts/performance/dist/actions/track-action.js env: - ACTION: 'openLink' - TARGET: 'onramp-deeplink' - PHASE: 'start' + ACTION: "openLink" + TARGET: "onramp-deeplink" + PHASE: "start" - openLink: - link: 'uniswap://app/fiatonramp?userAddress=0xEEf806b3Cae8fcecAe1793EE1e0B2c738F61C6bB&source=push' + link: "uniswap://app/fiatonramp?userAddress=0xEEf806b3Cae8fcecAe1793EE1e0B2c738F61C6bB&source=push" autoVerify: true # Handle iOS deeplink permission dialog (optional - only appears on first run) - tapOn: - text: 'Open' + text: "Open" optional: true - waitForAnimationToEnd: timeout: 5000 @@ -41,43 +41,43 @@ env: - runScript: file: ../../scripts/performance/dist/actions/track-action.js env: - ACTION: 'openLink' - TARGET: 'onramp-deeplink' - PHASE: 'end' + 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' + ACTION: "openLink" + TARGET: "widget-deeplink" + PHASE: "start" - openLink: - link: 'uniswap://widget/#/tokens/ethereum/0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984' + link: "uniswap://widget/#/tokens/ethereum/0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984" autoVerify: true - waitForAnimationToEnd: timeout: 3000 - assertVisible: id: ${output.testIds.TokenDetailsHeaderText} - text: 'Uniswap' + text: "Uniswap" - runScript: file: ../../scripts/performance/dist/actions/track-action.js env: - ACTION: 'openLink' - TARGET: 'widget-deeplink' - PHASE: 'end' + 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' + 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' + link: "uniswap://redirect?screen=swap&userAddress=0xEEf806b3Cae8fcecAe1793EE1e0B2c738F61C6bB&inputCurrencyId=1-0x6B175474E89094C44Da98b954EedeAC495271d0F&outputCurrencyId=1-0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984¤cyField=input&amount=100" autoVerify: true - waitForAnimationToEnd: timeout: 3000 @@ -92,20 +92,20 @@ env: - runScript: file: ../../scripts/performance/dist/actions/track-action.js env: - ACTION: 'openLink' - TARGET: 'swap-deeplink' - PHASE: 'end' + 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' + ACTION: "openLink" + TARGET: "token-details-deeplink" + PHASE: "start" - openLink: - link: 'uniswap://app/tokendetails?currencyId=10-0x6fd9d7ad17242c41f7131d257212c54a0e816691&source=push' + link: "uniswap://app/tokendetails?currencyId=10-0x6fd9d7ad17242c41f7131d257212c54a0e816691&source=push" autoVerify: true - waitForAnimationToEnd: timeout: 3000 @@ -116,20 +116,20 @@ env: - runScript: file: ../../scripts/performance/dist/actions/track-action.js env: - ACTION: 'openLink' - TARGET: 'token-details-deeplink' - PHASE: 'end' + 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' + ACTION: "openLink" + TARGET: "transaction-history-deeplink" + PHASE: "start" - openLink: - link: 'uniswap://redirect?screen=transaction&fiatOnRamp=true&userAddress=0xEEf806b3Cae8fcecAe1793EE1e0B2c738F61C6bB' + link: "uniswap://redirect?screen=transaction&fiatOnRamp=true&userAddress=0xEEf806b3Cae8fcecAe1793EE1e0B2c738F61C6bB" autoVerify: true - waitForAnimationToEnd: timeout: 3000 @@ -138,20 +138,20 @@ env: - runScript: file: ../../scripts/performance/dist/actions/track-action.js env: - ACTION: 'openLink' - TARGET: 'transaction-history-deeplink' - PHASE: 'end' + 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' + ACTION: "openLink" + TARGET: "invalid-deeplink" + PHASE: "start" - openLink: - link: 'uniswap://invalid-path' + link: "uniswap://invalid-path" autoVerify: true - waitForAnimationToEnd: timeout: 3000 @@ -160,46 +160,46 @@ env: - runScript: file: ../../scripts/performance/dist/actions/track-action.js env: - ACTION: 'openLink' - TARGET: 'invalid-deeplink' - PHASE: 'end' + 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' + ACTION: "openLink" + TARGET: "moonpay-onramp-deeplink" + PHASE: "start" - openLink: - link: 'uniswap://app/fiatonramp?source=push&moonpayOnly=true&moonpayCurrencyCode=usdc&amount=200' + 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' + text: "USD Coin" - assertVisible: id: ${output.testIds.BuyFormAmountInput} - text: '200' + text: "200" - runScript: file: ../../scripts/performance/dist/actions/track-action.js env: - ACTION: 'openLink' - TARGET: 'moonpay-onramp-deeplink' - PHASE: 'end' + 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' + 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' + 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 @@ -207,16 +207,16 @@ env: id: ${output.testIds.ScantasticConfirmationTitle} - assertVisible: id: ${output.testIds.ScantasticDevice} - text: 'Apple Macintosh' + text: "Apple Macintosh" - assertVisible: id: ${output.testIds.ScantasticBrowser} - text: 'Chrome' + text: "Chrome" - runScript: file: ../../scripts/performance/dist/actions/track-action.js env: - ACTION: 'openLink' - TARGET: 'scantastic-deeplink' - PHASE: 'end' + ACTION: "openLink" + TARGET: "scantastic-deeplink" + PHASE: "end" - killApp # End flow tracking @@ -228,4 +228,4 @@ env: file: ../../scripts/performance/upload-metrics.js env: DATADOG_API_KEY: ${DATADOG_API_KEY} - ENVIRONMENT: 'maestro_cloud' + 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 index 0f73d3201f3..08ab78e29fa 100644 --- a/apps/mobile/.maestro/flows/restore/restore-new-device.yaml +++ b/apps/mobile/.maestro/flows/restore/restore-new-device.yaml @@ -184,10 +184,29 @@ env: 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 # wait for cloud backup to fail + 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: diff --git a/apps/mobile/.maestro/shared-flows/navigate-to-explore.yaml b/apps/mobile/.maestro/shared-flows/navigate-to-explore.yaml index 3431fe0cb24..200f366aeaf 100644 --- a/apps/mobile/.maestro/shared-flows/navigate-to-explore.yaml +++ b/apps/mobile/.maestro/shared-flows/navigate-to-explore.yaml @@ -1,20 +1,37 @@ appId: com.uniswap.mobile.dev --- # This flow handles the common action of navigating to the Explore tab -# from the main wallet screen. +# 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' + visible: "noop" timeout: 2000 optional: true -# Tap on the Explore/Search button in the navigation bar +# 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/package.json b/apps/mobile/package.json index 6e581251f19..18f214ecb22 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -111,6 +111,7 @@ "@uniswap/ethers-rs-mobile": "0.0.5", "@uniswap/sdk-core": "7.7.2", "@universe/api": "workspace:^", + "@universe/gating": "workspace:^", "@walletconnect/core": "2.21.4", "@walletconnect/react-native-compat": "2.21.4", "@walletconnect/types": "2.21.4", diff --git a/apps/mobile/src/app/App.tsx b/apps/mobile/src/app/App.tsx index 7554650723a..da7c8559205 100644 --- a/apps/mobile/src/app/App.tsx +++ b/apps/mobile/src/app/App.tsx @@ -3,6 +3,17 @@ import { loadDevMessages, loadErrorMessages } from '@apollo/client/dev' import { DdRum, RumActionType } from '@datadog/mobile-react-native' import { BottomSheetModalProvider } from '@gorhom/bottom-sheet' import { PerformanceProfiler, RenderPassReport } from '@shopify/react-native-performance' +import { + DatadogSessionSampleRateKey, + DynamicConfigs, + Experiments, + getDynamicConfigValue, + getStatsigClient, + StatsigCustomAppValue, + StatsigUser, + Storage, + WALLET_FEATURE_FLAG_NAMES, +} from '@universe/gating' import { MMKVWrapper } from 'apollo3-cache-persist' import { default as React, StrictMode, useCallback, useEffect, useMemo, useRef } from 'react' import { I18nextProvider } from 'react-i18next' @@ -55,13 +66,7 @@ import { BlankUrlProvider } from 'uniswap/src/contexts/UrlContext' import { initializePortfolioQueryOverrides } from 'uniswap/src/data/rest/portfolioBalanceOverrides' import { selectFavoriteTokens } from 'uniswap/src/features/favorites/selectors' import { useAppFiatCurrencyInfo } from 'uniswap/src/features/fiatCurrency/hooks' -import { DatadogSessionSampleRateKey, DynamicConfigs } from 'uniswap/src/features/gating/configs' -import { StatsigCustomAppValue } from 'uniswap/src/features/gating/constants' -import { Experiments } from 'uniswap/src/features/gating/experiments' -import { WALLET_FEATURE_FLAG_NAMES } from 'uniswap/src/features/gating/flags' -import { getDynamicConfigValue } from 'uniswap/src/features/gating/hooks' import { StatsigProviderWrapper } from 'uniswap/src/features/gating/StatsigProviderWrapper' -import { getStatsigClient, StatsigUser, Storage } from 'uniswap/src/features/gating/sdk/statsig' 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' @@ -121,6 +126,8 @@ initDynamicIntlPolyfills() initOneSignal() initAppsFlyer() +initializePortfolioQueryOverrides({ store }) + function App(): JSX.Element | null { useEffect(() => { if (!__DEV__) { @@ -240,12 +247,6 @@ function AppOuter(): JSX.Element | null { } }, []) - useEffect(() => { - if (client) { - initializePortfolioQueryOverrides({ store, apolloClient: client }) - } - }, [client]) - if (!client) { return null } diff --git a/apps/mobile/src/app/MobileWalletNavigationProvider.tsx b/apps/mobile/src/app/MobileWalletNavigationProvider.tsx index 9e692d3fe02..6f2319b4364 100644 --- a/apps/mobile/src/app/MobileWalletNavigationProvider.tsx +++ b/apps/mobile/src/app/MobileWalletNavigationProvider.tsx @@ -1,4 +1,5 @@ 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' @@ -15,8 +16,6 @@ import { useFiatOnRampAggregatorGetCountryQuery, } from 'uniswap/src/features/fiatOnRamp/api' import { RampDirection } from 'uniswap/src/features/fiatOnRamp/types' -import { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' 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' diff --git a/apps/mobile/src/app/modals/BridgedAssetWarningWrapper.tsx b/apps/mobile/src/app/modals/BridgedAssetWarningWrapper.tsx index 6b6c3ade50d..29cd1bd00a2 100644 --- a/apps/mobile/src/app/modals/BridgedAssetWarningWrapper.tsx +++ b/apps/mobile/src/app/modals/BridgedAssetWarningWrapper.tsx @@ -1,7 +1,6 @@ import { AppStackScreenProp } from 'src/app/navigation/types' import { useReactNavigationModal } from 'src/components/modals/useReactNavigationModal' import { BridgedAssetModal } from 'uniswap/src/components/BridgedAsset/BridgedAssetModal' -import { checkIsBridgedAsset } from 'uniswap/src/components/BridgedAsset/utils' import { useEnabledChains } from 'uniswap/src/features/chains/hooks/useEnabledChains' import { ModalName } from 'uniswap/src/features/telemetry/constants' import { useDismissedBridgedAssetWarnings } from 'uniswap/src/features/tokens/slice/hooks' @@ -36,7 +35,7 @@ export function BridgedAssetWarningWrapper({ return null } - const isBridgedAsset = checkIsBridgedAsset(currencyInfo) + 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) { diff --git a/apps/mobile/src/app/navigation/navigation.tsx b/apps/mobile/src/app/navigation/navigation.tsx index d59a3a4d5d1..07cbfafd88c 100644 --- a/apps/mobile/src/app/navigation/navigation.tsx +++ b/apps/mobile/src/app/navigation/navigation.tsx @@ -1,6 +1,7 @@ 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' @@ -110,8 +111,6 @@ import { ViewPrivateKeysScreen } from 'src/screens/ViewPrivateKeys/ViewPrivateKe import { WebViewScreen } from 'src/screens/WebViewScreen' import { useSporeColors } from 'ui/src' import { spacing } from 'ui/src/theme' -import { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' import { ModalName } from 'uniswap/src/features/telemetry/constants' import { useAppInsets } from 'uniswap/src/hooks/useAppInsets' import { OnboardingEntryPoint } from 'uniswap/src/types/onboarding' diff --git a/apps/mobile/src/app/navigation/tabs/CustomTabBar/CustomTabBar.tsx b/apps/mobile/src/app/navigation/tabs/CustomTabBar/CustomTabBar.tsx index dc6727df6f5..01de66c7fe1 100644 --- a/apps/mobile/src/app/navigation/tabs/CustomTabBar/CustomTabBar.tsx +++ b/apps/mobile/src/app/navigation/tabs/CustomTabBar/CustomTabBar.tsx @@ -37,6 +37,7 @@ const TabItem = ({ tab, index, isFocused, onPress, colors }: TabItemProps): JSX. return ( numberOfDigits: PriceNumberOfDigits spotPrice?: SharedValue + startingPrice?: number + shouldTreatAsStablecoin?: boolean } const PriceTextSection = memo(function PriceTextSection({ loading, numberOfDigits, spotPrice, + startingPrice, + shouldTreatAsStablecoin, }: PriceTextProps): JSX.Element { const price = useLineChartPrice(spotPrice) const currency = useAppFiatCurrencyInfo() @@ -62,9 +66,13 @@ const PriceTextSection = memo(function PriceTextSection({ 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. */} - - + + ) }) @@ -144,7 +152,7 @@ const PriceExplorerInner = memo(function _PriceExplorerInner(): JSX.Element { value: convertedSpotValue, } ) - }, [data, convertedSpotValue]) + }, [data]) // Zoom out y-axis for low variance assets const shouldZoomOut = useMemo(() => { @@ -177,6 +185,9 @@ const PriceExplorerInner = memo(function _PriceExplorerInner(): JSX.Element { return } + // Get the starting price for fiat delta calculation + const startingPrice = convertedPriceHistory[0]?.value + return ( @@ -185,6 +196,8 @@ const PriceExplorerInner = memo(function _PriceExplorerInner(): JSX.Element { numberOfDigits={numberOfDigits} relativeChange={convertedSpot?.relativeChange} spotPrice={convertedSpot?.value} + startingPrice={startingPrice} + shouldTreatAsStablecoin={shouldZoomOut} /> diff --git a/apps/mobile/src/components/PriceExplorer/Text.tsx b/apps/mobile/src/components/PriceExplorer/Text.tsx index 0da167fcdcd..8cddaffd8e7 100644 --- a/apps/mobile/src/components/PriceExplorer/Text.tsx +++ b/apps/mobile/src/components/PriceExplorer/Text.tsx @@ -1,7 +1,8 @@ import React from 'react' -import { useAnimatedStyle } from 'react-native-reanimated' +import { useAnimatedStyle, useDerivedValue } from 'react-native-reanimated' import { 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 { Flex, Text, useSporeColors } from 'ui/src' @@ -39,19 +40,47 @@ export function PriceText({ maxWidth }: { loading: boolean; maxWidth?: number }) ) } -export function RelativeChangeText({ loading }: { loading: boolean }): JSX.Element { +export function RelativeChangeText({ + loading, + startingPrice, + shouldTreatAsStablecoin = false, +}: { + loading: boolean + startingPrice?: number + shouldTreatAsStablecoin?: boolean +}): JSX.Element { const colors = useSporeColors() const relativeChange = useLineChartRelativeChange() + const fiatDelta = useLineChartFiatDelta({ + startingPrice, + shouldTreatAsStablecoin, + }) + + const changeColor = useDerivedValue(() => { + if (relativeChange.value.value === 0) { + return colors.neutral3.val + } + return relativeChange.value.value > 0 ? colors.statusSuccess.val : colors.statusCritical.val + }) const styles = useAnimatedStyle(() => ({ - color: relativeChange.value.value >= 0 ? colors.statusSuccess.val : colors.statusCritical.val, + color: changeColor.value, })) const caretStyle = useAnimatedStyle(() => ({ - color: relativeChange.value.value >= 0 ? colors.statusSuccess.val : colors.statusCritical.val, + color: changeColor.value, transform: [{ rotate: relativeChange.value.value >= 0 ? '180deg' : '0deg' }], })) + // Combine fiat delta and percentage in a derived value + const combinedText = useDerivedValue(() => { + const delta = fiatDelta.formatted.value + if (delta) { + return `${delta} (${relativeChange.formatted.value})` + } + return relativeChange.formatted.value + }) + return ( = 0 ? -1 : 1 }, ]} /> - + )} @@ -93,8 +122,8 @@ export function DatetimeText({ loading }: { loading: boolean }): JSX.Element | n } return ( - - + + ) } diff --git a/apps/mobile/src/components/PriceExplorer/__snapshots__/Text.test.tsx.snap b/apps/mobile/src/components/PriceExplorer/__snapshots__/Text.test.tsx.snap index 98694cc5409..4b14e906407 100644 --- a/apps/mobile/src/components/PriceExplorer/__snapshots__/Text.test.tsx.snap +++ b/apps/mobile/src/components/PriceExplorer/__snapshots__/Text.test.tsx.snap @@ -6,10 +6,9 @@ exports[`DatetimeText renders without error 1`] = ` @@ -32,9 +31,9 @@ exports[`DatetimeText renders without error 1`] = ` }, }, "fontFamily": "Basel Grotesk", - "fontSize": 19, + "fontSize": 15, "fontWeight": "400", - "lineHeight": 24.7, + "lineHeight": 19.5, "padding": 0, } } diff --git a/apps/mobile/src/components/PriceExplorer/useFiatDelta.tsx b/apps/mobile/src/components/PriceExplorer/useFiatDelta.tsx new file mode 100644 index 00000000000..ff0024319e8 --- /dev/null +++ b/apps/mobile/src/components/PriceExplorer/useFiatDelta.tsx @@ -0,0 +1,116 @@ +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) + }, + [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 + 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 + }) + + return { formatted } +} diff --git a/apps/mobile/src/components/PriceExplorer/usePrice.tsx b/apps/mobile/src/components/PriceExplorer/usePrice.tsx index 3b8197599c2..0e23b3d110b 100644 --- a/apps/mobile/src/components/PriceExplorer/usePrice.tsx +++ b/apps/mobile/src/components/PriceExplorer/usePrice.tsx @@ -68,7 +68,7 @@ export function useLineChartPrice(currentSpot?: SharedValue): ValueAndFo formatted: priceFormatted, shouldAnimate, }), - [price, priceFormatted, shouldAnimate], + [], ) } diff --git a/apps/mobile/src/components/PriceExplorer/usePriceHistory.ts b/apps/mobile/src/components/PriceExplorer/usePriceHistory.ts index 5f1222aaeda..62775d74aee 100644 --- a/apps/mobile/src/components/PriceExplorer/usePriceHistory.ts +++ b/apps/mobile/src/components/PriceExplorer/usePriceHistory.ts @@ -83,7 +83,7 @@ export function useTokenPriceHistory({ relativeChange: spotRelativeChange, } : undefined, - [price, spotValue, spotRelativeChange], + [price], ) const formattedPriceHistory = useMemo(() => { diff --git a/apps/mobile/src/components/Requests/ModalWithOverlay/ModalWithOverlay.tsx b/apps/mobile/src/components/Requests/ModalWithOverlay/ModalWithOverlay.tsx index e86b139e12b..4ea51ca8353 100644 --- a/apps/mobile/src/components/Requests/ModalWithOverlay/ModalWithOverlay.tsx +++ b/apps/mobile/src/components/Requests/ModalWithOverlay/ModalWithOverlay.tsx @@ -1,4 +1,5 @@ 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 { @@ -17,8 +18,6 @@ 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 { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' import { useAppInsets } from 'uniswap/src/hooks/useAppInsets' import { TestID } from 'uniswap/src/test/fixtures/testIDs' diff --git a/apps/mobile/src/components/Requests/RequestModal/WalletConnectRequestModal.tsx b/apps/mobile/src/components/Requests/RequestModal/WalletConnectRequestModal.tsx index daf85557387..768181e6026 100644 --- a/apps/mobile/src/components/Requests/RequestModal/WalletConnectRequestModal.tsx +++ b/apps/mobile/src/components/Requests/RequestModal/WalletConnectRequestModal.tsx @@ -1,4 +1,5 @@ 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 } from 'react' @@ -29,8 +30,6 @@ import { spacing } from 'ui/src/theme' import { EthMethod } from 'uniswap/src/features/dappRequests/types' import { isSignTypedDataRequest } from 'uniswap/src/features/dappRequests/utils' import { useTransactionGasFee } from 'uniswap/src/features/gas/hooks' -import { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { useFeatureFlag } from 'uniswap/src/features/gating/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' diff --git a/apps/mobile/src/components/Requests/ScanSheet/WalletConnectModal.tsx b/apps/mobile/src/components/Requests/ScanSheet/WalletConnectModal.tsx index b3d79b23c5c..d3bd8eb86a0 100644 --- a/apps/mobile/src/components/Requests/ScanSheet/WalletConnectModal.tsx +++ b/apps/mobile/src/components/Requests/ScanSheet/WalletConnectModal.tsx @@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { Alert } from 'react-native' import 'react-native-reanimated' +import { FeatureFlags, useFeatureFlag } from '@universe/gating' import { useDispatch } from 'react-redux' import { useEagerExternalProfileRootNavigation } from 'src/app/navigation/hooks' import { BackButtonView } from 'src/components/layout/BackButtonView' @@ -24,8 +25,6 @@ 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 { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' 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' diff --git a/apps/mobile/src/components/Requests/ScanSheet/util.test.ts b/apps/mobile/src/components/Requests/ScanSheet/util.test.ts index 441ed9a01a7..76993011636 100644 --- a/apps/mobile/src/components/Requests/ScanSheet/util.test.ts +++ b/apps/mobile/src/components/Requests/ScanSheet/util.test.ts @@ -1,5 +1,9 @@ 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, @@ -121,4 +125,84 @@ describe('getSupportedURI', () => { 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 index 140882c7d38..f8f07334473 100644 --- a/apps/mobile/src/components/Requests/ScanSheet/util.ts +++ b/apps/mobile/src/components/Requests/ScanSheet/util.ts @@ -46,6 +46,9 @@ export async function getSupportedURI( 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, @@ -72,7 +75,7 @@ export async function getSupportedURI( (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(decodeURIComponent(uri), UNISWAP_WALLETCONNECT_URL)) || + (await getWcUriWithCustomPrefix(uri, UNISWAP_WALLETCONNECT_URL)) || {} if (maybeCustomWcUri && type) { @@ -147,6 +150,22 @@ export function getScantasticQueryParams(uri: string): Nullable { 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' @@ -181,10 +200,10 @@ export function parseScantasticParams(uri: string): ScantasticParams | undefined try { return ScantasticParamsSchema.parse({ publicKey: publicKey ? JSON.parse(publicKey) : undefined, - uuid: uuid ? decodeURIComponent(uuid) : undefined, - vendor: vendor ? decodeURIComponent(vendor) : undefined, - model: model ? decodeURIComponent(model) : undefined, - browser: browser ? decodeURIComponent(browser) : 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 }) diff --git a/apps/mobile/src/components/Requests/Uwulink/utils.ts b/apps/mobile/src/components/Requests/Uwulink/utils.ts index a600453c6dc..5fede7fe5ea 100644 --- a/apps/mobile/src/components/Requests/Uwulink/utils.ts +++ b/apps/mobile/src/components/Requests/Uwulink/utils.ts @@ -1,15 +1,15 @@ -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 { DynamicConfigs, UwULinkAllowlist, UwULinkAllowlistItem, UwuLinkConfigKey, -} from 'uniswap/src/features/gating/configs' -import { useDynamicConfigValue } from 'uniswap/src/features/gating/hooks' + 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, diff --git a/apps/mobile/src/components/TokenDetails/TokenDetailsBridgedAssetSection.tsx b/apps/mobile/src/components/TokenDetails/TokenDetailsBridgedAssetSection.tsx index 840d9c10050..cd3aecd60b1 100644 --- a/apps/mobile/src/components/TokenDetails/TokenDetailsBridgedAssetSection.tsx +++ b/apps/mobile/src/components/TokenDetails/TokenDetailsBridgedAssetSection.tsx @@ -1,8 +1,6 @@ -import { useMemo } from 'react' import { navigate } from 'src/app/navigation/rootNavigation' import { useTokenDetailsContext } from 'src/components/TokenDetails/TokenDetailsContext' import { BridgedAssetTDPSection } from 'uniswap/src/components/BridgedAsset/BridgedAssetTDPSection' -import { checkIsBridgedAsset } from 'uniswap/src/components/BridgedAsset/utils' import { ModalName } from 'uniswap/src/features/telemetry/constants' import { useCurrencyInfo } from 'uniswap/src/features/tokens/useCurrencyInfo' import { CurrencyField } from 'uniswap/src/types/currency' @@ -29,7 +27,7 @@ export function TokenDetailsBridgedAssetSection(): JSX.Element | null { }, }) }) - const isBridgedAsset = useMemo(() => currencyInfo && checkIsBridgedAsset(currencyInfo), [currencyInfo]) + const isBridgedAsset = Boolean(currencyInfo?.isBridged) if (!isBridgedAsset || !currencyInfo) { return null } diff --git a/apps/mobile/src/components/accounts/AccountHeader.tsx b/apps/mobile/src/components/accounts/AccountHeader.tsx index 040684b16b8..5b29965af69 100644 --- a/apps/mobile/src/components/accounts/AccountHeader.tsx +++ b/apps/mobile/src/components/accounts/AccountHeader.tsx @@ -1,4 +1,5 @@ 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' @@ -11,8 +12,6 @@ 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 { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' 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' @@ -39,7 +38,7 @@ const RotatingSettingsIcon = ({ onPressSettings }: { onPressSettings(): void }): if (isScreenFocused) { pressProgress.value = withDelay(50, withTiming(0)) } - }, [isScreenFocused, pressProgress]) + }, [isScreenFocused]) const tap = Gesture.Tap() .withTestId(TestID.AccountHeaderSettings) diff --git a/apps/mobile/src/components/activity/ActivityContent.tsx b/apps/mobile/src/components/activity/ActivityContent.tsx index bdbab3bf204..ed12c5ecaaa 100644 --- a/apps/mobile/src/components/activity/ActivityContent.tsx +++ b/apps/mobile/src/components/activity/ActivityContent.tsx @@ -1,6 +1,7 @@ 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 } from 'react' import type { FlatList } from 'react-native' @@ -17,14 +18,13 @@ import { openModal } from 'src/features/modals/modalSlice' import { removePendingSession } from 'src/features/walletConnect/walletConnectSlice' import { Flex, useSporeColors } from 'ui/src' import { ScannerModalState } from 'uniswap/src/components/ReceiveQRCode/constants' -import { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' import { ModalName } from 'uniswap/src/features/telemetry/constants' import { 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 @@ -55,11 +55,11 @@ export const ActivityContent = memo( const { onContentSizeChange, adaptiveFooter } = useAdaptiveFooter(containerProps?.contentContainerStyle) - const onPressReceive = (): void => { + 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 } = useActivityDataWallet({ evmOwner: owner, @@ -105,6 +105,9 @@ export const ActivityContent = memo( ListEmptyComponent={maybeEmptyComponent} ListFooterComponent={isExternalProfile ? null : adaptiveFooter} contentContainerStyle={containerProps?.contentContainerStyle} + refreshControl={refreshControl} + refreshing={refreshing} + onContentSizeChange={onContentSizeChange} /> ) : ( { // @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, }) - }, [fullWidth, scroll]) + }, [fullWidth]) return ( diff --git a/apps/mobile/src/components/explore/ExploreSections/ExploreSections.tsx b/apps/mobile/src/components/explore/ExploreSections/ExploreSections.tsx index 26d19b1252f..edda8bccac5 100644 --- a/apps/mobile/src/components/explore/ExploreSections/ExploreSections.tsx +++ b/apps/mobile/src/components/explore/ExploreSections/ExploreSections.tsx @@ -6,6 +6,7 @@ import { 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 { @@ -34,8 +35,6 @@ 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 { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' import { MobileEventName } from 'uniswap/src/features/telemetry/constants' import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' import { useAppInsets } from 'uniswap/src/hooks/useAppInsets' diff --git a/apps/mobile/src/components/explore/search/ExploreScreenSearchResultsList.tsx b/apps/mobile/src/components/explore/search/ExploreScreenSearchResultsList.tsx index 1ae943b09fb..49db351bbc5 100644 --- a/apps/mobile/src/components/explore/search/ExploreScreenSearchResultsList.tsx +++ b/apps/mobile/src/components/explore/search/ExploreScreenSearchResultsList.tsx @@ -1,3 +1,4 @@ +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' @@ -5,8 +6,6 @@ import { ESTIMATED_BOTTOM_TABS_HEIGHT } from 'src/app/navigation/tabs/CustomTabB import { Flex, flexStyles, Text, TouchableArea } from 'ui/src' import { spacing } from 'ui/src/theme' import type { UniverseChainId } from 'uniswap/src/features/chains/types' -import { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' 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' diff --git a/apps/mobile/src/components/home/HomeExploreTab.tsx b/apps/mobile/src/components/home/HomeExploreTab.tsx index a52b9881913..899a6fddaed 100644 --- a/apps/mobile/src/components/home/HomeExploreTab.tsx +++ b/apps/mobile/src/components/home/HomeExploreTab.tsx @@ -1,5 +1,6 @@ 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' @@ -15,8 +16,6 @@ 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 { DynamicConfigs, HomeScreenExploreTokensConfigKey } from 'uniswap/src/features/gating/configs' -import { useDynamicConfigValue } from 'uniswap/src/features/gating/hooks' import { isContractInputArrayType } from 'uniswap/src/features/gating/typeGuards' import { MobileEventName } from 'uniswap/src/features/telemetry/constants' import { useAppInsets } from 'uniswap/src/hooks/useAppInsets' diff --git a/apps/mobile/src/components/home/TokensTab.tsx b/apps/mobile/src/components/home/TokensTab.tsx index 9b41db908a6..b5da1f62cb5 100644 --- a/apps/mobile/src/components/home/TokensTab.tsx +++ b/apps/mobile/src/components/home/TokensTab.tsx @@ -1,4 +1,5 @@ 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' @@ -14,8 +15,6 @@ 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 { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' import { TokenBalanceListRow } from 'uniswap/src/features/portfolio/types' import { ModalName } from 'uniswap/src/features/telemetry/constants' import { CurrencyId } from 'uniswap/src/types/currency' diff --git a/apps/mobile/src/components/home/hooks.tsx b/apps/mobile/src/components/home/hooks.tsx index c216431ac85..e5b8826f637 100644 --- a/apps/mobile/src/components/home/hooks.tsx +++ b/apps/mobile/src/components/home/hooks.tsx @@ -1,6 +1,8 @@ +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' @@ -13,6 +15,9 @@ export function useAdaptiveFooter(contentContainerStyle?: StyleProp): } { 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 @@ -28,7 +33,7 @@ export function useAdaptiveFooter(contentContainerStyle?: StyleProp): } // The height of the footer added to the list can be calculated from // the following equation (for collapsed tab bar): - // maxContentHeight = TAB_BAR_HEIGHT + + footerHeight + paddingBottom + // 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 @@ -36,17 +41,17 @@ export function useAdaptiveFooter(contentContainerStyle?: StyleProp): // = contentHeight - paddingTop - paddingBottom - footerHeight // // The resulting equation is: - // footerHeight = maxContentHeight - - TAB_BAR_HEIGHT - paddingBottom - // = maxContentHeight - (contentHeight - paddingTop - paddingBottom - footerHeight) - TAB_BAR_HEIGHT - paddingBottom - // = maxContentHeight + paddingTop + footerHeight - (contentHeight + TAB_BAR_HEIGHT) + // 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 + TAB_BAR_HEIGHT) + maxContentHeight + paddingTop + footerHeight.value - (contentHeight + HEIGHT_TO_SUBTRACT) footerHeight.value = Math.max(0, calculatedFooterHeight) }, - [footerHeight, contentContainerStyle, maxContentHeight], + [contentContainerStyle, maxContentHeight, HEIGHT_TO_SUBTRACT], ) // biome-ignore lint/correctness/useExhaustiveDependencies: we want to recalculate this when activeAccount changes diff --git a/apps/mobile/src/components/home/introCards/FundWalletModal.tsx b/apps/mobile/src/components/home/introCards/FundWalletModal.tsx index 67565c140cf..35c592bf7ee 100644 --- a/apps/mobile/src/components/home/introCards/FundWalletModal.tsx +++ b/apps/mobile/src/components/home/introCards/FundWalletModal.tsx @@ -1,3 +1,4 @@ +import { FeatureFlags, useFeatureFlag } from '@universe/gating' import React, { PropsWithChildren, useCallback, useMemo } from 'react' import { useTranslation } from 'react-i18next' import { FlatList } from 'react-native' @@ -13,8 +14,6 @@ import { ActionCard, ActionCardItem } from 'uniswap/src/components/misc/ActionCa import { Modal } from 'uniswap/src/components/modals/Modal' import { ImageUri } from 'uniswap/src/components/nfts/images/ImageUri' import { useCexTransferProviders } from 'uniswap/src/features/fiatOnRamp/useCexTransferProviders' -import { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' import { ElementName, ModalName } from 'uniswap/src/features/telemetry/constants' import { usePortfolioEmptyStateBackground } from 'wallet/src/components/portfolio/empty' diff --git a/apps/mobile/src/components/home/introCards/OnboardingIntroCardStack.tsx b/apps/mobile/src/components/home/introCards/OnboardingIntroCardStack.tsx index a03ea0a749d..910a539088f 100644 --- a/apps/mobile/src/components/home/introCards/OnboardingIntroCardStack.tsx +++ b/apps/mobile/src/components/home/introCards/OnboardingIntroCardStack.tsx @@ -1,4 +1,5 @@ import { SharedEventName } from '@uniswap/analytics-events' +import { FeatureFlags, useFeatureFlag } from '@universe/gating' import React, { useCallback, useMemo } from 'react' import { useTranslation } from 'react-i18next' import { useDispatch, useSelector } from 'react-redux' @@ -12,8 +13,6 @@ import { BRIDGED_ASSETS_CARD_BANNER, PUSH_NOTIFICATIONS_CARD_BANNER } from 'ui/s import { Buy } from 'ui/src/components/icons' import { AccountType } from 'uniswap/src/features/accounts/types' import { UniverseChainId } from 'uniswap/src/features/chains/types' -import { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' 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' diff --git a/apps/mobile/src/components/layout/TabHelpers.tsx b/apps/mobile/src/components/layout/TabHelpers.tsx index 3cbea4bdbc6..e09383d79f3 100644 --- a/apps/mobile/src/components/layout/TabHelpers.tsx +++ b/apps/mobile/src/components/layout/TabHelpers.tsx @@ -1,5 +1,6 @@ /* eslint-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, @@ -14,8 +15,6 @@ 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 { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' import { TestIDType } from 'uniswap/src/test/fixtures/testIDs' import { PendingNotificationBadge } from 'wallet/src/features/notifications/components/PendingNotificationBadge' diff --git a/apps/mobile/src/components/loading/parts/WaveLoader.tsx b/apps/mobile/src/components/loading/parts/WaveLoader.tsx index 8eb6baa7881..202eb9da63b 100644 --- a/apps/mobile/src/components/loading/parts/WaveLoader.tsx +++ b/apps/mobile/src/components/loading/parts/WaveLoader.tsx @@ -19,7 +19,6 @@ export function WaveLoader(): JSX.Element { const yPosition = useSharedValue(0) const { chartHeight } = useChartDimensions() - // biome-ignore lint/correctness/useExhaustiveDependencies: only want to do this once on mount useEffect(() => { yPosition.value = withRepeat(withTiming(1, { duration: WAVE_DURATION }), Infinity, false) }, []) diff --git a/apps/mobile/src/features/datadog/DatadogProviderWrapper.tsx b/apps/mobile/src/features/datadog/DatadogProviderWrapper.tsx index b307753f982..33cc7e78ee6 100644 --- a/apps/mobile/src/features/datadog/DatadogProviderWrapper.tsx +++ b/apps/mobile/src/features/datadog/DatadogProviderWrapper.tsx @@ -8,15 +8,15 @@ import { UploadFrequency, } from '@datadog/mobile-react-native' import { ErrorEventMapper } from '@datadog/mobile-react-native/lib/typescript/rum/eventMappers/errorEventMapper' -import { PropsWithChildren, default as React, useEffect, useState } from 'react' -import { DatadogContext } from 'src/features/datadog/DatadogContext' -import { config } from 'uniswap/src/config' import { DatadogIgnoredErrorsConfigKey, DatadogIgnoredErrorsValType, DynamicConfigs, -} from 'uniswap/src/features/gating/configs' -import { getDynamicConfigValue } from 'uniswap/src/features/gating/hooks' + 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' diff --git a/apps/mobile/src/features/deepLinking/configUtils.ts b/apps/mobile/src/features/deepLinking/configUtils.ts index f6047a337dd..19231b35d75 100644 --- a/apps/mobile/src/features/deepLinking/configUtils.ts +++ b/apps/mobile/src/features/deepLinking/configUtils.ts @@ -2,10 +2,10 @@ import { DeepLinkUrlAllowlist, DeepLinkUrlAllowlistConfigKey, DynamicConfigs, + getDynamicConfigValue, UwULinkAllowlist, UwuLinkConfigKey, -} from 'uniswap/src/features/gating/configs' -import { getDynamicConfigValue } from 'uniswap/src/features/gating/hooks' +} from '@universe/gating' import { isUwULinkAllowlistType } from 'uniswap/src/features/gating/typeGuards' /** diff --git a/apps/mobile/src/features/deepLinking/deepLinkUtils.ts b/apps/mobile/src/features/deepLinking/deepLinkUtils.ts index a25138e2060..318a9e14845 100644 --- a/apps/mobile/src/features/deepLinking/deepLinkUtils.ts +++ b/apps/mobile/src/features/deepLinking/deepLinkUtils.ts @@ -1,3 +1,4 @@ +import { DeepLinkUrlAllowlist } from '@universe/gating' import { getScantasticQueryParams } from 'src/components/Requests/ScanSheet/util' import { UNISWAP_URL_SCHEME_UWU_LINK } from 'src/components/Requests/Uwulink/utils' import { getInAppBrowserAllowlist } from 'src/features/deepLinking/configUtils' @@ -8,7 +9,6 @@ import { UNISWAP_WALLETCONNECT_URL, } from 'src/features/deepLinking/constants' import { UNISWAP_WEB_HOSTNAME } from 'uniswap/src/constants/urls' -import { DeepLinkUrlAllowlist } from 'uniswap/src/features/gating/configs' import { isCurrencyIdValid } from 'uniswap/src/utils/currencyId' import { logger } from 'utilities/src/logger/logger' diff --git a/apps/mobile/src/features/deepLinking/handleDeepLinkSaga.test.ts b/apps/mobile/src/features/deepLinking/handleDeepLinkSaga.test.ts index 170a0955994..9424abf457a 100644 --- a/apps/mobile/src/features/deepLinking/handleDeepLinkSaga.test.ts +++ b/apps/mobile/src/features/deepLinking/handleDeepLinkSaga.test.ts @@ -42,13 +42,11 @@ jest.mock('expo-web-browser', () => ({ FULL_SCREEN: 'fullScreen', }, })) -jest.mock('uniswap/src/features/gating/sdk/statsig', () => ({ +jest.mock('@universe/gating', () => ({ + ...jest.requireActual('@universe/gating'), getStatsigClient: jest.fn(() => ({ checkGate: jest.fn(() => false), // Always return false to avoid Korea gate redirects })), -})) - -jest.mock('uniswap/src/features/gating/hooks', () => ({ getFeatureFlag: jest.fn(() => false), // Default to false for feature flags })) diff --git a/apps/mobile/src/features/deepLinking/handleDeepLinkSaga.ts b/apps/mobile/src/features/deepLinking/handleDeepLinkSaga.ts index a9afe70a1a0..c115585ae5b 100644 --- a/apps/mobile/src/features/deepLinking/handleDeepLinkSaga.ts +++ b/apps/mobile/src/features/deepLinking/handleDeepLinkSaga.ts @@ -1,4 +1,5 @@ import { createAction } from '@reduxjs/toolkit' +import { FeatureFlags, getFeatureFlagName, getStatsigClient } from '@universe/gating' import { parseUri } from '@walletconnect/utils' import { Alert } from 'react-native' import { navigate } from 'src/app/navigation/rootNavigation' @@ -29,8 +30,6 @@ import { waitForWcWeb3WalletIsReady } from 'src/features/walletConnect/walletCon import { addRequest, setDidOpenFromDeepLink } from 'src/features/walletConnect/walletConnectSlice' import { call, delay, put, select, takeLatest } from 'typed-redux-saga' import { AccountType } from 'uniswap/src/features/accounts/types' -import { FeatureFlags, getFeatureFlagName } from 'uniswap/src/features/gating/flags' -import { getStatsigClient } from 'uniswap/src/features/gating/sdk/statsig' import { MobileEventName, ModalName } from 'uniswap/src/features/telemetry/constants' import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' import i18n from 'uniswap/src/i18n' diff --git a/apps/mobile/src/features/deepLinking/handleOnRampReturnLinkSaga.ts b/apps/mobile/src/features/deepLinking/handleOnRampReturnLinkSaga.ts index da904ede2ea..8edff2d8379 100644 --- a/apps/mobile/src/features/deepLinking/handleOnRampReturnLinkSaga.ts +++ b/apps/mobile/src/features/deepLinking/handleOnRampReturnLinkSaga.ts @@ -1,9 +1,8 @@ +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 { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { getFeatureFlag } from 'uniswap/src/features/gating/hooks' import { forceFetchFiatOnRampTransactions } from 'uniswap/src/features/transactions/slice' import { MobileScreens } from 'uniswap/src/types/screens/mobile' diff --git a/apps/mobile/src/features/deepLinking/handleTransactionLinkSaga.ts b/apps/mobile/src/features/deepLinking/handleTransactionLinkSaga.ts index fefafa0b2dd..c6e5d16b175 100644 --- a/apps/mobile/src/features/deepLinking/handleTransactionLinkSaga.ts +++ b/apps/mobile/src/features/deepLinking/handleTransactionLinkSaga.ts @@ -1,9 +1,8 @@ +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 { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { getFeatureFlag } from 'uniswap/src/features/gating/hooks' import { MobileScreens } from 'uniswap/src/types/screens/mobile' export function* handleTransactionLink() { diff --git a/apps/mobile/src/features/lockScreen/LockScreenModal.tsx b/apps/mobile/src/features/lockScreen/LockScreenModal.tsx index e1c19214daf..91addda367a 100644 --- a/apps/mobile/src/features/lockScreen/LockScreenModal.tsx +++ b/apps/mobile/src/features/lockScreen/LockScreenModal.tsx @@ -1,3 +1,4 @@ +import { FeatureFlags, useFeatureFlag } from '@universe/gating' import { BlurView } from 'expo-blur' import React, { memo } from 'react' import { useTranslation } from 'react-i18next' @@ -15,8 +16,6 @@ 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 { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' import { useAppInsets } from 'uniswap/src/hooks/useAppInsets' import { isAndroid } from 'utilities/src/platform' import { useEvent } from 'utilities/src/react/hooks' diff --git a/apps/mobile/src/features/send/SendFormButton.tsx b/apps/mobile/src/features/send/SendFormButton.tsx index 2b7019008c5..82bf9ea84db 100644 --- a/apps/mobile/src/features/send/SendFormButton.tsx +++ b/apps/mobile/src/features/send/SendFormButton.tsx @@ -2,7 +2,6 @@ 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 { checkIsBridgedAsset } from 'uniswap/src/components/BridgedAsset/utils' import { WarningLabel } from 'uniswap/src/components/modals/WarningModal/types' import { nativeOnChain } from 'uniswap/src/constants/tokens' import { AccountType } from 'uniswap/src/features/accounts/types' @@ -60,7 +59,7 @@ export function SendFormButton({ const { tokenWarningDismissed: isCompatibleAddressDismissed } = useDismissedCompatibleAddressWarnings( currencyInInfo?.currency, ) - const isUnichainBridgedAsset = checkIsBridgedAsset(currencyInInfo ?? undefined) && !isCompatibleAddressDismissed + const isUnichainBridgedAsset = Boolean(currencyInInfo?.isBridged) && !isCompatibleAddressDismissed const insufficientGasFunds = warnings.warnings.some((warning) => warning.type === WarningLabel.InsufficientGasFunds) diff --git a/apps/mobile/src/features/wallet/useWalletRestore.ts b/apps/mobile/src/features/wallet/useWalletRestore.ts index 00c8d30154b..d0e4e6214aa 100644 --- a/apps/mobile/src/features/wallet/useWalletRestore.ts +++ b/apps/mobile/src/features/wallet/useWalletRestore.ts @@ -1,9 +1,8 @@ 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 { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' import { ModalName } from 'uniswap/src/features/telemetry/constants' import { logger } from 'utilities/src/logger/logger' import { useSignerAccounts } from 'wallet/src/features/wallet/hooks' diff --git a/apps/mobile/src/features/walletConnect/batchedTransactionSaga.ts b/apps/mobile/src/features/walletConnect/batchedTransactionSaga.ts index 15cd9d3e14b..ae825ac1a3d 100644 --- a/apps/mobile/src/features/walletConnect/batchedTransactionSaga.ts +++ b/apps/mobile/src/features/walletConnect/batchedTransactionSaga.ts @@ -1,4 +1,5 @@ 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' @@ -7,8 +8,6 @@ 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 { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { getFeatureFlag } from 'uniswap/src/features/gating/hooks' 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' diff --git a/apps/mobile/src/features/walletConnect/saga.ts b/apps/mobile/src/features/walletConnect/saga.ts index ed6d1af97bb..b52b1b8d603 100644 --- a/apps/mobile/src/features/walletConnect/saga.ts +++ b/apps/mobile/src/features/walletConnect/saga.ts @@ -1,6 +1,7 @@ /* eslint-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' @@ -39,8 +40,6 @@ 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 { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { getFeatureFlag } from 'uniswap/src/features/gating/hooks' 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' diff --git a/apps/mobile/src/screens/ActivityScreen.tsx b/apps/mobile/src/screens/ActivityScreen.tsx index d3005fa1c06..5ab318ab1fa 100644 --- a/apps/mobile/src/screens/ActivityScreen.tsx +++ b/apps/mobile/src/screens/ActivityScreen.tsx @@ -2,16 +2,16 @@ import { useApolloClient } from '@apollo/client' import { useScrollToTop } from '@react-navigation/native' import { useQuery } from '@tanstack/react-query' import { GQLQueries } from '@universe/api' +import { FeatureFlags, useFeatureFlag } from '@universe/gating' import { useCallback, 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 { useAppStateTrigger } from 'src/utils/useAppStateTrigger' import { Text } from 'ui/src' import { spacing } from 'ui/src/theme' -import { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' 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' @@ -49,6 +49,13 @@ export function ActivityScreen(): JSX.Element { const { refreshing, onRefreshActivityData } = useRefreshActivityData(activeAccount.address) + // Automatically refresh activity data when app comes to foreground + useAppStateTrigger({ + from: 'background', + to: 'active', + callback: onRefreshActivityData, + }) + const insets = useAppInsets() const isBottomTabsEnabled = useFeatureFlag(FeatureFlags.BottomTabs) diff --git a/apps/mobile/src/screens/AppLoadingScreen.tsx b/apps/mobile/src/screens/AppLoadingScreen.tsx index 27192763892..10f97edf4aa 100644 --- a/apps/mobile/src/screens/AppLoadingScreen.tsx +++ b/apps/mobile/src/screens/AppLoadingScreen.tsx @@ -1,4 +1,5 @@ import { 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' @@ -15,8 +16,6 @@ import { import { useHideSplashScreen } from 'src/features/splashScreen/useHideSplashScreen' import { RecoveryWalletInfo, useOnDeviceRecoveryData } from 'src/screens/Import/useOnDeviceRecoveryData' import { AccountType } from 'uniswap/src/features/accounts/types' -import { DynamicConfigs, OnDeviceRecoveryConfigKey } from 'uniswap/src/features/gating/configs' -import { useDynamicConfigValue } from 'uniswap/src/features/gating/hooks' import { MobileEventName } from 'uniswap/src/features/telemetry/constants' import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' import Trace from 'uniswap/src/features/telemetry/Trace' diff --git a/apps/mobile/src/screens/ExploreScreen.tsx b/apps/mobile/src/screens/ExploreScreen.tsx index ff0cbf76986..136fc7bcaa6 100644 --- a/apps/mobile/src/screens/ExploreScreen.tsx +++ b/apps/mobile/src/screens/ExploreScreen.tsx @@ -1,5 +1,6 @@ import { useIsFocused, useNavigation, 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' @@ -16,8 +17,6 @@ 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 { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' 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' @@ -98,7 +97,7 @@ export function ExploreScreen(): JSX.Element { }) return unsubscribe - }, [isBottomTabsEnabled, navigation, listRef]) + }, [isBottomTabsEnabled, navigation]) // TODO(WALL-5482): investigate list rendering performance/scrolling issue const canRenderList = useRenderNextFrame(isSheetReady && !isSearchMode) diff --git a/apps/mobile/src/screens/HomeScreen/HomeScreen.tsx b/apps/mobile/src/screens/HomeScreen/HomeScreen.tsx index c57c24a05d1..e9d87ab7df3 100644 --- a/apps/mobile/src/screens/HomeScreen/HomeScreen.tsx +++ b/apps/mobile/src/screens/HomeScreen/HomeScreen.tsx @@ -2,6 +2,7 @@ import { useApolloClient } from '@apollo/client' import { useIsFocused, useScrollToTop } from '@react-navigation/native' import { SharedQueryClient } from '@universe/api' +import { FeatureFlags, useFeatureFlag } from '@universe/gating' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Freeze } from 'react-freeze' import { useTranslation } from 'react-i18next' @@ -52,8 +53,6 @@ import { NFTS_TAB_DATA_DEPENDENCIES } from 'uniswap/src/components/nfts/constant import { getPortfolioQuery } from 'uniswap/src/data/rest/getPortfolio' import { getListTransactionsQuery } from 'uniswap/src/data/rest/listTransactions' import { AccountType } from 'uniswap/src/features/accounts/types' -import { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' 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' diff --git a/apps/mobile/src/screens/HomeScreen/HomeScreenQuickActions.tsx b/apps/mobile/src/screens/HomeScreen/HomeScreenQuickActions.tsx index 30cf4f84917..44b5ac900e0 100644 --- a/apps/mobile/src/screens/HomeScreen/HomeScreenQuickActions.tsx +++ b/apps/mobile/src/screens/HomeScreen/HomeScreenQuickActions.tsx @@ -1,3 +1,4 @@ +import { FeatureFlags, useFeatureFlag } from '@universe/gating' import React, { useCallback, useMemo } from 'react' import { useTranslation } from 'react-i18next' import { FlatList, ListRenderItemInfo } from 'react-native' @@ -10,8 +11,6 @@ import { ArrowDownCircle, Bank, SendAction, SwapDotted } from 'ui/src/components 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 { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' import { useHapticFeedback } from 'uniswap/src/features/settings/useHapticFeedback/useHapticFeedback' import { ElementName, MobileEventName, ModalName } from 'uniswap/src/features/telemetry/constants' import { Trace } from 'uniswap/src/features/telemetry/Trace' diff --git a/apps/mobile/src/screens/Import/ImportMethodScreen.tsx b/apps/mobile/src/screens/Import/ImportMethodScreen.tsx index 572ca36bbcc..fe00d212b5d 100644 --- a/apps/mobile/src/screens/Import/ImportMethodScreen.tsx +++ b/apps/mobile/src/screens/Import/ImportMethodScreen.tsx @@ -1,4 +1,5 @@ import { NativeStackScreenProps } from '@react-navigation/native-stack' +import { FeatureFlags, useFeatureFlag } from '@universe/gating' import React, { useState } from 'react' import { useTranslation } from 'react-i18next' import { navigate } from 'src/app/navigation/rootNavigation' @@ -17,8 +18,6 @@ import { Flex, SpinningLoader, Text, TouchableArea } from 'ui/src' import { Eye, WalletFilled } from 'ui/src/components/icons' import { useIsDarkMode } from 'ui/src/hooks/useIsDarkMode' import { iconSizes } from 'ui/src/theme' -import { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' import { authenticateWithPasskeyForSeedPhraseExport } from 'uniswap/src/features/passkey/embeddedWallet' import { ElementName, ModalName } from 'uniswap/src/features/telemetry/constants' import Trace from 'uniswap/src/features/telemetry/Trace' diff --git a/apps/mobile/src/screens/Import/OnDeviceRecoveryScreen.tsx b/apps/mobile/src/screens/Import/OnDeviceRecoveryScreen.tsx index 95c14285751..0172fcfffc9 100644 --- a/apps/mobile/src/screens/Import/OnDeviceRecoveryScreen.tsx +++ b/apps/mobile/src/screens/Import/OnDeviceRecoveryScreen.tsx @@ -1,6 +1,7 @@ import { NativeStackScreenProps } from '@react-navigation/native-stack' import { ReactNavigationPerformanceView } from '@shopify/react-native-performance-navigation' import { SharedEventName } from '@uniswap/analytics-events' +import { DynamicConfigs, OnDeviceRecoveryConfigKey, useDynamicConfigValue } from '@universe/gating' import dayjs from 'dayjs' import React, { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -20,8 +21,6 @@ import { iconSizes } from 'ui/src/theme' import { WarningSeverity } from 'uniswap/src/components/modals/WarningModal/types' import { WarningModal } from 'uniswap/src/components/modals/WarningModal/WarningModal' import { AccountType } from 'uniswap/src/features/accounts/types' -import { DynamicConfigs, OnDeviceRecoveryConfigKey } from 'uniswap/src/features/gating/configs' -import { useDynamicConfigValue } from 'uniswap/src/features/gating/hooks' import { Platform } from 'uniswap/src/features/platforms/types/Platform' import { ElementName, ModalName } from 'uniswap/src/features/telemetry/constants' import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' diff --git a/apps/mobile/src/screens/Import/RestoreMethodScreen.tsx b/apps/mobile/src/screens/Import/RestoreMethodScreen.tsx index 994f0ce214b..d4b12c53b99 100644 --- a/apps/mobile/src/screens/Import/RestoreMethodScreen.tsx +++ b/apps/mobile/src/screens/Import/RestoreMethodScreen.tsx @@ -1,4 +1,5 @@ import { NativeStackScreenProps } from '@react-navigation/native-stack' +import { FeatureFlags, useFeatureFlag } from '@universe/gating' import React from 'react' import { useTranslation } from 'react-i18next' import { OnboardingStackParamList } from 'src/app/navigation/types' @@ -13,8 +14,6 @@ import { useNavigationHeader } from 'src/utils/useNavigationHeader' import { Flex, Text, TouchableArea } from 'ui/src' import { WalletFilled } from 'ui/src/components/icons' import { useIsDarkMode } from 'ui/src/hooks/useIsDarkMode' -import { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' 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' diff --git a/apps/mobile/src/screens/Import/SelectWalletScreen.tsx b/apps/mobile/src/screens/Import/SelectWalletScreen.tsx index 8e0f065ad69..a661b53a19c 100644 --- a/apps/mobile/src/screens/Import/SelectWalletScreen.tsx +++ b/apps/mobile/src/screens/Import/SelectWalletScreen.tsx @@ -1,4 +1,5 @@ import { NativeStackScreenProps } from '@react-navigation/native-stack' +import { FeatureFlags, useFeatureFlag } from '@universe/gating' import React, { ComponentProps, useCallback } from 'react' import { Trans, useTranslation } from 'react-i18next' import { ScrollView } from 'react-native' @@ -10,8 +11,6 @@ import { Button, Flex, Loader, Text, TouchableArea, useLayoutAnimationOnChange } import { WalletFilled } from 'ui/src/components/icons' import { spacing } from 'ui/src/theme' import { BaseCard } from 'uniswap/src/components/BaseCard/BaseCard' -import { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' 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' diff --git a/apps/mobile/src/screens/Onboarding/LandingScreen.tsx b/apps/mobile/src/screens/Onboarding/LandingScreen.tsx index e18fd5f0ac5..d4871955da3 100644 --- a/apps/mobile/src/screens/Onboarding/LandingScreen.tsx +++ b/apps/mobile/src/screens/Onboarding/LandingScreen.tsx @@ -1,5 +1,6 @@ import { NativeStackScreenProps } from '@react-navigation/native-stack' import { ReactNavigationPerformanceView } from '@shopify/react-native-performance-navigation' +import { FeatureFlags, useFeatureFlag } from '@universe/gating' import React, { useCallback, useEffect } from 'react' import { useTranslation } from 'react-i18next' import { useAnimatedStyle, useSharedValue, withDelay, withTiming } from 'react-native-reanimated' @@ -12,8 +13,6 @@ import { TermsOfService } from 'src/screens/Onboarding/TermsOfService' import { Button, Flex, Text, TouchableArea } from 'ui/src' import { AnimatedFlex } from 'ui/src/components/layout/AnimatedFlex' import { useEnabledChains } from 'uniswap/src/features/chains/hooks/useEnabledChains' -import { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' import { setIsTestnetModeEnabled } from 'uniswap/src/features/settings/slice' import { ElementName, ModalName } from 'uniswap/src/features/telemetry/constants' import Trace from 'uniswap/src/features/telemetry/Trace' @@ -40,7 +39,7 @@ export function LandingScreen({ navigation }: Props): JSX.Element { useEffect(() => { // disables looping animation during e2e tests which was preventing js thread from idle actionButtonsOpacity.value = withDelay(LANDING_ANIMATION_DURATION, withTiming(1, { duration: ONE_SECOND_MS })) - }, [actionButtonsOpacity]) + }, []) // Disables testnet mode on mount if enabled (eg upon removing a wallet) useEffect(() => { diff --git a/apps/mobile/src/screens/SettingsScreen.tsx b/apps/mobile/src/screens/SettingsScreen.tsx index 87db6bed817..8f4d3e83b9c 100644 --- a/apps/mobile/src/screens/SettingsScreen.tsx +++ b/apps/mobile/src/screens/SettingsScreen.tsx @@ -1,4 +1,5 @@ import { useNavigation } from '@react-navigation/core' +import { FeatureFlags, useFeatureFlag } from '@universe/gating' import { default as React, useCallback, useMemo } from 'react' import { useTranslation } from 'react-i18next' import { ListRenderItemInfo } from 'react-native' @@ -56,8 +57,6 @@ import { iconSizes } from 'ui/src/theme' import { uniswapUrls } from 'uniswap/src/constants/urls' import { useEnabledChains } from 'uniswap/src/features/chains/hooks/useEnabledChains' import { useAppFiatCurrencyInfo } from 'uniswap/src/features/fiatCurrency/hooks' -import { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' import { useCurrentLanguageInfo } from 'uniswap/src/features/language/hooks' import { setIsTestnetModeEnabled } from 'uniswap/src/features/settings/slice' import { useHapticFeedback } from 'uniswap/src/features/settings/useHapticFeedback/useHapticFeedback' diff --git a/apps/mobile/src/screens/TokenDetailsScreen.tsx b/apps/mobile/src/screens/TokenDetailsScreen.tsx index 37413162e96..83a20f7ec10 100644 --- a/apps/mobile/src/screens/TokenDetailsScreen.tsx +++ b/apps/mobile/src/screens/TokenDetailsScreen.tsx @@ -25,7 +25,6 @@ import { Flex, Separator } from 'ui/src' import { ArrowDownCircle, ArrowUpCircle, Bank, SendRoundedAirplane } from 'ui/src/components/icons' import { AnimatedFlex } from 'ui/src/components/layout/AnimatedFlex' import { BaseCard } from 'uniswap/src/components/BaseCard/BaseCard' -import { getBridgedAsset } from 'uniswap/src/components/BridgedAsset/utils' import type { MenuOptionItem } from 'uniswap/src/components/menus/ContextMenuV2' import { PollingInterval } from 'uniswap/src/constants/misc' import { useCrossChainBalances } from 'uniswap/src/data/balances/hooks/useCrossChainBalances' @@ -296,7 +295,7 @@ const TokenDetailsActionButtonsWrapper = memo(function _TokenDetailsActionButton }, 300) // delay is needed to prevent menu from not closing properly }, [currencyInfo]) - const bridgedAsset = getBridgedAsset(currencyInfo) + const bridgedWithdrawalInfo = currencyInfo?.bridgedWithdrawalInfo const isScreenNavigationReady = useIsScreenNavigationReady({ navigation }) @@ -320,12 +319,12 @@ const TokenDetailsActionButtonsWrapper = memo(function _TokenDetailsActionButton actions.push({ label: t('common.button.buy'), Icon: Bank, onPress: () => onPressBuyFiatOnRamp() }) } - if (!!bridgedAsset && hasTokenBalance) { + if (bridgedWithdrawalInfo && hasTokenBalance) { actions.push({ label: t('common.withdraw'), Icon: ArrowUpCircle, onPress: () => onPressWithdraw(), - subheader: t('bridgedAsset.wormhole.toNativeChain', { nativeChainName: bridgedAsset.nativeChain }), + subheader: t('bridgedAsset.wormhole.toNativeChain', { nativeChainName: bridgedWithdrawalInfo.chain }), actionType: 'external-link', height: 56, }) @@ -346,7 +345,7 @@ const TokenDetailsActionButtonsWrapper = memo(function _TokenDetailsActionButton }, [ fiatOnRampCurrency, t, - bridgedAsset, + bridgedWithdrawalInfo, hasTokenBalance, onPressWithdraw, onPressSend, diff --git a/apps/mobile/src/screens/ViewPrivateKeys/ViewPrivateKeysScreen.tsx b/apps/mobile/src/screens/ViewPrivateKeys/ViewPrivateKeysScreen.tsx index 618d1ae0c4d..5d5bf6a3765 100644 --- a/apps/mobile/src/screens/ViewPrivateKeys/ViewPrivateKeysScreen.tsx +++ b/apps/mobile/src/screens/ViewPrivateKeys/ViewPrivateKeysScreen.tsx @@ -1,5 +1,6 @@ import { CommonActions } from '@react-navigation/core' import { NativeStackScreenProps } from '@react-navigation/native-stack' +import { FeatureFlags, useFeatureFlag } from '@universe/gating' import React, { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { ScrollView } from 'react-native-gesture-handler' @@ -17,8 +18,6 @@ import { AlertTriangleFilled } from 'ui/src/components/icons/AlertTriangleFilled import { HiddenWordView } from 'ui/src/components/placeholders/HiddenWordView' import { AddressDisplay } from 'uniswap/src/components/accounts/AddressDisplay' import { Modal } from 'uniswap/src/components/modals/Modal' -import { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' 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' diff --git a/apps/mobile/tsconfig.json b/apps/mobile/tsconfig.json index 0f4eef55ad2..94f3a6d681f 100644 --- a/apps/mobile/tsconfig.json +++ b/apps/mobile/tsconfig.json @@ -15,6 +15,9 @@ }, { "path": "../../packages/api" + }, + { + "path": "../../packages/gating" } ], "compilerOptions": { diff --git a/apps/web/.eslintrc.js b/apps/web/.eslintrc.js index 331eff80908..3f9abcfcb2b 100644 --- a/apps/web/.eslintrc.js +++ b/apps/web/.eslintrc.js @@ -21,6 +21,32 @@ module.exports = { }, overrides: [ + { + // Portfolio pages must not use useAccount directly. Use usePortfolioAddress (or a domain-specific hook) instead. + files: ['src/pages/Portfolio/*.{ts,tsx}', 'src/pages/Portfolio/**/*.{ts,tsx}'], + rules: { + 'no-restricted-imports': [ + 'error', + { + paths: [ + { + name: 'hooks/useAccount', + message: + "Do not import 'useAccount' in portfolio pages. Use 'pages/Portfolio/hooks/usePortfolioAddress' (or a domain-specific hook) instead.", + }, + ], + }, + ], + 'no-restricted-syntax': [ + 'error', + { + selector: 'CallExpression[callee.name="useAccount"]', + message: + "Do not call 'useAccount' in portfolio pages. Use 'pages/Portfolio/hooks/usePortfolioAddress' (or a domain-specific hook) instead.", + }, + ], + }, + }, { files: [ 'src/index.tsx', diff --git a/apps/web/package.json b/apps/web/package.json index 6cd0ce8266c..b06f96e8d51 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -187,8 +187,8 @@ "@types/react-scroll-sync": "0.9.0", "@uniswap/analytics": "1.7.2", "@uniswap/analytics-events": "2.43.0", + "@uniswap/client-data-api": "0.0.18", "@uniswap/client-explore": "0.0.17", - "@uniswap/client-pools": "0.0.17", "@uniswap/merkle-distributor": "1.0.1", "@uniswap/permit2-sdk": "1.3.0", "@uniswap/router-sdk": "2.0.2", @@ -203,6 +203,7 @@ "@uniswap/v3-sdk": "3.25.2", "@uniswap/v4-sdk": "1.21.2", "@universe/api": "workspace:^", + "@universe/gating": "workspace:^", "@visx/group": "2.17.0", "@visx/responsive": "3.12.0", "@visx/shape": "2.18.0", diff --git a/apps/web/project.json b/apps/web/project.json index 9d2524a729b..a49be623dc7 100644 --- a/apps/web/project.json +++ b/apps/web/project.json @@ -19,7 +19,7 @@ "command": "nx playwright:test web", "cwd": "{projectRoot}" }, - "dependsOn": ["anvil:base", "preview", "wait-for-webserver"] + "dependsOn": ["anvil:mainnet", "preview", "wait-for-webserver"] }, "e2e:anvil": { "executor": "nx:run-commands", @@ -27,7 +27,7 @@ "command": "nx playwright:test:anvil web", "cwd": "{projectRoot}" }, - "dependsOn": ["anvil:base", "preview", "wait-for-webserver"] + "dependsOn": ["anvil:mainnet", "preview", "wait-for-webserver"] }, "e2e:no-anvil": { "executor": "nx:run-commands", diff --git a/apps/web/public/images/portfolio_page_promo/dark.png b/apps/web/public/images/portfolio_page_promo/dark.png new file mode 100644 index 0000000000000000000000000000000000000000..910c183e24deb9b43730d54843d9036769a12733 GIT binary patch literal 232460 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%0APxrg1&5$NyZKw4;**he-L2uztDq8}u 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 literal 0 HcmV?d00001 diff --git a/apps/web/public/images/portfolio_page_promo/light.png b/apps/web/public/images/portfolio_page_promo/light.png new file mode 100644 index 0000000000000000000000000000000000000000..0387c71c436244e8e3b8aa406dc0345e9dd3c1c4 GIT binary patch literal 279063 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

H0!@vNK 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�smaIsoI)3 z6bk@MaDgp#aeX=Su|vMi=UE{rE!BY`tfLPrV}5{InFW5T`$u*l1msec3r0Gmuip5o zH#>CBw(s4M=JmIHYv`M&Ro0W)U;skurnV~5lT0kklSin2D=M`H8qKNsw=KjM^9=g!)hxPFp)lB1i@w6rXJ+V3c>O%hn z);X10(0G0w88)%X_w6eYW`3u*--@C>#3UiIoUb_zfg`o|pB%Q{L#LtA5lM<=6ef2K zUgjbC(_k zNqz@r(QZ)y2v(3ZqVmwjy-c5_0QWzwa{%^GPp2XsQ-lS7l?JJOYwbc98GpMT5 zA9op?mz1uj6}!C;cvl{AY<<}EA9(n3<&AjO8;eone1h^`pU|+f;w1DhK9r?PFf??m zAtA{!l-4_zr7u;a+E7p|c}jmQ0U|;254(MC6-I=&Bj)e{8J}FQoE}-=vbPqeR5~g2=#* zi3cNqU@5zfvQ*`iw+OP2{N>m^SDh_)+;dduK=fERLWEj~zNnAOAIsR?r07xWdu?7$ zl?^9Gj9Muz{+6cS%c2)SfRS>tU9o9bkX~%88r);nidGbLy+0}<qWU?ZR|y)G#$xbTIY zeIt~p!4OK|tLOXvn#z_@xe#B-;ch}TYW?)Hoq;MzofberJZMnc1yZG&q=U>b9#N)} zUw*a;o&-*cb$vZOus{O{trIg`u@P5*tJ8lrqnTK3PR$|%YsJS(2ob0L9%Z$jXe9Vf z7YXDy`F{Ae9YR=6s`h3EqrZ{}t-6N*f{w<0cvhPxD17-9K3@#5B$*vo5yDSJSZZ;= zxJamncEA>MTKx=*Lr%cFx-i7DDFj8bZd+5vfT8ayL2%SfH#kgHU_&pUy;aF8#r+JR2GDXrs{Fqr6Gch3^M4ANJ3kh{AzgJhz5D= z_6!eXgIF>^h-9mw9VozFBA>uWIIyo&(Nx5PGf)(=Wg=W_$Qg?#I(2b|fy%0GQDQCz zv2B|W)*MdM>!XWf#*##OG9sRk<+Xgi9%~(F=z6K!qF-u)*TbD8RHH$G`rI*v1D1Aq zK#WZp)9D2>?#|y=JT0?VBJeuUMe%13$eXuJ+|DtyApXUE%9Pk|bOlR1)Hnv2S5TVp zXxVh`4SdjqC7sW{)2lDfcur*jQC~pK5YWk+Wxt8{Nf{gx4D&N=$QX4gGF=)O?)>omx06pGbZdiaU-AL^}*OBgico zA_j~Q&WRH(%eX+%6x^o|^5TXRO7fz5S8nG$>QNEXr?Z1O6%rQ;i8|#u4YisWt4K1n znVS&8Ta>o1}{?)%cy8B5Wl8Y!k zNMupf&s8ju$O0^vE-1i*=5|VLk=8cOl4#fnnzxuGU4{-jP=()0tHAd4F+-Mqy>qj} zDkh!k>H1xgV034fiHiUd0RaK~v?Hq#05JZ#76)5lb$<}o?)!mTgMOeIUbL;fP?gkp zSk|6sqxkf0zGc3|U6gHsFcM9xBo|#Ol6%ZyLD^d3i;9GkLNiVC6N62!d{< z@E}Lcv8W0=YdWE<(Fz>6K(R19GSVKvC%rrl(u?&UW?J1;ibrp8DL^P`pSS4k3Z5Uo zn+YiiZjl{fn?*$f=*VBqQAaAh9 ziPc?mJj-^^$mTJ-8R%;(7UEiMSq{O~;pAw@(IewpxCcZ?(2mqvWn-LJ1p7Y&Wv5T2 z+aZ>r^{?bzzM2}PvI``wNpTiM*urMHpA%f#}#!v{upc$XyBw zCYZ~M7b0sHlu|xuzd6;TLc^$e{O!hXFDyh>h3pgWf zK0Pj+wT|&7C24rkGL{rf6J_nK`;t&^hcvBXkIDbwS%o2%6vSTf?{n&l0p>ir|#^WoqFG-o@xHm;o+Qj0I+ zPF%uy7H?0Hc%XT3gI06OWD}|iX-TU4DZ{DWI6LN@T6lE z{7sSqC8lmH5Km(R0FUJ7+wmp4k}6>ic@0U-=-2e~TZwI&4r@J8@?R!RI9F~vk^1Qz zkN8la6l`hYt#g!u@!JRmvIvm5CyRHd__vu(f7}u*>E=C~!vE5?-?9c;x%U6n-wr{U zd9Of6KNy6G5FH$aj~vZZP}cZtZp%40OipBnaL_svVQ@^pPUao^_1iN&pyf$$nWkcD zg;eZ6cL?niOYp`fpCmPGj^Qd$x1`PWY%_3JZSYnn4s7Z(+uI7!SHRd>YjkDak4Oa2 z*_3fXCt?k&=eV+bV(A(y2TT}K!ei${1UbM=IJVCr^+U*Fl?yU}CxVLl%q&4DGe=S7 zt)khNn6y`E9AEzyOA=^Vd^M(_K!O@~1?H!gh6+Vtonrf+pa4}_rhk1AM}i9f<|em7 zXgV`)NhwTu=K%ZFwLPb93QmIvj2EW6C!?ud#A`{!cHPq<9uH)_}cYY)e^u8=zJh9GXvNreztupPRQp(zG$*X-AKkaFyx1ysM+{94N=7o34y zMT?azBjot1G3kg|s8g_izJ7)S*!&EkBZCKtE*e4o0<9zFd_$LHq!)(E1OGb5OJ}^L z53pAE2R+@>GBpzE;}m#NGSq`v4BazTP_XEU(HB)cqiOU2en0_*9-p}EAk+Bv$uXLe zsh{_|!Ucrku<{fOw!UAu#Vf`Z*Ne5?k`qDbK=BR@e2$-R9~e_?e~OmH$~pe*{qY+3 zeF6PljOE00KJtgaYC4N{vV7QA=4`g;xl+t$3Dg3 z(}O<;;7hb^I-B3P9gXshRIab`wpAt!GEFIPn z?|)%|D3?-sp;PQ*C!?bPX$?VZZoXGi65IUQHXYpg2o@XT5r|L^ z;%R*YGI)JHcd-Qu%aHgEL~pt)@c=y$k-+sWd?C(fsXwA*DYayVSsiy6_e){-z>?`( zLwwu$9ys@#>ysHduCT?ccbTqvl?X0=r057)zyXB4viyWj5Xnd|KEhd*;d<|r686w)0cX$^1! zb2eOu(g1GGaJe6@TlpJe5hRH6p=9VIM4F~7WyF-L^{YY}&)9cFcf~0Dd#%U|e^2h{ z)uj@(Z+*pC6ODUR5<6cO2?-S<6M*pP*xqJ$X8R+kr~JtWhzZmfm)qpE)n=^I zDSdm20;;U((k4`3$^YsZAG{@liD6_jo0bd8TGDSImZFchjV)OkSBX`xe$^=Ri|l(3 zmwHsBI2zDP{>r?%Y%_d`*xs>Of6afMMa+BZFKNa%QoS`4eb8&N6X$~pSBmGHt-lUR z9^ji!iSW&|)#}+pr^`xHX1{76*P9eW4*|&ur(cjg#=kA7e)amhcI!JA9GX{84qh zp_mG6*ya*6FvMs#cQb-yqvmpxw-Fo1bJ9JbxwfA|5Way5hgY`ihzG!i1C0@{8BUr3 z10ngF(@&5`zIK#0y?91C7C()ki|Insh#JLL8()F0=!0|%B`1VZ?l%Zk zzgY}F?$i)}Ya{4@b>IZyl+}I6fWlP-q!{Oq@_H1#BachI2KOV zO7)oy>GtgXi%J_NsF?{wmi6T!h7!o)3({e{*~G8<1_R8{I@DdM8#cPIJE&{1eyf?; z(EyAqRA?NhaIk(m@J{f1YP%6#hmUL;M|Op_so(yS`+j1umV294WIG%!MLDl;g_0eE4(+}Y*!g3XG)*p z=!tlka4$D)YjxO=JV(gOQ78sr#m_LqgWG;R29&%=atW%7+VY%qMucsif3+U*osuZJ z*{03bmw{3GLtJ6jKvjef?f$05|W!xL_8ra|ew5a?Pkt-UT z4K74S%XGWq)KrLO4z&p_{My=8jLrf6O-fwu`)0V56&r6=oFts$TRcu5!vpNW6oEjo zPVOQzzW_u+toQOEMF1Hn9CVVe?R}>=vp8~T$T={b2D&GSmX$PrAa8z5#0f9?W7#&0 zrC%wAT6~z@hdt%Qoe(IVr^2@*zJxav`xN*uJvMkQYx7N%^EZDDmLCJA*&~C*6Z2l_ z+H?6IySpeBI^<;)!0M*Sa#Ul?Q9-FX269t0npDP41kqYQ;hu?>&lP zkkdWxM*bs_A*Ub!-3OqrHoaXdaf$#Muz#xP^)-D!;N=S%==CcD9Dm=S$e)_F;@3oY ziNAk_(&PDM%OxU1eedac;1NseCx8K$0?6?MVpxdLilabR^Tr!jUMx;mQVF$txUmSw z-4^oyB3Rn|gesQrggtb1%DDtP%f-Da;5!VM$)xyft3`IUd8)Fvf1yzFVm`a9{9qPj zWt_k#IOnP1*>zb}|K||v^L`=kK1o=J>&tP(4o}&wL%hkoUwgfTGVmay4K7@!u(LEg z#Z-}Cz4P60#98JcI+B?Z=A7)|Q|=abWvf8ug%h;dGG=#^QzoX@5$d)fd+Hhc;N*AJ z6z@&=AV2SGJ+njv>eEGnEs8yyXCfm%hitmxExL62?ody}h>nf?+12)Tu-$4H9NGT# zVlbZ3FH!P4p>&Gw;z>{l^g@8!uWND-+Yk~*^?!jWP{Yr^9^BbmbvLtwlF!e56<0@2 z{N?>_D3Y>&D7eGU-af@^#aWul75`@G0gev!-%DORtv`g*Qir;^YA40?T{E9?)JRxv zs1qtpd2A?ki}SpYyKeNpEa+$^cnS=SSAT46>1(vfa=}#VV7tjxfYj@PM!o64b-!D( zDXiwt;(#-ZEz92}tL)_(MfI%{Kb*ywu0p5FA7Vaz5~Wt{dXD}9f|_qoF2UXKHaMT- zQ`{f@6S&DHVIn$Q0%5Yz`QtqZYqoSzYq#)PhVjXki4dt=St+tk3)Z@yg6oH7@*+_E zZwF)=*9Zevy6nrWf%B)AyysoB;U7~JI)T~Q!%r+}31ufn#F=Bap_X@y0<|*) zLpB?vFYbRv%yJQe5ZcqNPhP`reo5vJ2+hfPRt*Eaeupibl9SdvNN&(W)=qJOLAdvQ z`kOq>rd^$NI>neYUk>;asjkG(yhFh`3as_Nr+Sn;87O#_I`fT}FyTj$R?tsDgZYK& zSin5oV>CqoBDa&s$MCHlGQ-IbSu5Pd;x?Ly4;9mA7@4t(B6vpjB&F+fzd7E~m`uR# z=w49pC7fn_eml)OzN4LGh&~#oJiD+C`rldrSm3tH(_E_5)f!S0<4OLPH}+#`k%4!g z+x_8bm`F4uDl`p}+`^J{3A5Vx(4;`yzPny-V&xQXtboyYD1g(?HmEK-~G^M(w9-GmxR~AEC)?_u#M`_&@p?yP5%CF{!u}UBA4~J#Sc>fk{Uo%Lj(lw zs*DQhx()^i%As3mB0EM2QuK)|JqDcWf4TSR*Lff##1alr3Ir|f=r{4`?!1EjMuDEx zu(M1fTXhP1RO}FHvLhjUXdls{YvHvW&X6QPF@s`jHZl{2Se7AyrT+QmM&ZbE%pL+1 z!Uq0DmhbNDVz6J{ z(0SIA3qnLl47JUd&S!cxhMRbzQujVU4V{Gdh% zws1MVWdrJpu;uPgaYjl7h3A%ch+_bywnU+D1qbiMEW%y3MKop|A36M*NPkP~H!Mr) zO-tu3p`DWE3W)L9*KjaL^l^CUIML^u6C7X&O}n?ia|7RaLBq%TthCx(9N>u}yiGrb zAM5}^i?&L#qn1&Ph6v~O%wAAgmp313YSTC%r4)+h*1k3KcnHD!AIBPNgKAoG720d- zuCR^|B7ZBfbi*-q%V_@wnr6hhXP~8Vx%1GOfjwyDM*NeO1t`%(sGkIcLDOSf-QK1sMGb9$@WOh`adgEAIPor9m z3J+QKwfuNc3B=66jx(F+I0!xLGkR;O`{ZgtL&3GYc9BS=mhsk~eTI>T6Z-Ye1g;6{M29z!(k?ziwLQ3yt)N;{!U%&@ z>8qPKOR^z9fiSGMp*qd)a~7a=YLw+K{#AX}+#i@j|2?HR<|4!(PO8qfhQxv8Z!Jd* zzj|;^*xIfVNxjNtK1zYdraK|aZlZatkUg7W&(N|t0)$}9rsED@*-tzdM-kw8(zd|D zl)gv3%!i0*eCndb!~X`85xVo#S2PSRFG_7hUy{uC3%&YVY(!5<(o$bL{LKY?o6dT< z7>J1A>etlDmAJ$mVIimt%e}T?r|YqGYoEdfX5>l0Aw}G2+i)WjzZ*Lj7FU7-0QmH^ z`i!(V1JagW`rF3R2bjPB^XR+ZhzPp;0t1O&57U36gt{5kQ{|#R_8ibqGr2W$t4_Fa zR6qts;@ZOL7BLV4B{gWu<&qZ)J`oJmejhk)t;Df9rv7U6X)pC&H+I8^N=wPQ+_!p7 z6BWV8%^|VSw{5ps**JNV9TlNQ0i9<_P}~bMsNVt2zuS!l;jF0ARVlGB<WBEdgS@xF=i zqKC@^z?tF^e&YHj$xZS*mDt6YE0O*C`We+QGVQ zr?7T|h=E7i{=%pFsc#!uFhgu|+_I{|V}9FXaK$UAy1eWE!jC%<&Vi{&_?;iAcbDh? zUBH72uD^95yMv6p*0yMErIEV0DvlBC!Z~_z>Djz!JT`D9NiJ8izi+3-&kVS6U<**tDwgk7-kba4U^Ot{)g4{$_IFEmAq@EFs&+K*JpsLDr`MgtM zijTnd*xFTdgR1}q-uIwvvV)T=QTCq4X+1+M7ny>$YNHrL+bHU7Pg~t|sB5|?8gxh( zjwd>Wa`>AM3bwEICRGu*PYK-$4U{gTGY$2LX0j`vAIUnoF7rA236djXX;cR?u9A*j zJWCFb_XN-&O$-Dh;=l)s3^7b#H$*N?W@xS5{zqY_8yCN~_t|{`?4QBivGu2<8C)o^ zpu~(d^zM(SiKqg zT^l4Sb%n}5;hqr^t9UNA*Eu%5-71T=$7$hqC+4IPz4FTD4!wlF`i49cm zVtoQP`G_eTdIHpgP=XoxHW}b7Wy+~71LuogOl_6vB#-6W*<491x z88}Ua)&VOK0n0wn2o7(5F4YP=vFWj8%X=C z?AKoT+}0jZ=m#?1{dLY9vNgqN%w;n?KalVS`K%rpTrV{E_29%DP}YpO??ATFgEyXX z5ZAM>Bd6DWOF|Pc0Z!pSAgSx|`8HTn#9$1FUQNv(B1Hl(D0{^-W!0*g4-_q19r7+Y zVFt_V6dS|QLjkfi$T0EJvcSyMNK4u&sOO94_{mmFdQ@z_1wFyqvu#Fs}E zb}Qwd?0tdHL3m7GWV)&by_zm`yak_UpENNS^NyxE!K1w#vy*VSJ0Juh)@r#tkqMH_ zh6L_-?f?lqh0PubQw{7f8&8kSbN3xZt_lja))69jY`9)bug}&CYUCyUX3n^+L4%Gk z#l$z&MQ#$MW5S41odR= z{@H4}B&^9jPMS-ACg-I$T#G`qu+uaJ-fIBiOuj6J2%4VvE0u7Gz~c&Wh*AC<`yQEz zR3eOnkiz4)t%q`InZpT6IVK*hs#%Btk34eP=ogelTACADYj|(X>Mr<^gE9wn`83>f zae>D~Tdh8^Xq1w+hWsub<&sdjb&dvHpfRs)q{2|)Y_`wg>T7Kqlf@aoh5~2jA#cV2 zoQ(Yj{}~K~Ut=`@jYm5Q1;&Q*ndaUj+rfZqJXot_ogGcS3i%@#FpmpIxdeJ6d@HtH zZ&%~v_5uhm(c5-mt8sUDMCX%=_K^p2(diNY@Ki(`KG$ zy>5Q(oCwNhoVCUiCa28)BqTQ;NJO`hup{b-<>0Hy(*iZ-PfG2j7+nC?Z!XLZ7Z*!0v z(l_$s#m=WzGhN{|G93H=eShegbNMdeoif@QrI4~tLxRS@)9g-_f9>_Vc{VsSVYpuT zy&CuEAz!A)_;at5#GIdH(h|)k;QCchoP{wDJdxJGO(Pm1t_$dJoKPlzZaTUC_Ql6+nCg-*M_w$tA9K2K(-lfS8A;Gr1VP=O(b&_(E5^M3-VuFHaJ$+) zsJS`!R;oXXOjnhs2x!(M+D?0EOCwiz^QX`)+zG1pu3mIv1(Wpy`X@2$xW?C05lPs3 z^*`=BnYzlFR&W6r`FLWkk1q$DZGnPKlZMmU@ceVl>`gub_&oJUAU_Vs+Q=Abda_hs zbRdx%>8oS9hk$0mx?6ipX-qLum@G@4R+z`_FnVJ@q5Y?onktSvM{Eok*I0B#&9EKw8YNoWd9m++#NK)pd}I z;22taulN^vf4>ic2VKb#ftd1NwEdycA20xhg{^#D1hlBnG}7FZcRUXjN`iRlfu2FC zVxMRpF)%6%vPWSe|Dd!#AEdG%?1|sz?$Avs8xNJCKlM1rD?4<6u|m;jY?2qB6t&8) zBv!CuKl7~2RWxGzL4rS*Z6%y~S}ir%bmcEC7%&qNH12YbZMS@u5Z%6a75{N>Lx>40 z4gC0#c)g)@YC_!x|De!T@8t%ki`$M_nzz)(R40zqVp|>3$ULhlXJ4YGaS_K_5>0Y?x>S$C_k`x|Gk+h6(>kG-q#q7hJDw8dngCDVXi4y*TTj(e`-P zbxnHf+helDDbwbrcsLVm@^vd9tlB}*VrbA;e#WO1D?)@y6O;326gT=q{1IIHkN zlDANytZl;#Wr#}&PX2%p>}~G57#BF`0r`$MTQVFcW?17>y+#fzgTyA4?{8^LUG8ng zaE3?vq|+wLZ4hvNq9a$nGw&})TVCj=$SN9#CRuTZGP&F(4T&&2?LY>(BGxXn6+v5K zS2ybZYPuv3GEx5x@_Ufa8(KK4seCld>p1N}I1%B@Z2$UV?jb!u3B=^m(iNy1iFtk2 z)*IgEFTxZ!?tAEW_`Xw_xO?N}1U#7w`9TF`71Y);&S4Dib4qY1Ku`|wFys3-VB|u^ zp>aB8Vb_z}5s6{;lk44~%(8*XUCwCv(mnPW?~TKyH);mAtxMIpqG3J464tnhibyU= z_ynrQg_GW5Hi-st&#n%!N<1rK(We;E%XR&|nMTT|t1)4H&a8xPcl%oLZ|x(I;kPaYSlg0rJoZ|^IMXZN{6C6x77IysuyD+z=IsodrB2^(b5gLd;3Z)WPxkDm)W#DT>l z=2^PS)sQ(GiguEJ_HZ^lnF;$=1FoUx&iWJ5^lGedB}aMlR)5uxjch>uO=d?*2@LV3 zAbvU+K^^y<85W=TDaGJ-4xr@^+#ItuTnwj2acoWv0tJw~7{Tn>f3pU@ED1hL{*PA< zSC;-UT>46@0P|Wups^lm-Ms^JqWSIeb?_I%u=w$7u+3%H=kN}<7&@;nAJArx!Q=6b z#~g--0v!7TxtAPt`0yXY#}1bes+9ii*(n5q@VyE+4Vn9q5)0{*Ch+nI(xWSLGF~<% zh02CmKAVPF2HeyqL0|tT#`p=3E;@NDnFDU)o!Pu+cg0Nq=8mH9Z#o`)D67z;k2G(a z=kur8lM2yM{uY;sl~fKLxyD9b@tXy7IRm)TRSlIf9(?N{SVA5&V}V(MnY8WH#YZ28 zBKr1FTK9z3ULYL!8;7Sh`0I(&62%!$nCuPF7I>OFBogpq5znVGbXAyFR;OKD0V#|7 zC<8ZmqZ+hT|8FZJnBhBmlcw@v-QIgx8Auj1#|GC;V*~QSdWc)&=z&tSP?MU2r_ta_ zi8_L*CdhkQjEAzU8({Z$(;IIGjjwj&)#W)HhS~D&+t=G!3E{HL>k1>w+j4)1{|S={ z8iDGpx;CSp1ztkyx)8!VtXAT6K#%;n^ zEgoZ~LEuYiHj(YZmRd~wB6_s#XQoKU2{ldv0%t9&fRbHF$%V^5y>KKgLU1qKRl}eb zI$*1(zv~QhiG7o9){Jmln+xb6A)CQTh$66vB={*GB_46y3;Ye}>v{VMT2 zNLV-6;VFpMS0J5MU*?~2A069Di(|o_&?`vVp@vj^_qMLl2}fwDNJ z7MrE_T@X7!6*aRv-AB*@I|%_#A}Lw>`-IoI0QmMLT<%C@*{iD3^7cka8q|8mE~;~u zEI+Y_THS!|vacN0*NP_ejjm$rmlxGl1TGPQIfgro#!CN3hgzK&m=JrI>*FC!8CWc% zuq&7F{Iz(ZlpU$!Tp<>;oy5P7kbthjWqxw2-$3(M#mU%yCY+}RuCmo}_Oz^bI9P^B z%s5DwII<)WK7sVJUVZRPYCdPIi0n~qkx)8+l*IEmuOedL&^7Q3?WPtoD6p`DQ^b_h z5XM=d?FT-Fedg|6B884hvUWCH??!%7E$Py;^d1@NOY zotdK}p?!CHR74KMQT0Hhcgsg+MNZW#q0BjI2bOWd5dy)I!nbnKU^^qkg14Tkn_xkK zUKW}YfgL4F?cTT#uUd|jC9pfCY7I^*=zFh)9fCA|^?57%#r&_6KKwX}1aYp0UVgz`?zDPwW3?X{R>K63 z{}|)$`tly6Bd-C{`{cbTrsxqWp#XX|@|l{T62-XJ5yw?Ksyr+PklTord7@@e)l{d&B67#>3h*S->^=x)(#j@rxNoYm~QWmA;+8){^+>_;a% z-Bynn40#mS3&(AepjtM@ch=8pt(*yn>P#Fo7?IH*EtFj_pmRE?m`HIOojpmOUGLt* z2>eGzRp=Z2ajwAmSh#Jo$k51+Bh|_@@YC|zfW!$YR9og(Y!2D*owh`0*Ma7>YOq1& z&iEl&^m{jaFEQg^ik%GdNHp=~e?Lg1UMgVT2L}Z_Kb|g16y>mAH9gxoKSkW?bFuZ- zCRN51zvwjc8uX+)4l+ovWm=wZo1o2QYOBHTA52uDL`1*8LT^dw&D-hxuZ&2c^Lh7h zmdhIxrp=}-N5$kWwihNFP(T>^<&@K)XD;(ithn{s_=ghQ)P$dzuQp!RKW$y^2CaxX zeUCHgn_qP9kBb03q!+rx&eo2PQYMc*rgeODQq{uf;YI{|5%3y@xGEuT^BA1>KEJGb zXZw%2;@?8|P9C!BQ{G_VHG0taw~8bozVnr+8Q=LAXY2)?k!IeqS&N;wtSD&Wb(lSj zFhx3Wd$_KP0gotMqEvF)9?JJARvXF9eilkyvw(SK5x-=v7~CwofMhtj*eJD}49%S#hz+glRK%98EAd4^|d zX=l%+LiM&^`a~^-qlXd59XFzs@yo7p-pv>*J-EVk=Mev8AG=@aplOj{hV_*Im>~R> zhpH&~lOZ#(&ROS=BM^@u!_d9;`{Nd;M@^S=EnH{xQGf2Rz{+=ACvB+EL#Pxu-26x> z96wRgCuq*XQdHi=m^6?7k8;KJRZV58Dgy@J;w?5uEwNmIU)O-kx>zKcQ9fbPKX|leo7h)%i?0zrAbvPgx~3cC-@& zG2pRfccUqPA*|}e?2mHTnCm@YOS;uatNR+P)zHM1Z#R8{35%#`G)PJNmnE%9uS6$^ z24>?!V_rvhtWPg#kRR2})GW7XkTWyu&hO+z6%Qg{48N*LfVTocvgJYC3YcYG1t|bHq<$|GI_#CKBO;6dO}$iR3{125*BsYY~p5YvLByXe&>t^x88$W_Q5x` z%DB;&`f#vgZjEDB10T8vz8gHTp$A@2=y`Bu)(i_9NQD(PgO53K+2Mx4E6B9Ik3UQq zf&2-}4(|!}3vWJUPn|#z398+f3=NgY;AYY1g7S=--i|jJM=S0zNQ3e}lhW&x(t#<` zhDY@ZNS#on^JN&qS4q!3+#V6t;ABdbp&jD!K6%R`I782^MLN~HQOY#r`CmD?%f^aO znci&&$-OD+<~t{!VzDZ1zF*aS4^W+~zbao%S28qj?(XhR@mW6K`?>#xo1d=T7uU?3b7tqvoHO$t#7=N1fdiq2xGHk( z3%5*IAm`!LpWjN2K{y=;3DI>YA!QJRM3D6G%Q|7H%&3WV*o`+hro)i%JCne_$XD%O zf9kV9jQMezo_!fC9i3w-f{aU)W1~WZG}QKCfzUQA?Dfi$%sL0hFg^=L8&#+yF$K2k zR^|`ngd};T{{^|a&r=ZL^Kf%_DID_=C-{qes0PcFFDrz znvnK+$Kt<{E{D{WWK@7ZPoolcZ}*sdv6G!d-z6dfm?|V8NQc1)DNDh)wvxvH-%5aq zT+l%fcU!=h#f8Xy%O-B`iAadY3b#a-kEum{6hA%1CLCI^ljiZJ_p?mWw;I904s7i8 zEaaUMewS2X_82}Q6z|3q$xFiq*`&FrH{&d-NHaova7#Ni_uF?2$HZHMeIL`~s|BGj zcRq`H2d{|itKk=jwvldGWn(PtKs0~ryoYA|#Sl|Bbt;I`)|&`vB}_*8pTwc?_Y0(r zjNeVA^wg(*#>J;F_1V#e4mzI6zdcVRVhUqaeE^aB^Bkd2nxXS$pL ztnSMcog)&!_%@NV;Bd_la}szG)0EG6=oVcXAI*f9vxM;tnLO;D85mr1tNF6mVt<8( z1juR0pGZ!(jf0GT?7|unt0c-*Mv0n~_jfN0Aqz<1o)IU{h(y6lJFZL_5Ussw4h150 zcS))-E;>u39mq@7PdpDk9jd(rV?}o=PGWSB!ZKY9Cw~Rr zk`kKxp>BjoPOCsWMTRZ4{)3B)f79;Wj2*6JDT5!+7^*7-s0&!ry!_0A%HuLwn<#z^ zGW2#{JzJ*pi9z0>JKaZP!w8U^bTjKf5^KybBB1V-8=q5f*71EQ7KqHPJ{Y2sOx||P z*YRG&<>|y^`_sm9Sc!9A^Y8MurrY|IeBlzNfBUpuVF%8JyRQ;nSenVikh!lv3W6=O z_3kp7vWaJQzEvmtmTz_U5S)LS&ZWNC~R3*skU zC`85RWEz_=@J+o}m%dCX5*(6`@pZ~_cE&TyLABGZ$f2Pb+=83_B<^_alFii;nsJs? zR<9?JlwKWYQkqTZ&ulcV2LwC3FX#O)HMr^d;D-fa|Tixz)_%=s99O;_OYN)$!l*-&{stwsQuWST#BVYN=Bu ziGW2AOa{pCHXQ^xfq)x5aPMy6yK~AoxZbt*N#2p7@KzTTyjpn*`|a}OJ=A@Ki9Tad zHH`@IhTqLNgzg;bgf)P zJ2Y}A5vQiDF9}C;L)wKMzIW%iYQ;Cr98*PdrZP-rvem5DsXKh+hljJ%?aXAX8xbCow!J} z;rSFukg?7t?E~Ky(!X0UL8!H9zpaI0y=qr)q|KFZeA)5+w&M=V$)zj_2YRtJwQ|Pi zXv-ZDs?O)ZJ(j;{SW)FtP?jrL_13ZAq-(={74XHUbkL`QAZNp@h0J9?-vdey^QIJV z=%9O2aPMSz;6zU9nO18vlOC{8_qR>f^#eI8#1)=MC8GPQ<{A~)L22a&l@uHp#+8wt zwu#@s$qkf>9p%wAgRkQQYI%7T#t-NfVx@kEPi?LAcb^~49|>?EP^krx)K@+Rc3hM# zV9WxO(+0aCCt&5-wZS!Vp23=L&Ijue@Lz;-n})@@KGNa(E_TB@U=@Z*-=BMCRTe=~ zj}EA>h}T_E|Jb&sBl{tX9JG0zyg|A^04-X`x3u06)_G#D53khI!`A8t`!_jT#~4I? z%$nnkW?wXsd}q2mAJ;86G3cg%Zk}#tj^Q^Z;|dSl7i3LW+8x}zsLt4GgmaWLw~HUoQT$GR&*X=}`xKgbFJ_^coV0dO?F!$cY+9l#9=80_RygA-;p?x+&= zdm9n`kj(i&78n>m$>C7xJTdAJ5C3(kb>(TJ7E2FSfw6GCtdJ%~+U!YadYgvs$rnIqV!s`+Z6qC^_3()!|j z+o+QiP=h^hgI_M%Fno%=`kw$F0|ULns~t7U+}fSnY85Fh1Zz1mB)J5(7=om#$aVwy zIes!M7~E_MK#mC0uK8GSVKIrUsz|6|7hX3vw|^CGHBFIgIOM?KAoF3Uw9A4Oe^zgg$6Ox@y!#$e{~oDWk(P#>ja<&YA%7KDJy&x8`iM zIWqZ@_g;3ZZj}q)t>o4%Ae$U(b_(9(I5(r(v1#(i4e!W|vGY6Ohu7wu@c9k?F+?^i zer*KAp@R*tP>7z|;G7**hEu?%pW)Dp)1tuRQ=V(L0`vIv13oebuUKj@ z0_mm#R`LTNh2UvjcH!Bijq)MHnG#mQtw!Sgv^TPhF*eg~x5DEup>z-FwD1eOUOB<# z&L%8K6Ke&dx%VNeOp8~>%Y@3OljLDM$m1XSWYf#hW7Q9OHnhx)TPGG*G>0r~0)J=c9v zraOyCj~A)(s*sUxYNMfri|RQ~UAF7si3jMke}kPyf$sb&r#G*`Ecs81^KNhsZ3gFB zk9P_-&~C6nTb7vBG$v)@8gkk}|;C4RorT9I_e(wv#3I>k6XCYI)&BQ{0Xh zGQEHcna7es)<)hR&Uw$AA$X1?;u<&%j6Z(t7W1|=17Vxx%ITkV?Oy+N4;|J#e(PuU zB&r1lLL{w{$Fy#}n)L^vZER40gRY~4^WY*8f@dWzKq@DPBKBSDwE8*JQ{^49|4l?u zmP0)D$Mbt3xjCf5`ybO^1xW4KU?k|EwifkHDTUgF<7lPXu_ox9FHs5W)I3xu54L`P zQ}sF{RXACg-ln8!x!l4zKUyfD!(JM}t~5-^u=eWLVG8Bj|4y-vbBpH6+?7K;8VyQl zOr{f!-akJx@yL&9KZ>8@kyO#RwcwFs-1c35Kj|~mmM}u6KC3r5o06iEn99nKgV*5Y z%r}!BMM)QUBtMcQGr)}1oYrjq<`O#mb^)#z1 zd8OD-7R_Hvr87AC>6;U^3$q$6+NVe1w0hpb9PeU|rw; z5ztANn4?)UDe}obQFRS;y$DX4f*N=O8voH-xdhYZFr#JN5{z?UqFTd%tX{| z+#>ttRheNfJ>{5Z*{VTD#Nt8PaZ!7BI|Dk+)_@{593OnF%w}gVQ;aXFXQFv z6-w(Js>mBMXBsM>jO;Dbku`+JZC4#AuN*Kf;ooQtH>C8F77H>qcqvq4_+3{&vpPJn zoyMOi*E69PPJFm;pwA=S(zfdOfUa&T+TX`Pd z>{d-{xVf78WTk|oUCX5peQm$apaWxsH+q{tHEbVwd6Gdr>za4?x(SQ>Z5T*zzm**? z+HMC)N+{{HHn-bOnXoY@u?Xgb&oyMNu}^$|UwSB^Ce;6Koq1#xQ6UrkVvubgiUNCi z?o~iF$0S6q5flHx#x~wf24q>2Wt-qcv!3Le`!n2lv*F40c_5Fyuv?!C?8E96f$zM= z08W$fj`^I6rt6De{h6Y;!t`On+E%bq5OZBHldr$(GmEp-yWa>2=(x`AX2ixInASr> zoVD$K|E`7&kx5mbI~biD{q;?3O)({c@)R4^ad^*lD-Loq$v{U`pUFb0kBZ>dAj|PN zo6$CiZR~1$1Pu-BN#EG)(F9g8Y{z{d-x2Y~tg z4hx@Z)i#FEhCHW6KkCDj>0AUc=I;J#l1S-B~bHHft36;n-Cc7GOHF)ULha@osiIw<`Vv zO%e@=s9~z}dG@pzLVhHy>hcyG`-o2X_ay=;h(8$`aGtNm@U-O}62~2L#(q|7fARjP zj!IgYDaxN{bVSj=J7o~|qW$Q8om{yDu^+5y!3EDXk!3^V*5W;$XF{*eo%(b3 z%E(=H8(jej1fI!Gwx4LiwPt?ZOQ@^D5^~ofITg#JV{h=mH36yg|E-=6y5b$0MJpMp z^a{wnQZ}4+jp25AUgctC9W!uDmq0g8pnD?vq)vUtXHZ_NO$RbKqk`ZJ86iB z$7g&T59qL2o)CQjz1yIlOujsA%KN5W`tF?@3`6($x^Oc|q^(b#?{9(pER*M!k@gBM z&oL@Y_&MaC#j#%ELPyn`gRiavzkiMq5TA15{m6mzBX zIhbFRJ!BF(lxReO-0$c3*sbOytf9eoWQUfU(Lm`#$FH;5hN0$~bbIVHRR7_yi4$Ik zaA{@F-`YQuscsgv0QZva`wj}J&MjaTc*P_f4mrU(Uy{dt(7NrYv?YOdNP2(Zf(8s!GW6B{HexQc|ZbF+P^8TzyTL~2d7JaV77J1*99nkgm#g6`Vv8h zw4q%BT88LA!6227|9-s~Xg2DiVv#>52Ca6(ukOX#^zE7UBX!os6Zsb&_p)X&TCV=% z(-fpV%|6{mznvMV`df&4&4424qp;nu!BRy0Gy#w}8>7VM(1)R~zoR2+y2Vuc2XuqS;W3t2 z+q}PTRy`h6oNdb++t|%vMfo4QKsyzXI~`dzi+oRcBuJTf1~ZSoj5$Bc^Pn=TurwsB z#DMEjtx(69da~3Ve(uV3b4t@o@8=mP5U1)eA2f(}stjW|5tCZ+f}7XI-Sg7zV8dM}_hU1!QkUi^|4E?fnFVZ_&0pE>Cd(bp}tDrGSv z+s{CenKZHHd*K8?aHi$Gk<0r`)Lj8$ys;6_C{V=5mms|!f7^TIw)rj?^DDAFw0qze zBkWJgOWm7Mq{6!7(d3yMsWr(HunxDC`6PU?Gg7f~*2$mV2}LFPDq-7VS=45;OlNyh zoN#bKK2**&%qqi;vOp}W9JOn1uPVK>Y+EZJ`%_*@P~e|7zB;`7EeiN^9kNEMX7vH7 zW#(iNO*)%}&3lYPa?f6E24<@D_f2nRsFy5_zyTr}vY0P3X~wzHsmlDzEKvE|q41@otoYfX3#De;!|CU0VZ39Gp?WAK+MqJUmZNCfpzX?K zJP6s@=kn)mN2aqc7p9P|NpeOsu1ZTo{V=pWRnpExuk##!HBEw-`PWXEZ3SE8GjFCa3*7IGkQ5x)UoM>x<*hHM9V>$Y>6=ylOe7~XZv0+Qi-i!qCIT~$J5Bm*L-EB@jbGmF&@|Ihs z(IPWh`M$?;S9Le|p(^xYM&tvT8%5Rvww4!ZNILR&Z&52?J}^i%nq5H8)i8eaTWpLN z`=UvyQZ5H4|CN@k^y_ngN`BAKH3``|1Mgkt2-~+aLXobK`6T@`T(Z7(j})UN42!^S z(u-vNwM>(-?hB?w!y+3}5EAos#KLlE4L z0(3XS7XvaR7QVY67IQ?Vw+d+nbg&06e!VeDV2vT!9~nv_Wd0PP^Wy`lp(xsV z`NMywY6)c_yqXPIa+q7TboEs}SEv3zMU6kwG-THryV>yEwE3;dxz=yryrRPR`@`rx zM36N0T7e2HdmMj~?9%9*td1@Yh;u=5q&DnXNdaikKxA`JBeo7zRZR$((Sou63pit@&=ilnfE3=Gx zp4;q8eL=_%bq~8fShwNIZieY^Q{fhMq*bD+YjC2OTE(FoGQLh|+z`vkbCxsb(uT_0 z9dQVOj2sCdLkbM>wj&(y#!kL~$;2kxORyHWew~Rlx*Xy-@YNXQjT9$O=f6=KvU*@6E-lCCda@K9!tQ6H7yl*$8?Gfmb(OcQlg$mf;@ITSM_ONjGKK=)oT7u} z!<4dPiV)((Qc*{{Yy9$M7Pvg_om_`42J3nuodoaxE01p_Nrm%M&PTH{-hzoIkkq>r z6s08$sP|HzO-i~!hBCzyelVZ5Hk2vRj8F4UXUDn=A$A;p2yA! zPaRu6lIPZWb=}l2q-rBRk`X)1AY4t3vokJjV`P&M7UeY#UDogx(<#t1 z?S=A-i=X6$LNb~$JmtwU#(TL?H-!MOJOYav2NF9!Vklb#r*Nr1Xa{eKWs4A;=%xC~ zzvEmw@`Hp%tkqjPyauuk#zY{--p@!p!vQhZ714PxFfak%uEc;WqB z!(^G$+CM&>%f`)ne${FWRYYq=y{-sdd|+SV2)nbATsH#)=w`4uHS8)Pm(VFuFBc{l>|p!QkGkQ(t&Y+c-RgxA@%TVa!G1bdcV&ZbtqX}jV}hoTI@BSLion3 z!<5TiwDs7wgY0&gRB_q)oit4f(uefP7|>|YCb;(xm7jia#DtbNycRv_DbHZY{`O)& zv+q}Ay`NhUazp;{t@0Atta6QUU4B5a05t}h%5o#n{(RKw=T}YI7dAcV-6-0|j4nyhhZPK)aK@XOLbbR);>I7lZTsrtn zO;D0T%q96(P`Xe9_y3;@u%je5;2k9f3=ZGXc!sqvYoin9 zwLp-g8u;YPd`{;YY8$ot*Sm_&-Z6U?TJ>({Ezf)dhxnykP%&5ZFZwl!FC{=2?Hz!k zuVuLH`)3q*$E-tCzTpCDT`hB3`oJ8)bY2iqWK97-sI0gA&9B9rK`Yv(TAZG=V<(q+ z;QM&7EMnGG2QZrWa!%5~$TJw9Jk9UcddCo62s^GUyA90-O#_v^p-^g%dPfA*{R!<_ z92p4Ncr9#{aZtrjytBr>MI|L?GP&))`uDFRa(S3Dc%X2OyP?Tm!xqyRgfBU?dG5+; zc;a?_`cdTC^Kl5e>ciwaI@l>GETHu86-T`P(VEq$<71(_J{aQc${<5q3}}}ksIdSs z@>kM(#$4PFLq3gEeoyqkfpqs+wFA%P6fLZjS!C+N-YgG2hjQ1+cOa#@poNX4@&aJB z7>Ib$SB&t$zUI4ZLXK;=_gS83!lx%Sbc*b4;uPfbZoul>_#WHadlq5VWg6IUpt^TqS(jPvffCXzqsSByM1 zh?>=RV3~?V{bdgI2M^;_;bp$pP*CNcgw|(s0NUgolqVP={_HdK6^1oSQIW2YW``kA zoN_aF5c%iJ5>hC4{~3$VLh6s*9CXCjfG2EogZ!0C_dkJ{2moma1EB{bn7bxPI#1rg zr(-~9O)Ytx*sh0QM0tYF&89?2^4ub2S>j2(n9uMLAw_Jy9Ww>TD7^|ddo7us*^@`T zb+pVZoxcc*ft`0~S|B2muSEO#*#OucN^hG|mY^7yH!(C6ck?-P?%HC9H)a~`83bt< zJ+R}$5#2%QCRij${w&SPZu+q=_d{%mWdfu+es4`>2W1DnlN%)=`U=7lOmu_ykeBi- zD*A6cmQE@KY>4pl4RDfd!onfdD_jC0i~oiLO(U96A*&68__2kCv3r0Q4AclLNVAyz zkkGz9|G!u8?1G5-6$DZdBecgozT;v*B=B!L`@MxQ zf#UufuR{)ImU`AXO|wD&SAqzFU)so#QvbX4aE$1zTcy^A>|f4$-!3+NJj(=Qx1#KU z9RhSa4yOPxqnl4}lB9C%7*H=iWs@2*tRvs+lWg0+s_*n`a$4!NJO&{6RP&m+(f+=Z zqV1@kSE{+H)2pPM@=1B^LA*w}jb)-a!1H8|UCc0hzkN`wHx@pJ-?0DSm}`Khlg!Pj zqP-2kY`^#UjDng#evA(Nt(&DjnzJbL)uMBf>E2=e# zXr`L$r5Fk*;0GTVp<7sfsnJHbCh2`o70rpFhKE#iAAK(u_z8~gzX9ZuXFsP#M92PQ z;S4wRmm$ygQzq;LmaGXlfzBJ{aBqP1cMh$kIWpe(}DFWuR)F%AB+8^_U2}j z05EzoD3s}Y+?YoGC^`rUu|IMTT^~h7@@Pu@Ud5I$V?_mt5fj`~$G~SpFkaEt$y-OW zg?)VFtmT9@lAt3pvwt&dUQN5I5G}Gn$-ZLCpJ_ z@6Y&_%t5qqPvq{Du&S+s3$;g-O3J^=jRs}GD^BN^QtIPu78qR z;D^*IJp$}(Bz=u{Mkb@39^Uk`6jT}(+MKOt2#!YMPCYU`W-ZWKh;1T&mm@;GA8b4j z{O0iQD80iO|EQwZ^&v}NYFrycjNW`UbP_U!L{P)g=6z=-P2Fh_pB@M}=ee3@?8}S_ zz(c^*wz5QeQys+PUUZ~UqsLPIyG%871gFcF;UA~@iz_zocA+&n5ibF~UcX53#uliZ zSd{H)`Bts0C{v?a>*MI~C;2s91|*_nTsSbNCGjj5{Z@~aJz_oY?v@_69=&^qB1SxD zLz4F6UMjK+TUguG2U(Eq{MizC<$f!?cVjfW%Klq6+@A?)hj-E{b@y3!H?@uTQJ_(E z5L%+h=-K*5YO2(;>y%{u7f_KdtIpE$mdVB*qmN6V!b=vH_XYQwhXllfbl{zxJTU|% zPzzMIj|17zM*lif?F44rPfQ-mF zu76-t?`F7Hr>Rh?(w22qH-)7^DgpR%m-`g>LdY+w@HQ{=FJ6L{7*H>@+t3XuDbj#i zX9tBA)s27KSSAxYtKb>z5DrW#$>Y==rI*m;+K~z51CEk(z9YU3Y z5>G&X+{XY@CLD{K)<=O&Ny65C{0+}ht`#U!kDJlCxuM>6ZM1qVF?^Br0TYar3)!fv>qO3U*Qx7+*Ie?TkI$D4>TDxgmpIn+$+sX_h0S0! zOFQm6RirKvqB(TdYyTe4{wpaf(+H8}q_vEu>)J6H)r@XAIoqI?RpdJVEITg~s#E^n z{BlNc{4<(EY_x5IDG?5Bt`D=#4k3qOv73I_(=8ik|x=Rf8|4ru;@pQZ%54fbK8WwKrGt96|4uBxTB! zxL`;E_Hh}uXr?yn)1aF4tS>%CFeniH<2jdd^*EwcDU(mExSDot0S*+Fq|MD zgg#)=q?tT&X%jZ#B>wWe_3g9XS;ZKeoXRjE6}Q~4p49#UlOm6Ki96Bwinbz571J&& zMk?36bz!tcLYr|*0nWNKSpF*!fL*tucrJM7iiFRAGiog+v+Hw!9QzXdV&#k)RU=Zp z*;Yvs4Emvx4G)TrWxNcz4qKm>lu;qNZ5bEuvva2jkOGU3ghS%0TzzL=Bq~6${Jg)L z#b7P>NCiQF=alS80PID*!6F$t!iHm!>A0-L+l*_|AO&y0MPD+zsY{f<#1^`XDOYsMhzB#eti?_hHc&@tV zQ!!azWl%j*Lbu2yQy&7+;>u9^Ke}>mVQo)Yh|$iIvsx0p$9|H~Pb_?rEf@Za{h7>Z z43$5G1b+#2wGjZd^Ff;YW(*uipKWHLjRG5ad62Ashw{D4J4j3^iFaz~SDn$SqDVW- zVCqc(M6Pzw5Cy{gcDCkqxYVmFMY3;q=k)XDnXd`vc8P?s6WZ_cnomt$;&h_yxWqau}$cSIQ%|$Nd zo4^T}HK?x0OP_t20IWN}G%VKEC2!WLpRttr3M3P?Sa30q`F2!0bEuGaxxR9s{3L*hMu>ZWG9n!s8Y^mR+b^;_38 zn*|~dud?61fXWY0`@upwR5-BSWM>?^48)G&|CZEfjm%FY%8(FIQJ$fDJ+)AfpE?4h zu)xEl3A_JUj%yK>%LbUw7feL%O<7MD2qXO@;Wi3o1*=;AkZm~CZKs+->Da-|JeyYG z$9=lv(C#Tbn%B-gB_Xt>Ufn%@_z>fz`jLh)$)u&EqLV&zMamQo5Rt%_U3xKNev4O5 zI-Xvs;KgG-bzS8X|VloF zm1dvqt4DAy_N|Hhj#eywYM4YCG4?Yc#Spsz8YHEgl}B{$aR~!-^JCq+^Gw5zqk0$+ z8E!_M$2c+%zNs*cPXd6roQO_a1J?}X;zWb>hy6{(=41Yqy1Dj8?#YsG`X|S!#<69i zqL3-eE2O>S+y0wjPgezjfd3@R1BWMS9Ajo4(E1e%H#Oa!?wXavg@y~@JJhs`b?}E0 z+xam2A}H6m-_ic#n#l)bRn=O9e?fpK3xsW0N)u30}6p z8Vmv?Z-60}bZYz~+Nq!f;sh~Uk+l`pA2F%6B12>#leJg8O$1E55lP=5%f_u=;G|#n z{bubjI;>*+o(a+9s;& zcC*ueiIFE0rB>-KCZ$`Yc)bB|Q&p%u9fFz&c+yeg*0n*HFzE?}=9Xu7zVBvbGUdw2 zHQ6f~4toCa$X4M+g+$gv_Z>2g-KX(VPy(oQ=(cXw_Q7cut0LG+G>oaPLSLy=xl1V} z*%;Rq?_$t&zEXUbZJ$a`Cc-kplmSBg_y##pfA)+Y4$Hs(+7co3W)0aPp>Tf&bC>9x z;-6e91nRR^gWHdoG`~_Qt_^lQ+@B}@~DBkcF|)iP$DLrw49aQ=9!!v$SZZ zEfnJb=_K)#g-bjGwW}fV)VoIb^Izr2VitffW^>=vUp=V%>EWhsr^hFVFdd|5-v;Fm z@m&ouFy-LNo)F=}^*z-!m8@CN=G*vIw9Zv4DFfs+MJFkYs~~#nn5Mq?O%!oM=YO}&A*oQj^X!H7*#Qac`(h8aJ?r?nlc|ESI6I+HJ$^Xyx z&j=Cu=xgw8HHA!h-l1fcNI3yEpruFsw~jj)mN(%69<^-`*;1#wFrY0o>z~V~kUF6F zdz3WLqzE1ResK5A%lCEKoY}&x@OZaRFAX2Q&ysI@=2;?hJq$mH*v|F71r(X;9gmEhme^SFWIO&+H+6PKN-l_F zu9L7UhhzFi6(rK*DR4zEz6m-*Waea1NR&ZoA>9%ZCe8NfhLTVuQ~%+h#qGLmQrf@Y zBvh#`-yg`+%G&R_K07fjao(LG8TIBb5C2K8Td|LJWYNEC2-7LfsU~Z*e4S3!emKV3 zLWXSiDg@r`!gD&hyzhQb_0t=!?s+g& zrc<~Bx>C1IUT&Nu5S=k9(Db=QN9=g00hUo-S#I3KNUr&6^y%$_Ny^rzuW0))DN0pe`1Lks9)N3;JWe4IaPUM%*F zN^hDGaR2U`5vY`q9ow)ZO!t)L1Mp^xP}m@7H%cx_gL9;l0LO!+NNsq&qod-cA6FG` zch-OXmcCIV#|?W21X5HE?&veJZf){4c3gZxopTbOzD!J*kb(^6eFCPM}Kt{gGpS)h0Qf8^3&v z6a4%IrbOF-u zyfn$QJ%d7V%pZQGCn|;-RWg|0$UP;wiufe(4!?>&IHVy){48&IwOLu9v-nQjF5gi2Su(8wFN`mMv4pn4GK;KXS8Aj}hfO`z(N+rWo8@2}zWoktg zK3C0ksXU`T$)!c|koJ#;8&t`Li6n>%9+jVBASAyN`se4udv<`=&rFp_X1j3{btjjZ z=2Y@BlU^V^jyQj>Fz#08Z?}bG2AaMzO+_Z`$KZlTVLA;aS#Kkc*f#_Mvp=ck5TE_P zg`}$6)hq6gy1HSmYuH8W+g#UUW{?5NDu3uZO>)Zn`2X0KiG_4^Z>Rm-_F#vM=InYJ zhW?iiXed_kvg3|-;c(i9^KqE5cf*ndc=YgE^l+u#{3fzcmxD{XS;^JfOI&wMr<6nkjd>t?Q@Y9Pw|H z`CEqhTc79p*u<@?t7bl7i*L=@Y-fXW4E?J37d&0vI1E~gJJt8R5{E=@7+*Cm?T>zP zeRbA(cTq8Jo6^PPV*{dS^`HdDzKHK7xN)_-x;`{`0s8k*=N}^jYNyA{jhT;cF7J#F zuyyduQ_|z7pK0-rffam=yQf0KbT;)iIUvlTv{eQefRk zg@qk^y^@YS400zvHo)kVdS^`T*Fx}@h@n$$@*q&k13Wt=HMcfc5HL`$6yc-xX4Dm# zto-L0-sCLszVeFr)ofD%cfVtaqLvNCYVAA?^C56GGQFB0GIP8yvq5@fu+@HLNLR4e zV>vEh3UDGIp~~_L5BHI)`R~8`Z@)QmCE9$9hfJ*tgsM>IbLfKH8k`Si7T1N=^U0PLAm>;%*$mgTWWb|9~n?; z#C#diLWP{J562u8V~a596zMjma96@!#?HU=eo+m$@L%iiyCW8x-hJ-GvQKiW6@!L} z8AyP2G^9i1^tgnqt7<`2uNq-)#haEn9pqnI!e~mEMO*Y6Po&=tIPkq3jnuWKDB~HT z%2!UyejoewV8WBA!PiDfOM+Na z?5}l|7@PTm7UIZu;)f>%*o|g!^cGS92U6oMuMwDhpvzr z^U0NaV0vJ{?zR5M2F~ASt+V14RWhZ8K>kP1FC*Ea>P0SDFG8lLHUeHMRCHC311}}B zpipI>wyLI#TcXB+Wy}7_hbIZg?dZpvv?3FFAGi)eE@S$obyM_J8!9D?f71GvOp17_ z#`W5WJ+1Q}fki7IF}h$MGS4RxPK5*e5piYQhy_nN=`zLpX1z@qV}~ZuhB@s7AqA=# z@1qYRD;poEn_E168zrH+f*es}kQilEFC)a<|H@PY*eY}QS?A{RLJg&%mC+0HUw7|X z$u^bI88I97lb~jQ+Z#d>B3r*ihg)#VxB#_hDGd>`j_~^1I1w^(Y_dAow8Xvf9D>=>j!IXD{!O%*+fJd1C zdr9Z2p$Sd-!bpbnBiv5uPGT@gZC|~I)=NRn=W>5>((;AGJK*CW$q|+n@{aCs0}tt7 zh|>A6TdZ~+1z9g{Y#72+)>sJ zR0PkOZNCC6J-+pU>FxsylA@0W*##S~;eNN1g|&upW;;{zHi zWRFtH%|S8g<_PL-%FJ*9kBkL)x@+52NRYgQqJbAa8YG@7@LpK?jmZ)IbHJYznzKNg z)4>Rv`MLVu8vJ!mce>Q(p_aa$q)T{wS)b9`yxB_6??Zd-Vd~_iYbq;Df4|_{{pf4z z5?n~Gr`s#Xrw4o3zRhEF9x z1l@pm-#LEfS4RP83Cxwc_>ND71ej6983W+K|4-aJ%a-)k$DlrTYdjbgar7rUznZO$_9L%@o$Uq%m| zg)9UKvu2h$RZbd7l%0hvlH>3GoKdKi@AP7^4yvkk3iOEQ6hG_fqVed}z7lYo&|h+q zYt}|bR00ubKs9_CZN){h0$qUr;G-Z3^s$B!&cD6qasz!|)gn@eU)MI8pm!6&%>|Ymla(&x&vt3iu zNo1HI^@-MkF6^j_PQEi30IeGjpCHpADmflg?XjUZbIrg~YwR zc@tBqx(`n>!g&Yc!qSL!mEsJd8SeqVe#Xvdj!c%_35_}3HW_o?U$iUa&(~-F_?x~! z40t>-*Id%(sfP{t)X=SF0d<|m0A5ROfizujMkGDU*-4@04qxuS_zb-z0Ng~RV|c`u z-vMRtLwILowdc3aQ_SIl4@p-Zw(6TQ>jwF*EF@8r*;m3745%`^Q_}BWT@nFm$A;;m z^FFNDVL@~%{K#`+OawZLVP;G}SHk0^&w0rWJ|DY&YY<8Y0&3Rtm-{gar(~{g+G;|^ z9VzLcC7Utr=n9)I-IMC~Be}XB4ph)NEbsGsy?i5wElMZ_rj6ZL8_0=aGc$&hj~5=53r);W3k0p zd&!T;jp2mzGo)49niV2|%@ zm7$%b{3+EMvc80pqqDysGhoA^h6)VxyC|^^hg8+rrn~JMB)3FVOr1?)_}18e5-~@R zORNcbnq*XH;R5{avpjA&;NO44cQOj7d)%B*{qE7zILyichPEqw7a2>`Yp@t{`Kyv5 zTlbI1qKemw_R}AkAZloq2%O%N?J#{pK_wRLg7&sK!*u8oHssH-^-Y4h`?7k|btN1V zu%er9XkZ{x0w2hU**F~1U$X0$qZ*Z=B|z@I|HB1n`Y0VQFaGF)g?#l0mWSLC?7O@X z9};k6D!jD7bz2}9w#Sm9Xl1JRjm_-?}yx9_7jn)T{%i4!Q+vvnpLPhkQk(J7FK^6c8^N7cYMYJO`|49 zwvDzSrG#$-DVI}q9W^*}pC=~p7FP~Bn-!$Cm|Ds*j`P%|arL=NFCn`&AjfnZ`yt!a z!8jC7X?047v!pggukl$%?R&djQb%VEfVQGXjS0BsA-6E zD+TUZl&ISvYU)Dbt|KYUpI%u~d&J6V>Q}q&*BiAlI4X=`2lcgTv*a&BW$Ci5#K-G5o^@nv6NM z7Tw&k26W>Xm)ADgcupZ6Q;7N%ML*a)6c3CMEfM~@{0D1Y1nimgH|g*C#$k%>!9ygY z^grcz4R{PTx#WkpezwdWThub>Le?C4IXm^d z8QbB~P|T{`LX?89&tJ*`ixT9wJQ&aeo{qL^@ltth6!fbT_{Sg&GPzf}sY15NX4;`w z&3o&uQJVd}aUQ!sAj)tW`&8x@~H!zBSoAqk=hti?fw$q+SotI;9wtQRgc=noR z^hZ{KE)Mdi($88vdkc%o4>Ccf*q~!($UG1hZ59a;yKxYhakn$YA{bP^*x7Q`k(oD4 z^ky>QPUP-YEh4)fmz``dJGv*g{og`Q7Ys*(U{8v;%Z*sA362@egPJ;N@|mH|hUSR3 z#_2CrIgrD~jUcW#kU^%p8xO}UyY2FEDj;i25*M4y^>QsD+5IZ}=>OyCD+8K*zrPVd zDMdz!^tb_OK>?AHIb@?jItA(O4kd(<+bAhfQd+u0LK>vI5$W!H2H)TR*$duqUw!I2 z=Q^LWEUJy`cuU-gb~bO%lb%iW87ZG52oQrh#hYY^2!SwTV#>X$=nR%sh5g<6OI_KyYfcyJ1kGY-FiKT(!{b%j{;2 z+`Eqxg?38SwtmOnW`R+HpCkyYn3_1hm3o*B;`53)t&{cO${F_z;A8sx_mkS4bL9?6_^ zK6W&!eHtJM@;r#B*Q0Z~klkB7UAXb8>u>DSYD;=#dtN!79J+?l!2_3cKN-1J@FE{S zujiEtJ)4m_{(_C*?zyh4e4$fRpH>y=yF59*>whz?6R=HJ*b$5a=_Y(0aUIC5AzQjvoxGJy3ySHaaP<|acVadM~(E2M~2{avKR z8@G(HsP1Qj#68#7-uLkzfrvkO%ePNWl_iV_FzlO&n>kx)iGbreu zJ^?-nXbf9d?>o%-FyLV9yxlUv@P_TNBJFe%`wIfn;b5VEnFF>xLSDjxd;^6OX9xV+ zP}rP|V%a!BnTDk=-`V%hUy^W!1D1~GH+xV?TvdPzN7ATd9YyY+9vAL=#&zfs28%Q# z_sIvOU!5K>YdffbWj3-Ca8u+*4W2*B)HYQt{hKXmC~%`W8ZGuzsWuVakrF?11DE0o zE+&_Y8oZWxw%H90rC=kj;8TU~78G7$g_k*;!$~FH4<@ZZ@LRylIh!2vJ7_H_2WFtJ zQ|#(ufP^H_1x3kbO@+!$EW3NrhpKETq5nASY3T1#WysBQ4WEAo>D(;Y zl@OlT?=$w#UG@4b8w_l-L-vlow=OPviM8r_cbLdZCTC!|y$9r99=Dy-;|0TglWelR(sJ0k>p zHlYqv_^0^qQuq%qv>MEbms>NmJ=Z6$p*$Odl7ofyQy#{i&lHn)gh4>MrZ_y1z7M+^ z;!a%vswN*PO>Dar?05^Ul51p-Ae&*ZqkQC0;lek87sgc+(WE!cA3v)wS`QjQ)gmH( zjHZ+!f9wQn6(72YH3){5?5*N;MBFEw)qzdOZ>piVq%b$p_LipU#Y`^e0g9iD#l z=sYgLlYbYbe=0K=ywoZOE}{vKk z`$Gb%)5(3{e-K)l&Q^Hn98PklJY9xvd=l5Y28z;CzQxP@h|5jK9y3&+3>)fFpk+MW zU++7uLq%K|KL)QpxEV#}s0u~gpU`gk`0cbnE-{81ZYJie;=@D*h4~2nEj!y{YUTui zft_%~rm|)}@*sJ_|C^cR9a6@dWuJ^fiVM#e5af(Xq2*5YT&DT47UUQ4pP0YubKVt?>yW$%T2FnSgCqq4nh#zi*V@zHl+<}wPhrhp zLLX-CQUtxg>&zm_NDvq)M2QN}cu#&H+p1dVzmuh1!{Mk97akEYVHv(YYPWE&#O$mf zkveYEY~QJ;VvLyMfk@EDB4W8{qKY~f-w&L(ia;YWiv-SuwHd?$ap;}C+8E4xRrFHx z-Ew=y@yIrL|I{}(Peu~=QZflV$^am#iWc<{C$;LJ9TV~z6nP5tIQBe3w>BJjCs9b+ zS5I7v94Xat)g66ysN3ohPId%GOyA_FW7&25!#D%1&c~k0q%r~9Z^(sk`fBb3HRq7( zmsygTf02_LW-_&{7kN6R2a8ZDH3Bm9VNO^XBFP7vQ+vN!XVLT1fF7fJCpst)pw?%A z@L2nu&xzoM7?34^+#L){Ro(A+IDGY$2}tfdtLP;)$HFuTHWsl z)paA^hG+rXD2Q@zlJ}zrZqFbfo+ofFz_yr`N?Sel{(X{m2e%@NW^+5M$6*2K)KQfF z%<6ZOH6DR3?HL$uxd+SYQx?DBFl|%?WI^rZQhnGvakcPS)V7nM?X#)GULxrPm9X$Y z;H6$%7_i^FT5`qNq4-1IKUWaWJO~-UiOl=b=?zKUu5j|_`@jB7dq!Gi%Y9W2WmS*g zY;ahv($+{5$_-x^a1SRq*;(=xWYr5=rr2fvv$rKaVi# z`gnY^vMEgWdU2{~(s_Q=*)G=YE`1YIH}qr_;(Xlk^x(t3NeU3@;3^SUW5U7j@{9ur zbR>1fho=V#X!+f^FtHlTRm7Nz2=9tcUXC5MH*z|&&5mw8*OGI4Etrjg!V)Dque5TS5%~HTF0w^BkQSTN%2$#Q7HC0u2)v8Gmb70^fMcPMr zjrt4^An;uu_O^Yv&$bsfQNO48q)jfVMeqCY&~RsFp@QRckM}hQ4xr-gx8g+eVV?%1 zjxgrd%cywgTpcLVJ($D}W zE!l1+GEtsm&7xosl4|>`z8GJ<>H3Eys2lnhv$>oV`LfA&!Xwc4^68}y5KtuA^#6FE z%@(MBA4|(fLaFc-R>lcLV1H3;Y?^5rrxEh6&m-b67iPum+WhxP8OIg8N9{FXQ}Cn2I+pBd?U$a5y9}m` zgK_u>9x*i3d~Ejj(|ZefuVpd|%I*Csl-`>GKz06k2NU4W?=Z0`Ki!@<{3%2K@_z>( zOG9aSEhZ>{F()$#%@Gz#FsCCY!)*U|h8CrQiDMh`MGY`m?JjDno`>WC>&g7Hp!COL zQr3i3o<;8<;DfkX5o6!O{h}~t%RbTSoGCB1|LtS3zE)|O<%s1t2|8Yl=`ss6Z@v3C{2i-#ebkFG-uKiMG7n3h zFQym%y#I}`i3i28iF+$Tu&{`i?M7n9!jPrHHjdh`r;QScxjxGxFGvXoiy$VjUoF~}l&6I`1kc|{dBKb4)(quk^?`MEGpgtY5v z8?npHl>!3`!naCG0?GyoI!9^(1ClKYe@0QVe$)4)nsjrjdI_rrv;{0YwCvz z`W)s65ClS7KdWsIh)b_s##RIdaIskD2LLaf1T>2K(K9NKHHPnQ?a<$fZmbLAmcwTd z!%nMlJa}R-R!O2T7%1@}S{8IBy|cRy+1T#$*B1>TZys4tAVD8Qcr;9@4sW`Zl7U9F z49v@q(`N!IxqvRXHZ^GhdtU^%Mcn1@QqHIe?Ef7@O5>p%30hLQ#$2X%8wC0tYV(p< z*P2jC)miuWS6aBI%Jsv(E<2{W+9bgTqhyEJrq4Jl1+VqQW{C$3&i>8_m~XF&H}UKp z3dFIy=^kxHFXrL*Xl#j4b1cW)RbN3t7>Dd`h$w0Nu!LQHy^`M=mLK;s>X>}vyrn6|Rc~^B-FNiP zLwz>whTU-2qSH<_V%%+u>jx)^*7?SCYC(N8HG`s3|LxjG@qcHZY0yrN#C^xN06jP3 zQ-atT3J{th11rN4nw#r!^sXK@AXYap~ah9w); zCL6m_3(luq9`4OMRGBJu=$5_Q()WwjFU+L@$wL-O7lo#GQKmGnu^LSqAIl6{UG*;0 z{~*Qy1I>CVQkJ_;KlYH^EQ6PDI7`g>=RCh0DpyvV`uf4P?;zY!`%?iI_pRbcxQ=>4nr1zBfWd#{NCV#ac7qk)Pb`u1(1b)Q;=0 z9tqUt9d!#H>paxcwvJf5c5YvJ!X%tLl{zb?epZTJFKJMmtGt6nXT=o%MbhX>-r?F( z4{016jj>K#1ET2LUB=V#d@j<8AgM#CD6Vhb6_xFt&5t9SV^KfCKNr079NEm^Fa{@2 zsQMGn$v9qm-^IaLOOfRyRMaF#938_IMQGB8mIl_L zDbt?j0C5e|ZCKYc_4ZbaE7grQ@$NxrT7`;@<8S68TPpyA2G`j9O~i$YmXRF(m$(?s zsj3WSVy;p{SNuiq#J65fzau4;io^&IxBGb`ey<@-eQzB54Z=!Tff9Ejm#CS{&=>Xo zc>&P15UK@DTkNL}+1bZk7pjfw#)6ScLcf09!4g@slFZ&nrvsQYa~f2TP!0r9RJSzW z_2Fj*eEp<`6N7_#tr1@gi|u6Lkf33ef%Qln{}SqIZv~RvJGF&vHrBiY5*q_H$!^&7 zbPLrna0hFq=n_o7c%*b4VPP87yj1{X_Itec^V4Z2vKz`5qhtvh`OXh9e7h(;uua%8 zZc=QS^6guvgkN?1LF57{Dqw2niXk#_6`_6sKoP5cWf|gn@MTpXaAUR0_;i=G^LJWBEr5*msLoIMC zPS72!oiP+^hIGUCh(EK}<5-BVmCQr6`6BUqt9negP{yY3HU$KnJ~;ei_W5Cg0bdR% zu7VxiEU4c{_zMwlnU&b8X7XYe+{k)Gg61;6s&uA|FsT2gy$twuE|)X+FZ!kEM5fn zhxAOkcn^Nk5WS-w-cU`KB7ik#N_;Z#@Cm;ng*L>{R1Z$c+ghd}ak}I1K$?oTf*DB?AKtfJlB$gTtbjX?+9f8=GsLE_o_Sn1|kkQN0|Mb-y z;hQc^B|hEjjIvRQ92GUFa-F=wly5XH?}YAP%>^;$TA>7q3}s+eXTPPQa*u;aLe`oG8Rs<^gIC-Oa~0i!!cCM@=bNS}MSP zeD#(hy%Q}N0D8useBcl^4HO;b!etA9UhoxJOmMPAz2zVT|Ypd@Xt z5=M2<2dsovmO>y2iW_)vvQ;{=a5@K`Pkzz4cF(u9_1!M$f(-+19+kaZhSq;iEN+mG z?=;N%9PA`xiN6}mK!th*R(a<-PR~nOf|`~b~C@^017UEQ5M6YH@g0V zrm*kFYE`E5XI|M~U-y`rWG$GbVvg&gx$^0KeEjMBc~OgX~&dtcf+uggz`7Vg@pM}ydeS;<@ z%}i{7Jv^XJ2G)e=XokR+uw9#?_wRqf|J)wmBbi~_cD50x;+;*6Gb(`&@tz^j1Tb)W zo!<^2lCa-BG@vIUa&rNRRN5*=T@mT)GQzoDWTd&7WD>H`m=LWUPFH}P?xPNvo`W-o zZ*uYK-&NH^+3vsT`A|t{X3|oC7E_L<_B%8y#(bjO4F(Hpj}su+y~HW>j*PNcb>a6> zRLrlOTawr^D9Zx5_i8+_36j1Y0a$ZVh9SBrWb`ky6-ij_&qK3TrrpHIA(kDhtd29| zQs@Vs3q?00GLk3L7$x!YwKFxmj$(7LP@j}5*;eH5_`!N0mBG9eGEE0TsUGBy4L|Ij!c6Ap z=hd*Is4j54^2R|ODSLbz zWcS04v#g6G<=+KoRj#_)+eRX9K{sECr?7`7b$795&`l4`>0MaL5|fQqs@LDD_xv)A zFpIOy7fHjn6Z}9*Ec0gqQxofbIN!q#D9okfOT`TO8Dq5jLlZeRq;C&n ziBDbh4mCo_=zI4te*aD4ycPBo_2lDMR|}YlofLvkJ8$acL6!RNnAmyP{2f1x_L3~N z+T3A+)+T%K)CulIOJ-5{0dC8gXCH6k%R7<-Lc60sNOH!(l6>-O{x}`&*ntIdNd>RE z4iF)gZm+ zlON*z+=D%_LR>L3IW&ihPUOa8xL;3#K}P@YEx;omADS5Ifh{65(4Pk@(0q#>z)|*g z5xH|3lOU^8MiFik$!sRG?R^?kvm0P?Xp-Jsz_41!`?J2kt;KQ zrJVu<4ko6EgYV{<5QuxJ?`1JJhuQBFJ-2%urcb3Z2OD@vB>5aj@y#Qn>|rM&VoI!V z6IqZ8JRLKDms%^!r4ofLH#ugdyL&rzddLZ$w{(@l@4TXiFa<9Bly=kzhJTDZozSHq$!XM4m`eA$#)l|kJiPr5=MEg#(lN7@aew5iurJHfY ziq~{BteVmW6jZi6Rtw?&4s*pAI zkby)OG5}rI*!JoC>%*Sj&qSb4f1oR4k*jH z>Q5yYR`>UYHU_7+Jt{lU0mNjZLRwTGijL6Cv8s)~5k;)LB1peK`r)r6BWgHQ`d8D; zq(gm7^fOJ8oz#`X!j?-LjH^${9W@43f!p2?$bZJy&7vvY)K1Mbna=8O6uzZP|# zM>m#aqb&&7OAC9-*eY?!WDjA~UP9W92Gc2saH4u&n#iyo1QC|6O!)Lt#NemXOR9S1 zt9TZ$DN4!+Md|1BemOd_t!9a7b-}6+gwQ;+u_&ywom;Jrul|Y3cqBxQE*9uW{#>Ov zWbs5~I8?D`*gXHa5TTOZg^Q^#Y+8xWR3GUkRiZh@e(sk}9B?1!#t1wVIC9Y#DtGI@J0G=(WjOx_2bL}W+(5!1X#)bclWcIs# zpYv7jCApH;M3rG^Tf`rgBcOxtNDB7ZZf*OeMSiTnW1wMqtcg#<^_2g}C;h{~;#Wo4i ziqw$XE4#w-mjEt6xiBVJB9)XMKhhrD3ylpc&+Ivfric9i1C5=fZj9!eeuh!lDRaiCS z^?r3VKGn`RW%X?H@-o~zU~y!n)?O1^Z@saDc{ z=LX<1H0x*qNxr=u(HM#TTrxjH4c4} z2WYrQ8^%DIMc)+paErzhK5Fo5-$QUMPJjRlYorJna6vA4|39XWz-=lHYaJm0P)}Z($$A@yP6QwDvmU^dLEAuZ;gTgg8D84rIAM_-D zVoGAUkPbqKrnzevE%ICs@`V^F|Iw<}>?vY|4di=tOZfqErrH0ECK4A-i+<0Y`V!a; z?<^f7{r(wrBxNW7k9I28*5up!;c_I^4n}?9qS4rK2{EF3y~SedkcG`to9Ic}zJC5; zN}R&JvJlI)^XG~gG^;FBo#VNN*w$(&SCCONgZl>3tGmD9Kgke#^Q1_S+?zxOBPrbp7>76;^`%x4#EX`$GSkv!$5a8O6-n=}E7gq4nxF&-K6 zL3)!BTv-vGoeUOtwYQEcL#~6IFA_w3dfXRZLg`&`I!xZTevsf(ZNYyC$6 z_Zj7XbXv{O;l~JcIwN(mWkdP#FWUi^Genzxt?r={TcEZ`zBh6w z9y;juJz(U7ZJY_^tMB<8Z4BkfQQT>WYgU^XnxzE7eY-*>)1qjAF=#2lSgZew%kz9%xy?E2 z`LS8Q036fKScy1jQQr8g{e>yOLg~>a<4;)Xrr1e?&quF}KURkuqoJ!@d?r906Rcda zg%Sr5dKLJeXp@oV)tU)#QDRQWBjz4W{e0AYi2RA4DzCn3LC<=}0W1Lum1sIPA@IYg zU8K(D%3~n0R-$UP|Kh6kgiiOTg1VI5r*jtrUsmj0>Enq+lL~{i$ z63h^+Y49X7%m;Gma>tHzl{wPCh>4bvj4O>J<8e(-nB%9GP+mgKw$B9UDYjy4;R&5u5IMw!ClF7bR`_B7En=-`yJfbw2+$at z>L52A>&lX1nvLF3G>H`^OHBAb9fjsxVYJ89e(^aVq?}Iyg=8_10jM|4p!o_f0-;LB zuqJ<)*~1Q!Hu75XnAIn|eoKawkQWO2_~Hu z%g>UTRbP~Fj+9n>r(+@_vkO?ksGP%BBT`zs3W{($hiV1PgOW-fgV!NOc%utE$+8Bl*KdSzwd)?Q zzLFmfQK>=m0_U{MSnu*j=8#B+U-r1O8~@skj1UY|g=kXN?zm%;_<=W}Lx#ohBpJ*7 znk#-?Il+!mQRVowU&KC{aRIVO`B)dONGsb-GPGJ#nHv$lIY`cb;th@ZP92PS zOX_xWv4}McYTP)>fW%JZ3={(z~o}Y^>S2brFO7J!3!Y_xSH{`^JJyy!;m2KiM^KX+g*dgk8kAXwO%+Y z?fbWIu`OSM^etbcc7L!vV^V%a(&%uq_HudA=&H*U7ZKn>yIG2#u^-S`(;1jpFG3Dj z{BJJwXqsj+(AC7|ar0vr3>$WANdG0PVNQcRRki#wIAa@TBwu6A=-#4)I~(CK;3ztz z&74tU!5z}ZF$ZrrdMn`TEYb83$t+fGmRr#)46M7$H$H_mb=Xf433RGpO7>#iin2_$gS8;#)*lt9UOPZmu@h3(eTKryzbITsxOO$R;Nw z9kn_HsMkHpm!>NC*lagd&70U<$PM$zejlk4MJw#pyyh;kKp#;-fBid0lZNK3iUGg* z-hbl>-$8^;dHn>Gaxbx}z?;41^afo4_ll)zeld2GSs1?nQ(DzcL(oR?gK1jERQmUR za6Yg2>}))W8pRds3v{8d&3??5nDK$xyzcPQEsrymvbJV;_RKj!Se*G?y`HOQWkH3u zc&|xN;ZEY!OVusmPqpM>OZT_Q#}QUb{vRyq_&nb|QR}^Mp8X=So>Jr?Dq3m3<}910BB#58RV0>M{o3Y%eYIc$ISN zC)4GX$j}Rw@3=-Hsm^npN_AXX+8wuL*mQY$Ptl9bXGjL0DC}kwN-jFgWN_aS zAC^UmyhdVSn9=6Q!rT3HHn4C_GXC#I6p^E&1~R?;wWY!R9jnh&u^S=Lp#y|vi5>9MK`j}3J=r{kL>}H`NL{+};>aVb`=+CJsM;C(G_DanB!Pgf_BC zLJERj0++5&dg<&FVi%6kW@WR-)l6#>Y8K^V^sDNx*vFJrkH zpbyyeq%iNVZ$w)Z z$M%%?g6N;DV0~31ao2Oz3hhI|3`D>M3D@FeT07Ln{$Pa4e<_+_-ue>`K_2(bPqVupVli^BSg=k{bgtIoI7`n$cw-3Q9Wj*AB9O}%i7y510A`s<|0CX zNLhVhXXHw7y~BOD(I(QRY@$E!D1s#l_KAWDTphlr7mnkUrfP*0sZ~ef9C6<+ip@9n zu4zbl`}NQ78^mN;G4$h%MSkg}*$pbmK}n3jqdva-Ce&V#Vf^pNUJx@XM5BtLS}PZBrb$S(@+s&DrxOl9COk$ttq>NYyZZi-5%=8rz!yE6I@&FVcwK6P4 zf5)bq|ERr97M&*^MB2wIKZo^kogAJ{Mrn-z3{00$)lx6C8SD1`&{Vg^29L2u<`Xf@ z&m28}KJj<Gsffi$&(3{S=xEN5?ROx-X{3XKmUL6F(>sn%|Sl;MFku zoH*n4?!y^JY_+!$&(=MW=N9Xw1hSa<_vcOT9j-yki8$~50tidu3~@4^B-ya=b$rW&xh zK>2c?3?t63qT}gYq>!)!o?E6N;c3I^zz;PN?WK!9h@q03{Lob`NiLr{f zP<*Ci*jSq_VpwPLtD1G?1i z?i;XD_STyJtu?hY&5ccFhlz#Hy}b}(f%!Dl`PNfelzY7^DwBW5#P+03OzA_aEgz=D z9z&7;WGKQN`^rSrp$?oa=hYw@q;G}7C=slCFDi05n54;$BcTZoK-{yb-+@D6OLB1{f5D#CFWRr8|xDImXVc+d=EFvnHm31NRx0w9EW5Z`Ff3kGXxwdVw?8h{! z*{AKdvvyjB0H;QRJ3T?AKb?EN9*;(~8YT*`t)^*J?iH9XWzj1zn^<5 z#HcRQ(B)YQx^i%Yl*N%XD=U+!%&L;PnPVPxfafWk`CBZf76i&4V0fP z_`VliwjFcjQb*eT?_6WR?$XQjS@f^xwfs^Pa1r9>uwR7%CP_d2`} zgUwaN9|3QnG*8jF1RF5*vC((!DNtnv$BHw;Ph)ySL(&#ZI3oI3SQ4RdLzB{VgM09; zO0a-ISGr}Hx^X9mz0{hMn+iYtyeoumCiXxOTZ9SYNGoJVv&}S*OQjomSUs5xt+#%{ z`}!g?o7hz+T2hSr?^UJ3Lfl~Z+_H-8YN#Mi2MMlY51ZINv$3VeL{@CSbL%f{OD5RY z^XU;b{FpB|GSjoZ1?x`~#;(U}sApR2xxyA1IKXaxa9)1!?5IA}=&{fMZd9bXKn;6j zdl(M}xnx%U&m+Fyzg#jkTRaPD|3(Z~igNUv%POa@x;zaX_C^>Lx|(!dRrxSbeZqMS zBZmh>Fy}Pr#z;GHD&c~@XH_IFw71=Fb;a;|+EMq)p`@_*{i4fmCGA#=q>*~%;YcqL zcbSJ=X`YU2zG$L03rx;kOKt5M7|}^v=;2i(W|V77zuFA@e(k;}gNu3EvUG^}vUnP2 zdOzxgDP}spqMg0fKODRv;a^44w8lJj1>9W++hSj1zn4Es-G^Pf(#v>N)nk6jjmv; zg>XRA-4X$Pm{|H*O3pX4For+pK$P`AAJJ=40c5ZlaSc)RRM!KDul)f>337sH z7XAKP+&5Q&-SvSKXTqhAKKYi`FjxG?Vu~iOrG3DD~C~Z?H^_i*f_j_d(MO94AzjQ(;v&wwIWjm9f zw{ss0JW->;!_3nza3I&&&Hl|xOB4m>86%gz>V+PLurZ6J_%(MpeyK5k#X0zV;3S4( z&b9M4$#In9f~Fse4PQM9I%%_8<4%e9sk#B6n=kY3165M4xT1Sfn%&XXm!6bHlr zf(?`^)8|(c6VyM$^rDV~24FN>eM;lnj*<)pkMQw;SICXS&H>;i@yE@`9fN!QfN?WE z4wEU|_yF(#BBRR?ESP`o=;R@PP>|UE`Cdr|lkT9Tz(Hp_V(1^U$;J$tEq#ORlbJu2 z;idfm$13BU+XP3!-FGeBcN%orsURJGnJyo1KHhP^qCsyowV+OPK;p<}K{z;|8!Bn! z&oe7hj7$_C#gr1|3o?Q;{MUu+eUNvfEs5389$hXaoCk@=ll}%psO-L1-lf>6wd zm@&Cb2HR3H=*1`6Llai0b=5M0vU<5)eRlLm6K!_q6w2vhBT*^qIny>3#dL+$ZJz7g zYmj4swGaudZKkTUobyltqOp6o1EQo0$&GmRFCJY z{cCW-$c3uW=n{!An@Bh!Jwob}2REbJCjfKmcGD)6<0hiI^KU`tqU!gLI})}*Q}5XV zuEewn8Ecs_z2Q6&_ij0RgU&M-?y>7R*l}G-H0RIT`aJqo zvpgjnv(3{5%Ho7-tAlRY^@dRvHF6>s8*0=4$`#<4OD9(28{8fSW4Cl$dg<=_`TKk4 zdB4fP&iU!Hav9>?S;OCZzl#@S{hjhIys%8R^^=i!T_8Gw z8686lt0qc4GNTQ;&;E%f>ym#xJXsCX57tG^2A7tt548Wh7<%y6A_F& z^TBbMK~d$>-u9MJGn&)L5Fl<1kWX%2LUtCIwNGsOt6{MO@Fq%VW2tQiTA#-73P*+kji}K!t10#V>F>8+Mgb#(r91u`^%fD6GfcU zIV)m>$HIG6;dJjnQW*PwC%Y{DdUHW00wbBQ+>nybf~SSeW!R=(59G{{_@>34+X%X@EIrDm3&k;-7 zw_Sb{5LvFMQ)I;G@CMF>?b(FsGUj~xeU3q^t+Jpm$vPJ9^xXOnfmeD?D5G`a55#xj zuXGltF#&JP(@!pNkVRR^CunPIg9K|$ief=s1O8o`{j-BVU2Hz;=iM}DfHsSbZvS^( z(*xjehi)bd(+q2FKIwpWEZ z-hF0__NZ>cjkTE@U2SKx0pAI9FT>OZH~VNEA*afv692|h6)Amfpim|=~5^D%)X2g1)4dk zeMipgQviijQD7M;d?2@pnPsDTl%=5kmv_!3Zh(^Lrk*<#7rkT~C$#}Z>(=kDb-Wrp zUSYv^thZg`w!Qe^yD#&}NYQE`N^T=Ms#=QZCh75nKr%f@mpP_Yn`%hvAWDL66(iOi zNIU*cjwrZ%vAY)$wg{gZ-xwSMu?|@^zXm6ICBB-6B=h7I%W5#Pbw95X@*6|*QQoAZ zyZsd4^gFooZ3j~*_xt)%D+w)3Z(2^fi||+{7so+E=+pdTv798NA}-2cyESxCaTBkp zbt35>ISx9;J=={S0Vb<)!v;IX2qR83o;dPAi1`O{<$2i@)L}m2+IU;ulUj1*84>BT zsAySa0w^(nH^i!Ajpy?Edip!XH;p7L`+@wV^8_WMxI2+6bnVUBGT&M>hRBAiC#l6GsB8Jo-GW-FIyNF(QYm2WQ!u` z$R`FC+;o)~`~RXcgFQ2=HYk~?0>kY0t-e1R&f@5fV$>aTmJ1*dHJ}o%Y95lnAP}Ux zgyh)0DAtRUfy^#NX|zQp#lSILf8=p>tF9qiSSW3<6PS4wUYh>Kc|_%of;RGq_K!tN=^%`C9asAg;uX_gwOdC{UO+*T@_offaYbVCkv zp5P8+XNG=NWJMx^QR;BfWBqefo<`7;Jj3e1N8M&cH^QFrKU2bgZK@x?@@u_XkzuFi zO6`cN*zDy!T+;6i>i&s&jEChF)%BSGv0CgboFE#hK5W0J<=W^+Dse0gk1fvp06)9LGgP2E_1p>13~#V4~E_NZt^g z!UKBTti0}2b3rynCrA5hBwyzRCwjH?BRYXtUS1ym^S0cq=mcU7V;N)Z)Nxy4Bka@} zc^AhEy(9B0hB?Y+83QzI$hWsjYZ1@r-3%9fnn%@jPD@_UogSkkscL zp{|J(uphTF!p)1t{4U~H01gOZghSio8@~_MdqKw9IfrqfSlA$Ob+OXjb73KQk^UP|14Ilx#+2``{y zx~cGt<>d@i>Gfc57EGdE-h4hM9Nav=y4r9=zi)fKxN7&Gb$!#$M~vz-ujx%2tQ*_q z|Nhp0MJkbg_IZH-B$Ur$QaX0av%9@!`{Y*#P=;)TuJ71{YA}FL56$hkxJ@%&QjX5G zv)%ca!kP0X*8vPqaPDz0A9`{|$Q+X=`g=#dT-)B`4pt(K|3WPTOEgxa!rvhO>O!-Y zs==hJ(ENSqKp1uh0%H!2EisGDs;4J0V4ZxD)_r3B4xZNZb~=zKStN(EgZ(g-H$2;% ztAvmKZ?v8m0U(B%@6UusZ3`El)}Q~w$MShRoX;NZp;*K1`$dEi6eDQl>G%lDr|mS~ zN}&WAcIHT=;Ut`X4GO|o?u{0!?%{qgY%KB697Cu}Y(9h;@7x!$bn7CoeQ@h1!RY&S#kzYxs%$$4f z5heuXRIz65q)5GkNw%PZRq<`*-m}Y!!@G3=3_Ca7r+Gn&4CAkwLPU5>9~)fe*DG9P z`gT3+jx%Z(<4h@A&8ZI7kR3f_t_yQ z)nFLzBYm|Spxi#6`jlnsbvnkU4X+1He--Ro(?P{w6g&k6Kp~OO^C2YRO&?uS920>U zs&!j;lu;OZ5htE6-PZ=n0t@{CDrD_&`VdYZE!U2ryn&}TNW+@Z$@H%NT*ed)_Iqnn zh89nWZa!NDGdtG}0+p7x%hKehkgBV44b^igI~pg&!Q8o;3`O=r>R9K(d=LHPNx4O2 z1Wdqucp?8-IcI{A58ANQKr~??^AGW7+3_9O9?@o-p5lb=^l1-r#uz153o$N6A96GT zpgF_3dVN@+HSf&u7YJX>$U}#&uZm>xujc5*+30PF*2oF8pND;^Kb-OR6tA`Flkx?+ z#qNQUM2s(Ro8Kensw(dx>90R525v*w87%=Jacbs(hPToM~K}LG-bPGa+S8X3t|)_;mvi z`YNvW)re_Um~kv&p;sQi(Ud8_RDU|TAOBfd1fdRNR&haR*nsp( zs%Z-s{V;{?5|zVQtsMD_uZ#2?8QqXsoqtoK&2-WJ z1U!(0@Uzdc_B#9i@!U(n@61dpbmNFoXIQRP(&S|yl+nA&P+;-%G6Q^#PY5MSrvoij zf+oWGXU#Kd-t6Fe{o@ul{zvk z3@)q&eo|P{hx$fNZZGEinZAcuFUi39iheE{{k(qgGN5R%MFT#>X6gLJD$C2OgJGgdzB~bQh}Ru`T+){_@I3#demtBti-GvZVs5B3v@BoXuu-WETaC| z6&%HA<7177L#g`dqaRlo{zwM$rr0zuo_J!!ZyOq?)MpEFNN79{XBQNed0K&%9}Uv( z9-MU~zRx}xh|jeWZy6Fu-+ezvM@Zxst`G5hu*2pKJ_fk{_PI6a!CBRYFkQf zN5Dc@_Vsh&!?tDa&ExVBhOZ#^H@UeaR{jUUu!+uYjunqWGV2VLabSB^sfls4f}1RJ z(30|Ntw<6GlJ_ecNyrO#Fp9x4)mmzkfqJge=uJ7n5j}5V*yHn|MQl*U2(%+W552;! z67oQsT+6?-YKoHm78zSQFhQFCQXP1}0zq29!T`MZmt?pix#0v&wPglU`-a1LCE~N* zrWWkYyR7kO&*a{!X9TL>{xUwc1)!PP_ z`%Y%X>IL#JLf{8yds|qY)d)1i2T<`|+?tS}PC-_x$!t;Uqvhse4q6J84w1=#Tugxp za@CIPHZ>NGy_mZ(&pADQTLYEOQuny-c8^o7>mWX+10}CqgV~o3R#K1M-+CTQQ@Oi+ zmRtUnz_Wu}h`4E`9Y%$6@raC7GCNM_URn?9np&!9E0PQV{9Dy^2i zQiZ>oJT@f99ZiWkz9kI}@S?8cjamLAmhR|_^F8%5Nv45!=ksMT*10_f=1_ zT!kS_l7&5$!}umweIkrca>1PZdu1HHV@PoCWTt4=w$#@7Y+_Z-=%M8}*>;qnstU+% zXmFelP*^0Na^#D_2nh@q^taBWC^46^rns`K&is> zAA`l$J8ana2zsBXt+)c+^_G$Xhv$}Exa5u0lmn$SqqKNDl15jz&L5@PtuNSfDviI* zVq&ercO!%pGr5;(iHQZper-DEKn3m=b+4?4tV7PWbIu(Utztku7t{nxXE6r8)?q3> zliMSI&nzZWx{4;(-6L^5!vec2d!57BJ;8077ij#!eRZ1pGeVw5|HwP+QOkL-V08>p zNLK9aDW{J9BW5uH-LFGYDeZ9x$_QgyP#tVYEG;T7x&+^Q?^7vmo0;V*vD_GeFvg89 zJ8{Ottk_~b9++)AvxSA!-(=#;+UZ)QaR%k# zbRbitxTs=s2npa67x>nupQfuw5J1<+IubXDUNQG)EdGwknP9&N|6d+HaeuT9YTrP- zf)rQp3gG;|eV23+7`HP#6`&woW)?2Gzy>ExAuE1@276lb&h6^%W8DXp*5?toeEAX85Efdj2~zbAY>U`>8%DPzy=uu3`GN01aq`>r-wKzo0W5HFz7vIGd|wPx zOw)9q7^seJSt2l!I8`N~270|q)gq*Tw6H2xK?AfvX94Q%IQ%Dj2BK#S96&n_K?%qy za`0F~YKRz>I=jfd_`$8Tad?+0rSJg;;_03{;C=j<6}4XDnE8%yTp^F{H6dXibr8J|$e?#=j5&TR$Ct4lwkzLhI)q zByS7XD%Z_G7r?jjvYL=SSRTu{XcncCK;6FFk#B&w9hw$9sRAO$-dGzY@K(GQ0*U>i zbrd13aHR)W1zf-n7x=<|446(q?IYsD7r=uxXyNc2w8TI=ae;2+Kx)0FD-3MaOw~h8 z7Ji9u6GY}?ms783!dbql>^^4)5LV3>wo#MA> zRJ29xv6(32LQx4(ST(t*k@6V&fAfIF&jHMb^XLykzXoRurid2bKV1IhiL|!1Bsf)4 z0v9k3@hdON3Vm*g8%*lisr7vmDW_o~!QTqIV2lCBDSfG=HT2X6WqCwYtC+75C}~`t zsW8w3QFldhg z^PSxdmcyYtn#LmUyL}0_^pojQ9nSZ2qe=rtPapAyN`G=P^!5CHbj1F<2{Sv*t=9zU z?il<}Q{5EC;5xv@(<3(AU|;(1-HAIjy#S-@;7!RY7fx?dScPpKH}erpa?%s`dQ@}_ z>`iVY++t|`PM(tn$BbRKuw4f9p>2t&m_VhQsy{rav$f>EZ3QRKtF9p3zi@55_`Jc2 zgxLYMYas$S1X{LO{90a#ewr(JyE_~{X(051a%H~u&ZU=hf_-}M^2vIDzZi} z13qfqlSvN7Z`k>Gtcciz0)XqX~+LYlp|Sg1tFHbx_e9ivxp2zT0+Gg-Nc!kbb8v8WnpIe&a4B`ECEv2J99Q zvI(_dyw*dL#tW=>6#WB5HA<^Pa&0}4&`94EO5}!xfrFdM4y&Zfg2d@zg}`!xfif>k zpxO;KfUUcO9JsRTu@@3a2*CDJeiT*x052|EAu z5N&mD3cM-ds2LUoRk-4E2(m|vty5z}4T+tV=#IIR5pX1Nj82>1ev(4w$lvCO+DT(A zZT-DD4UGlYFg$AY%=VM)LcmjLY*Q`tDkBzDBG;{FyF|H1e)Pb7{1~YHdn$RP6*iBX zD=t7ffbA1}3NnVtQf{P}FJqnnz4-3niO%Lds$eK=`U?f|x$P1Z!ejy@ifN+Qd6%w_ zqIo{-H;7}+`&YyzF{uW5BD+!(3_o49e|~fuL;$>q9v+vGIum_K1^3Z~=IQ2^abZI> zdYXZ+Nl}>#SmoF?gj^&jfD(D!HXEdtIv@;;yZKQLWBEMcISOnX&&7dGWia6>H?|p8 zg{?JeAC|7EEC@!>k2KV%3j#ur1a38Eg$-EV;n1QXtW;sc+gD5&j0QI&d(HhaR%(hl zni%uhj_g~|SCGSF0Q61F86N}&tu?)aiLz$I>5YxiG3C&a>DkE(ww#z~P5F(VVxu?y zstXbydUtsB7BH$P7`NJR(*x-&;f7nQQk!)7lu3fXsEp+$X7FUK4HISq&kZA$P$$AY zgU6ynMq& zG-*<_6Yr!Rc9+#kYR1!gX*cyexY)H$$3W+(5RPD+6qa=k8-}vz7zH`%bMrL zkWh-;P&J{q$6?Eoh}_Hrg&_nYJUq1#2_9)P(dc&{3ew`FWR=XRgY;WV4~MbW-9Gv< zs5Pxs?FjY7lfB$;EwH6iHFEk3ar;E&Hh;*RMymoCvyuA1@&wt_cFjE=?B8BwF5-!m z@y?q5&_PPNh13TxExT@V=ez{SgIkLP#IKYG};TV{7M3`l;?kmT?F5((k;#q+`C{ z8Eel-D#3e`!VhGU$!+6G2U6c?`9-}3(g_$Qt2ivt{0tT(T0IY4-un7+3X?_=0R7s0 zS40C$HzW%sM+R>;g9yIE(MGf>8R20NPH9Ew``bzW>j%{6Qh5W990{RGYb3Ps><@3P zECfm}k)&~vq-RlHVc4z`-EAM5NbuOk z3OMl&LL^uD@a8+jL`d|s^Vqe!@LD)ugUybka9+U}PC3h^wv0h?N-$%2;zdYFL;{kr zM6-rFV5Jhd{q4C=8pVxUE@{%=nSP6$Uf7Un2TWelSZ&pfQ*%0b-_J0C3MhOzT)-ra zxyWTU<)26N)KC4fSQPs8e9-9{6<;XTvG{~;0q4GT{lUI%LSsNl0sIk7h8^-qPdk1- z97-@Q4o7QP#HgD0mFa4BRspNq-m!51y(B|fEO!yys0u)EyOp&nAUpYH$OkDhjJ~A{*j!n zebOcHOUDHQzi2QV6#jHHsI zmA`ooyUPkJ>R<%LbW#BEY35$7MM236Be=TkC|%aXJ*bR)e|>VK`hE$*6EkYmD9;GB zHUqMHllpFTNgbK)PcUHO&}mE3N(%?!0)<>69ZB&r3s-$Z|8R|CdMO5Z^DsT{7}kMz zvnU=l_ybhT??JEdak3S!bArS%1Qc8NdsE3XGjN_W3^4X$cxXTvyf*Lk3}3BsGtVZS zsLSx^66I24ttIxIFD^O}i&19-&@Tzp<>bkxV%>9`rpR6=|vf6q2c<<7_8)af?Yf%H=ARrTTYFb21-bWdE59V2({e)48S+ zaZ|QI4wW9;B&l=J`Z^e%HZ^>f7yDCG@@7GQPBoyMw z7LPA+y85hK(de9uO4w(j62T~{zrax=czu62L+xons?~_>c)*%t90Ivi0-#5=Tfi|K z0FakZU{^t|n{`hPFZKc2c4m>%iVwyItKr6uVX14!LHL^T zEkiPzG5=Aw5_+Lqi;vt_J@6efCive>=zDsC`z)-i0R#t%a8%cSSb(IHocOX4NDzcO z^UBRjJI)eOT1LS@V&m1T;X#q$QbQ6PGpgA)V>Xjg`tBX{`hPCuMaaMh<~3U4(0Jgk zbeJ}tX>wga=Q44;K8pK!m<|W(;c+fw9=#)W+P7!H8J522t=qJE!vyr$pq$$$?1D~6 zdg6&dHa|=IQv({TljbMN_ zw_!f{yvLLD<~q_kDDR2ZpkynDf|-lIkG@A9dSa5sPU}y&VmY4z@7p9-khARt-hQJN z9554=5Q`U|FP&6a2jo`=>pd!0`no@e~{5UK=Vwa{m z34))tnX^yuQE)xv%0gs}C&vA$Bwv5spqQD?SOWVB-y(TR8E7a!EutOGq>cE8=@*5_ z+C!CfJLsS?-ioEnd*zNKL0>%A6~Py=(rG^?GYIl6g+fcEP|r<}79_W4@T`3kNdx!q z&`i^6opW9HzjeKm#C***+r_NMhyw_8$2BH5l!UUP!qC~cp@V3!q~DbH?zqw^c}n;- zgOg~x5J{zE%8P-ab;GNuO6!L4U58CUx(lMBAso@0aY6zC4mb~>ssFQZjErzRcR;++ z%veOUJ1WmkRto^S;6{616S;GCD$tT?@Bw3IWJIiWsDl3ir3E0+ldeO|t20@$zmrI& zy&(y7(=;r)Hrca3N(~cm=|WEv7{&FN9NHO^Tf)O#6DaY%O=j$by6Xh9W8Tyk9@gM> zQw=Vq&Y{0q7DV8|5H<*4k{=Px>R6On769m};zwIDJR2rBl5nhW;>H85h>_#%2a}jN zDO)zJLWN9$9yIT&ma2r$p3CNhN9ki6>idK^uid5I^dTp*LI=B`5lEqzb8`ZuHWYMX z79GuUKGwm*FRg8fNxJo=9Dk>8!t?O{oKu9c)i^aDcIm>Nez?vc?0s8;aW1=`J1DbBEIB=4kbGVj}lzK5m-51*-^^@ zpS-ma@n|Eb;6S;*qxM*&+Ytx8l7TZRmaazbN<&A(ShP)D_JJ` z=#&={*A5Zn(lBfGh@8hy4OeoYId^L?k;lb5<+)J4SY=O}>`3AwdF?_Cq{arx#9B<|OaPiw>K z&jZetr)7o9@w&C*^RKqQ>Lyliz#~KoKS1sc^R{*UGH=vn^vtt#K6Tgs(#_FZPKywe z#d)umN1NeBM={6J(cusPoNZQ!rwo+`O6fND<4+?qdd8_`%FmSc&^!5XdrxezD7`J$ zqI$E;kI;rG9PKu(&HIXVi>vqB<=plyW;A;@wL%2Z`;M=0!J$zBep?zZXz_EdUg|9H zX1b0e_wd^rb(<>2NCNFuJ>_@N+Q_%DQXpuM69mOoI_oU!qULPY<1OzwB}&12jbM+R zCs1qphWdVTaNWCK{OmzGf($i(sCQ$mfTx-i?K7BoLGwe62nf7PmbH~fsqiiK zen^o-Vu~ZP%eE5fBoy4CUg}QV5eoI@j&^KtLwXO7d|H~ z2aX_8jrUx6UGi=92OP1^ihA zKR`o)naewDuYsvyDq!hX?=2_47q;j&2eltk<~mqa__Z);`|2g0AFUdk?QLlsyn=>& zD$;-;eTgymKCnxiR7}NF;UP($l=aB*s_ zf4ND34X=;O2w>*A;?GcK1k)sE=mLtHD|#puaNHkw0Y%^x5MRfwss0u6FV6Qh+=5s9 z5D}nAP&J}g*hKzW@u5mK0}JOX-Ox;ozTm%*-B(PszH)>7?fn<0x+CQ82{Pt+ffnaQ z(S|)GKRuB6k?wh?c)551J zbdi+ZyY*Ic_EEdc-?A!)=3$QU6z*R2_CZOO+c*v;%ByO;g4trh+*RsP`^dz42Y);s1Ntf-mz@^M zo)@U zn8duZIU^Z1+nGM^@m6xy^3ngkXkjG*3;r0TQIdOs`nT&MrcY0k05-_8piBYu)A$_W zr@0K*2?$@itJXCYeM;TDulJl?cV4)y;E5sslKUm$BtCxy zw6Xb^!?0R13Bqt%{pRT>!yd=<`T@*qb6}4a%6S|}Xg9B0|38*?o?l%$pdN=ZE*$DJri>7^gth_TfUhY`C_Q zUR{HFn(uHNI6Xnt-`=^^|JxM^-n44?ND1AAf&d%@G^Zi0|=ujrch4vOd8}hJ!xMs0G#lhh^ zn0cK?0^B~&F|C}Cz}f8>9k3;crF{290zeZdUr$(AV61^=N{A>M2gOeb- zvFlGWRqJXa1Z?%cRa$uYS6`I2Zh^=Pz=QTvk@4qb*yTR^MLD z3@uO|M%r7nBZD$(^o{wx&N?)Q0PC=r`sPdhVziUnF9Xp1?;6sR|8c?-WOJ*5%0zMG zyf=uEG{|_pxMNT=g&X!}@_N*dN_lFwaTQMqU_uUdWP3F!^1tG*;z}2e(?b^_b4oB2 zspgKqEpIimCWRFS90;0&z^H%!9KKCKNb(e7rw{n|KbC^HsPGnpiUT`1%43>Tz$d zAH3i7=&}Ch=YxpP!6u_vlOROznIZpfZre4S+)kG&C*N31e~=vB)(0KP>)b`cQModl zMDDw0cnS%{vkCL>J=1l>hJ)D-V)2>}oc=np2eXD?m}ClMeICB;ZHDGZbuh{-+edE& zYxqdyV(`=i_J8k?_;(zwP-BfOy@OqOTeVHy-0QQnWw=mj5z^2W({d{xWXHs&q#JH+ z-257Q^N9GEdwX&c~r9+O*8Cvk`nA%rd7+ghI|naQ+K;I)&+I5e1` zR=2sn)_PMcTT-ufNBbB5T1V$sU7@w2XyB23l|_A2(@#9x3BUDsw#g|zbpP$|h2|Im zetMvfe-19pbNag?W@yY+un(G%MuJRE%bqs(`Gd%NaGF7}ub!}*fKkp0h0pu6?A_Uq z^!pM?3DNa;Qc<3#zu(lA`^J&Ib-O;0uh$L*3mAQnTBno>{cja)9JH}vWx+W^jU%ql zZ&^8A%ofGjvGC)b5~zNw5~Gf0F$|&^K+NTy%#p4eaCjgj3U%j|{xwcJE$QmKdrK}W zEu`<&C53-0#V-G%#NL3qxUA_5syJC$bw}x&*w<#i1xZUNu>MntJpN}z(B{@Dn?vF4 zI%~a~)9b5NZNMxS&gTMhd709vQO4~)t`z2oJ;}CI{NRRfQF+|Z;LtefJ8J_$9bt%k zhs52)^6=}&k);+MuCN0`Vl&pAYMderR1$@+GJ`pTWUWurZaZ63i=RZZj=+M;BU1}T8jreHO*7f6vBjx5g|DgK~P$B9qyuuHvkQ~V{v zb}GY`%d64RyV2jN5e_wkTf^a#rqbjV-Lsy>*}bRNMtoNaJ>$E-X?0TQS;4L!>=TBW zj)MHDT>r+}96(c6DeDW_(O<<>xoVN}3b#%T?rMi8WY}KKj@NL&L2U5$+}19QEy#EG zD5Z|wnvPEoCnjWW^$*Sb;!ZQ0!t(J)f*|(Zi~yN+jyle7Kf=7v1MA5=R5Jb)58A@0 zN-*zlx~{Z~k6$-hG|Qva-(~vL7g)Hem9zQ|m+c(fvz|h{dJBZLTVm_oC;O4l z0mG@bDNH$cPQw{xSPv|kd~F`S54%N^_vM&yE935`fhqvolnh4DVypqsYHMLiDQtPv zB$iOx^A(y3A5SV}Az@O+E#S{soPW1IGg_X(mkb&?*pMNx=5T7f99EGvEBk$2Lapo9 z2%nSoMfZEnnP4#Cl?DyWis9dx!Ai|drlAVDVAWv}BiCX{{!csQm8!3U*r$VY(%zr` z{S44j`OkJP1f2~;0?*&!kVq?t1%c)N)lh7wq3old^BY8hJ-JH;<=9laO!$LsnDvDn zp4}*T3OXuB?4XO~F-PcYpet$SKRI1-@|8$==#g3u-WbUc9?h;hYhItTt*&|W(dPHq zh%Ek>FW{t}bu+#dTbq5mXL9&4T~fB*9o`y?qkJOiB$LgbN(TeUWFsWT8s&>!g)OSbs^AO77YGX*4pqx! zwP_uOZL2YGAbTWg@sC(7D_4<~XdW1D3~(ra>I46cfG`5W^qlt_txkK8egikcN#M2r zFP`b%)w{t3S|?2GzP_3*1(^6t_)QS~?aVM`EJFK8rb-~$w4XCOT4W*{Qk-Tf{cm7s zZk1AkZU8+?Y(4Ar9wmW;@*birGZ(^_!k!01uV4PpNcsR75|IUGOAWNF4}AUy-#$^e zY>Lr(Qr;0dXuQDIFPM_tiU~4jpnXq3gzyv8xXkSE;~E3u$(O=bS&E}y9s45CMv$`x zN(}`TO^hC9anEV?eAeN{M6OT{1x_1mOQMF-Gwi{GOmtC`mBvj^a(7*P>VS>5RG-S`_o+ZtQC8h)!yLj_*{k`| z7_p_ha&)?keZRfLHE|e27%+%eG#rcozehuFm<7uQVXhXw4)x_S;hOCq>NIT8s9Xyh zX0V*^ElJrq-kHKc_^2pAytiREEt0-@a?G8*pbbynlz!07f{(||eqS*;#g1!s-xPodU#qx_aM&%st2M}`$zw!oAmPlmIT4ZCz;W}^ zZOpJ)aRN+AXaP&EZAg6Y=-Xz_eVU;~eIKQyY=>QkKo4AGgpX#z7)0_E4g^ax*0O?G zb9J^#9NdD_#0ZbAzRDeX>|~&307Dfc$UT2RN|b84Y;RDZ{B! z!QV31Wm!-G)+Yl+h%1XfBg0wkvc%ObX z17#tG3Ns#(dJ(Ow>2w=>K=5`T4^2TBbyMVd8M?d?L+t~e`k`$D40YbkIm7i5?X3T7 zQy*5pyQVNLg3w+)ZTc++;9u{t-ax;nsjQ}~7^T@~L{pwoz4#jF+5C=6F5FX8O)F_9 z!Ri(*j{#U|@nMCCS3;O3{G;@JdonH|!pVCI`KC{6x$(RokFh{6jgbsuwAXKvGbNYH zcue)NFSjiB+!QC}>6y6^z9f&k2ohpG>Zor0-sX$5E>{4mllRRj0o1NfCUNBi4jL0P zQHt%7Uvbjqco54h!er$E#A;ha0toGg{b>_#&F4e0eX0UMWFU$ryHEk0gAkhAM9y&_ z`y4;Q7nnU?wMMM5&8(~xZY|4>WY$aRiIDKJ*?8ja=mY(R-Ph8~)bQQ(un-;@L={;E`q zi)Vy@U>+%X<&*IScs!Xq`Zc!rWuJoJoa*#dux;SgWl|aRdaBBC8VuEI`;%`6=O#l_ z;`+Np4HY*v!_LMG<$OKfsL~Wg?C{+r<>*xgA9-#m^0>Q zMo7+1Ye+BvyDGEy;qxW`t|1N`+_=nZPEW!<70oVH;xB*b`UEpqGFX5ra4=tPj319i zScXr?BjUKpeyc+H%0dqD)l;1iq0q1?`BpInhv(mLa}7H>)aMNycJ{GiBtkWRcb*2> zd7nK9FX577k>|)yPOAG&AJsDe9>j__`~95)euvynbJ@|^cpSxbp!E&s+#hPvHB(UW ziNm5xsZA(qAK|Ac=&Z8RA0mtjAl=$QCXlMwpCUaZ%xXOu*{);_sqLKBB} zr4Jp-`s`f#hoQ1)rOrx%&Sc5$@VeoTR~lh% z?s#-Jab(ss&4?!IEqa30NlhlmitJ2O|cq!$*h*)|mcvmVfA9qj)atX~!WNgx7gFSBYYGwu zyGmr$la=o#ahxUqFUYds9-_U>v)S?Wc#G>KhG(DB0sjjXEgv4mB~QO*JyyU#g;}Ck zaf2L4_ue?{JM92#{9JD6?Fb#M+AXL_rE=u-7aDVZLGq~QEc-d_u6f<#lsp+zQFw}0 zR#I-VowX{zr{(o{T`K0_P3vTg`26Ll)YrSQ8S5O;GDcjgsR|V+D}QyhZ3h#SpjFV zxn>=4_-7eMxW*-1_lsE;Sl@~Zd@?!}*QJf~8eg(Nx{s0GFsA4umUmL7>GQH2DPO;f zB*P%s+_QhWq<;_jLdEzz{5QFx@?Ju2<|6^t-|hzWKCJqde>bNCV0U~;o_AG+fk07{CZaewVX1lcmWTK*Lq_SF&bF_{ z*7!)TL!x_6nL0aen0XEbO47f)HQwC$W?KGT;k&KvgX!K#vz=+wLAO*-I`Hxm<>vpe z0J9XpAx7XKUM2Lg7noi*Lk_$;p#~(8{)a!lb_kmW%Xh95ZD*C-il9py3}gxsA7McD z;bkMfZ^+Tpt-c!oGXjglf1yiSc>1u@c>$VLV^m0R;zihH$hCe}$b(ISyqQ)+L$I~t zO^Pq7sHr|mI4s|UP5bqd$UPmgF`pF=i!*Bs0HsM)7`AO z^A-9c--8QgKBo_dt(`2U+wtG7pnq;a(+!JWj$V&+J)sF?K;+(SnI6DPe3TJ1Cq^*< zAm|`Z-u7Qy=JKg8eCSMO(i>s*ATM$Njo4^ODT&*&B_%=js@pCF?x>br94ZpeagHm6 zGos5n&xgz#ha1)-hSSZUa>wtyIEC;zWThsoBX6@?bzydGL`vxlt9= zdh=6^7`itvwNG`Sn6fkto>rW^wt7WZ+~eqCVXVMB?Svj<%8;POIph3(X^QMqi()fw zdF*g>SjfsYHx_J;c+Who&%<9E+Tt}_mk$A=Z%CR=>cFq21S(yAG7kHF1b&Q#*RT5^ zOtfMrqMsUe-OQs%NCbBuPAchvmVoc9{1Ouyt{?HbGL^e#Z+y7lESdua$a)Qzdt_^) zGTOS~1x*b`IB&Ruu&Z<(-FSV|3&PBqbxMijeVxd3PiP*Ingpg7t~p$}IF2A#gVUM& zt+rZsvc+>>T+8t_8qu(LmMDH-yra(>yR#jiPZ0n4W$1gVwgOtHFG*vhsF49O9PI4H zedZoO@DYwjrE%B8gzmHluXIaauiDo)EhoqbxhKtT=scvqW?oCCx*HLq7YDTqx9fh& zps&%m+T%ZGIVIqHE$#dmp>=#?u9%9)IkL?y0<)YB%}IGk>X<|US#MCr6;Vxbh$Ms>O(v?wc z$$d&fs*)=Lb+cccl4c-hH*nkyJlBo)_YMRbrw)xSRXnz!T|Pyq(GiA=FK?pZ9`CPF zzxBzrgoP^ql!pa4T#`7)tDpxe3=hh26V1|RuJcsD?9>AEF^53o#v|buM$X_DaYzU! zLwIZM0d=Y9bsaw9KI;kgJWqP`Lj(C_1QfuP{pEwaGU`mjBa5ctp78*4{C$!dUNA0= zT9bW8AE%!BZvNblhM(y5S-boOGd*vSK(3I{MxFNqpWjte;PKUTS+kWCANCHO(og~? zn%HcK6kdnwDb-1U@)BNSvf44+;B;HRqiNVSv8CR5R1wKm$JJM<8seN{ zLLjW&SMDzD)4s$hMeo@0VCK`H9Ap6>Rcl+@@9M^0CYi1*X6Li=aa|`kOQ-QDa#@?^ zzM3Wqi9DFGM`csi@_gOSPfnt1ju<;WOisTI)tQ1IGt-hRAI@)$NMHFfD9*bec}c@r zwFV#E(|Jx==|fdFVnYkwq*E3Z@Q)-YoQL@%Pk(e;n&cQUy3;?$1S>qYAJcKgK>-f1uQ5gj!@G+Ub3=S z&%#bWKb{g6EZ`GY&Lbr{#$?BF4uPa$!5ACPcEVN}aBst;;MJK$*ES`&MU@-3?UbG2 z-7sBo%Di>~?6$OS)xzQSiuQjW-Pu^&$Xe=@9}*u{lzUH?0C3#V6j!W|sEB0W%CB>uhsBNDO_lqJxw+m>A?dOW6 zaMC1~14r~G5d>@^GM%4b?ZV&YEv*c)WvW6Pn67O;shjTv}2z}JW?c^?Hx>L~CHW4+Z3 z;YH3htbz0c-QxgH` z`e)CtZMj4#IJIOMp0>tCf4eLTF%TKtK~2!_ZIgR2&oez*VAxl&R<-cfSFWhd`amIZ z>_iyQ(s z%DjMkRV?rvpVSOS4adQ!9{AeM0rZmORsJmMrvF3@f6pEIJ7jZhr(fF8N$Dmjwep7+ zbsVK~X3E@HS;kF&r%(j}H9kyIimyl$6eniC{st^4TG`ygaW+t--S9*8%y7Q1nW8C>gx61`p&PD>8nqIs;P4$F4Uu-&Aji2TMRVg1Hh*aOI z^1wx>y&MZB5bK!Af@T-p^f4A93qnP!_qG0FL=UG#tz_&~lg+O0JTv!N%lT}kOLb*0 zb2RJ%&-~AypBvM!@gPJxyZHy8FMtIZei*z|#iW3X7=`4OzcoPpo_2%!dEB%^!`GAc$l(qz5eN8 z-JzMs;xH*8y8Pr1W~M`S@i=gdmpx`yRPi7n2(_qkrsbPYa;?@ISt;|!eL7# z(RB{f*r$`@xzwQjV@J9#?<*!TG2eW|{fZ*K4dq}0S`O#Y-q>qJ*SmWd?*fXgZDb$MeDBm&S9;YOI zNqacOYNVt_ZnqHqXl#pPT@-D|xZ-RDMZKu1L-+9^uDFK)sA}+I6*~nBct*?FfXxY? zhSY)bd^UAg{JY_%4-!qQMAS!LP$Z|ZmsdceLYRh0O`ACWTO4gwd= zv@-|J2IMnV3eO?1%Abb&QRxp8h`?yHPJ6Fh%*{3imIZ z49LmhCn}l9l0>$;Y#VCwsn?K6o!(a9YWjc7ef2|B-}5&rN-4naSukTczai3hLLR2K$J&C6ax`+g#sr+y*$8FSXvI%pOP7@)e;o^v`}k z+Le))57Fc&{He^K@pdv);c^Xl-+F8K41+I4Ti)dTr!83`Y{)c~qYC1!O<7gn&N1C{ zz*1Ib+;j~SS$E6~R&|(hVXSkVV11*)0aafu_;5nvKeP&LAUH2=^RN`hJwnwfreG3PzddiA?Equ5c#*(`D<@x32RgIRk@rG9MxDSQX*YkaLm zZ5ZyPoR8vS6hX1{@#iv%b0vE^G`z@G6{0z+Do>OCrgY6Yjk}MHpJs^InEL1B-8vnP zz8@`GKE}ETm3~PN%gGH4j*SZkKU|&vINL`2i@=(0)n9BEg_RAoM^iPL0QW-z&9eA0 zOG2)j4r`MuTD8*HN024KcOMtX1JV6Q53l`u8*np$5LgB;bTS;#7#aLr-Pw_ zADRfw+H&%-8JnwW_>K6jY1N>nckOY!xOP_>-U2_}<6-pML{Wim;GAS6LO`TWr?o z9#xt?516ByPUvqAPngMF{EbGaCDEm6WXD3vf{G5vf1*W&bMvNw#V=feenZ6*$r{4j z4NZIY@{2J24O}X#fY&3^5X;Ag#<_wbZgDDyx-JB(<%j=3VUBmsiVJgN=*1ef$~Oj1 zo&K$eLx;{<>CK?5!}pmyghiusCYZS*Zf<5#V(`60so})9?jYaPe`9JXILJZyqp6jX z#_CiJprwnx7QQ+5cZw3(w`(ds0kfbN88YA&U+m@NQW!i%7X)p8{CEhm#pu=B-j4`L z-o2@nUoiyJexvPJ5MfK8~I-dhLAU7 zX6B533&(;wc@M*`V!ic;q#R44f*nV{wT`$8@s?Y8*_2wpfAB?7332{@qvoMK^k|}= zYrv5 zopaIZ5OgvVm}sGAadG8w4;n)b>RHgnJ@q82=iZ7Dm{I%+ui79f#F(We#6@1UaSbYF zTN*ebd!bl)XfIa0@-P2n`)sA@DsrSRqhs+AO=>%Q(Qinhyi)O7AQ>(Ra|tFmhCcX^ z)znNG56tFd14M;m6TB$vV-d~Qx%zJOcFpGHj`@d!o&?|tpKg>mAlnkyHGb8G{(9%l z#&M=){z77;*bR+ZT9q7;K(F(HnE-6JcWs1Eszxb82OawKK0)ke(8KSwhdtS-Y?_aj zF)t6$;gx$fj>Z+^OZ3>Mm7+?^5F;-2y)jID!Tf?(u*BaF@vzXgayEWLr!VIjQt;PpB&>z>z#2R!0mTud_)lsZ1q3f85gLM*9O z5%R&+Jue<&pnN=Tl}_fyalGRH>bu3m_z5RVB+7hM^`^~jK2GVVQq(YR6b{R%2E1iR zNC!r}-XG2-o@&-Pu z<@KH*F7mDBx=Ag4p!DSjxBr^8ai=Lncw4WAsd5vS<0-1*C4a(5SsGBq-d^lM!dcoL z=O_7l9b7xx;Gy6=I;xt!1&xVz(e6vk7 zZFe4W_EdJ_*OS?CeH_kye_3e`g$?xCaLB-9u{&GY*p@> z%o8?8;wjzZ>{2Wn3=8vtnJ0LTfiW0y>`~h3^)S4fCp<$+_%p$9IL{uphp3$Zn6^Z- zBL5ngy)5V)+~t_e+@Qv<5xknGHL|+!uEuhC>{B%o*g;RB+(GpU6x`v-yYVX&Lrvl) zY()FX@R)`_GpUP0U(~DJH({sAlxl^pNJiZT&4z+IoHveM6VK35Yn^*w(wVOr`a#=J z*lvf%YaPjRh5+SW^EibevHq4>ve%2`^t)hiOSzJfp>YR?7#G+KvEf53aaNc!4N_=Z z3bI2^)sqz27i_l@BVarmZJLwOa51 zUA_8Vm6SHl!HEiI#I{=JKY>J^e~kM&n8>xL`Dbe=2Ld!ax!JAwhk?ub*G?+r5lzH= z#c#2I7;YlJM%9NkIjGHM*%IqCRg(L};Xh*tBc4rdGN2Ci_Vv{SFv2j6Yu1mm)`l|*b60f4#g9T>IKVdp93i$ ztA8XYpo!2`1~AcJBZ2a>D@b#a?IUqcn9z;#$erRFE-BnB&f1ekj|&k`-}(uuz~AAZ zX=rCF+Zey~-4>zt`lUseNYyx|o8>|7{yH%TPzeMAY3niM1$VOVjQ8|Y3Dj%2pSocR zZKxEp5SlZ=9;@#5t5po%-Fbwq&gl~NDe{3wYnOwgf z3xEVq~IBklwdS`>hSxK0SD>tN@yQ${{w&FJ(Hq zc{QD*G@8ngbd$-l8BOyYw?j^jj`ZJOova2|onb?NZy5rl!(!Zf7<4R>wN)Nsecp+R zCzPROc_q53D;cqDyvZmQF9j24Ea9qy6-9;4J^1G~yE5 z83mEtAfxx#)=CVJD!*(z4X%9_iVa8_EGF5U2Ck-ad8Y=%9%soFo+0uqNkEG9cN}L{ zPmoeRwmn!IDGbQ(nW3a1hJ;F#;acJeG3dzTAkuFL=QXB>Xmb%e*GFzs${OMZQ~cV0 zo$yN~770hDNwO@qmGo2`NTS}BKixjG_p7r#g{3^og$%x`?y3~;4elZmSM(u^!-w4_ zG8Os>KyQAA-MYw8z=tGNOcRSeq^}$J5Z53D?N3?HN~SU|lky;=YeO~HD$M|Cfl-r= z?>>qedbIewgSJvI=3Jw*e}DZ>FQ{|RwYYhH8r+jV2CA)dp;j*De>853c*F>RlF z=ywkviQ%;1quG}$6ZFg9OeI&=)*249CR{t;5lUU;j7#%U!&IsXRfePjvpMA{%a)3^ z<1!vYx*VvnfNaYq*r6R6>{I)jcTg;;OApXaG2Mj5tBGRPZ?SJ=Bn!zu`Y6k(E~hH& zD^hADPkW(uYV7$%Qboze&(}4ie+f((Kt-0>RF)uPmJ&z4(eYd~NP19yW@GIbgXje* zJ}tsq)PoVpzFD#J%c*I(;vw3HCjoiPenom0UeYYVHwY^|japS!s*JHRcin^bmq(0a z$<Qq-HDHMa)jY+ul}Vs=+ipG<{OH61%e^n)*Hc-4skL@pQ7SAT5{jYzI$Xl>!ot9_ zWv>>)Kxp!+zj83Hj0io7*4;s;ci}un%E^&w+!v34?=|hkP@1&6B}HL*dHLX&FO^+Z z{&x!#477i?`Fk|50`|(4_4?GEA9bGAX0DtIvQcu&<6-PmIhWU*TF1fOI$-&Y3Tf!NZ+<=By=v_NQEC9vKFtq zJZ;3$5r@v-A6$j=JLa@3hv6>vlq;bgbO-4Y5W=Z<4AwSm^WF>7kyjGqAS&u7170l# z^88u<)>1&n@GnUsCMU;l#-Ron$j`bx-NIUmbS$q%8*K(2fbOIJ1uKI40qa$<9TcNAw}r;hcG|XKf)|tAYy&m{Kz}d&VWPEXENb&qB4<8)vwzs? z5YC1$FUiuqYy|5=>-*IXzBWkz$RZEX#Db^_45=c1ZBME|+2TR)AF%L4?4r)Yh!%Y- z7s?2FzaM_36ptc?`=R~E_!}O^5lin$BIL;cKpS>BIQN=J=Gu>N?1EP z8X!Grw09B*j338GV?Zp_+b5*%{$*hSiyvm5{O_g0^C^j(%71@!bgZiDbEF;3R2p?_ zL(9X;UZe|98sq%Gmp}{&D(HEkJ}rJ18;BEr7=cjY$!T8xUwVhVK8^9m$d@?JW^t-w zW#dVGcKrA=zyWrDtk`QWQUC(@GX-k?>-#?)0C_wmVU#Qto32F_x2mFZ_*+8d-BylD zB@Nzv)D8GMs%;WNq~hGJ21o;wy{E1Rza{AM?VZKp%g-2My_3F){;x~oE{e26do=xr zFlyMRSTni`(C&PoMYwN%*LTCyrF=pV#+hCBzX>~Co-_m=1xUMY(F-BgqiUw&m-}u! z%zP2H?|1$LP0=s?`5Z|#+p&=PVirUTfuaTWvBER$A4Wvr*_>S3dk!%s69p+3%Zyjy$o1Zz=O}dgp4vkT4M>OL(o` zxP)x>|2Kkx?XKffY`Dr}2vif5hz-}r#yVVmzOk~Av#soB!2mH<&JxJ0J;@#O|1Ws~ z+WxcdVm@SvqzYraaZXl=PQ0Uw9C9fHJL2t|f{CjZA`WewbI;c;vyQbGZZy@ys2nzX z^#AqkRe&_-2hq<&F7oc zNL4CWB&}oSm=w~2A+FnI&px9F@;(;v2#ll>eGP_zgLp}bBqeaf9Kf3-6PJ>)=_$&> zW5jZN$84CP`A)6+dXack_1r{U44rbh1|< zOCLI^CIv&<1}tyqx!%7`x{wtEtWqKs-_vi@RQ(TIYM&eSg2XnB2)S7DjTd1(^*_et zx|k|AV^gFWW+RoTGo9duM5Tqn_v*<%CjVXOuEOY+mGW=iLQ1Va8vT{5lva6yd_^sJ z--I0um>PcPz^M3xL~e&VQjdu~cKty_R(RzkAQE{0!W_@%i5flqFb1!KToM?yN5}o2 z-U|f!&J7OPrPG> zP5MmYh=Zr@M^pZ(>yTj4rFZ9_ZyMPlr03?)!yZ~|f>aR%)kDGfd8%R{K zcrrOU{F&_Wu`qDjtg`yXLCCDUhV0D^TrI&g|$s z&Zv~taHr3*^7n>@fuP_KnCk^iERNn4=9WZ~xf%)Ni9>zyE^buvVS8g4E5_?1hng1z zeHoQ*p0)$(`n5XAOKVieAP+(d~W6I z*={8po9s^$CU40%fBk+^Zg(|veRffF@x8d=T_?mT)cI4cG``9J=EcMY`bzhl_1l#= zq2s#Bn5Hc!!jjz$3|mWXNI@)|u!%+H zKg`yDdU+USlJ?=$g|icrM%n$G{^N-!six6u!I~!gBjvgIU{bqbDV#kEN#YIsJQ}ol{O$S*Ro_i%PT@9`| z)Xd(8Vw zGiZkhQ4(27Jo%2zmY2av?MQj1&sC73a*Ms~UG6swTfqRuwN@0tfjfUbUIo0_~$UNV>;*FvOcU#Su)ZK z2(8%{MFQ7PK&S7+j6)vFR^^(YXJf23OO$)cIB~1;mZ0u;y`~K1ILBYzG5e^6P@lT| z-+v2WkV4MfabmE!KdFb|obXQtWupSaHD!j&CnSRNgTReC{5%?b$~`7AoG860sr`<- z6MqoHxl{q^X`6phQ-bn?-+Kq77SzQuad{zPK&*Q@sutWsWL(;y2!EbpGpMsQ?LV@u|)yk;5RG763v9hY0uc0r#dTnmZ0= zHBq?Xi=r1#g*DXIh(!I@Qbz!=il4o9#SnLGPv>Cv8Undj02v}#@+6%#oWp zhIse)RPq07;~lNV;W_>uivH~1@K0B=@!g{T>QRW0Hk*Jrpka9|hk1}qaNg9A{x_%b zo%m#uhr^2Bi7={0h;J=yisSRJ;q71y0vehzCJ@7dfQ4~rw}~qkOd$hyqVJ{nXZS;S z38Dkbukj%kf%WXt+MguYB00C|bFBD#=a{~mo+sg)mcC@;y9<|1rbX`tGenm9enzz* z5zdp51lB`_TfWXd(rMml{|)9hLgtWArxM694*hl&pH^d3&VdCy`_Bi8qlV>44|BYR zjIZQMILdxgy0J2wqPri^zb}s>l+M`_tSivL)8CCBD@P(aEkC!Ri!wPVIXI|v^R-nm%M1)qaMbv-<{9rnl z8HpSs$XcGHV?o#0*6s(D=Bgci?N^FtHZE6enUuJVJJfv*1iFn!dJ1LofXAvqwkF*I z$$eM}9yQ!K?ev9cjb!8A)WoYFeTA2DXEu&kTU$b6JB~*!zIH!!Ir)QJZ^h*RXt(7~ z-!<43|B8K7}}AqUJ-?GXglpmGy;4cyNT@{p1vRNXt}j@$Q7d99@U!eY3w!7+cLg zhuNmW4r!5Ubnz=}|9zCFfC|*%HPekK0j#ZN_eb@40p%fcz;e1YBu^63$FB2VG7}Lq zI#g6u;{)R<;ogQpjqQqR05S72UIaypv}J%O(}6YpeAE@s2rHyzK{y9=WK9=u{!sU{ z<|_>vpUL~w{B3+|)@W||j9AgBD$7T7M?i49&R5>cmlB5C=$cabDon@LbpBMDHqC8K z+olihI_(_$9kHdQw77d}xoD%H@AoQRBrOTnz={=g?;`Gh4v;2+#$R@SH^na%(<$gm z-e?^CUG!mUL66GRjxIVhSj(pPA&Px3RePW23DN>uIJ-T3bh6Ei=o@DcN=i_8iFEeb zovOOiHn6F^c)F3F4F`Dplikdw3_!vESH6FBX7 zY4N3X=d0@i5?J1WU7KWTQmkH-Me+BJ?$wlAQB_|?&C8WYsr1>=I*Xbu07_jSA!nxuSg7|?~pIwT)=A_r}b+< z@ll|SU@_&_=^JCA=#z&bcsp`ZNYWu1r={ch+x1NVq9gO=tTLl;0_<>L8Y!mX(70n% z!$y*AMW6oK>>{{ve1%tRqSoO2GnWEjmd-&8T8L|+)xB1wt0S?skq7%p5_Ac-$=Ik1 zGdHayFlE_@4;uzD>19i!-}HQeZpDC&-w4WbPRE4jD<5Al_k7`#>#zV5vACqv*#)l* zBb6Cr-%K+NhB)iRjp^OFj~*mgKx6xpqgq|Go^dkbn^9Wv%j}b|IZioX&=iXNECsty zYthObQ{=V@pq1eP7U>*o7@Dv?W<=J8Q_}RF)yVNKb!)!#AF4(+lWi9XRL=2PuO1yg1tWDxL^O)1`t{p(t>Gb}*wx)3i`}qQs#kMrdEv57hy3y{}pp50<`x zt-VT^wdJUQYQS7c&><8tJp_}q)Cxa3XJT^=3REJqn$8v7Y#aCN9J~9yC05LNMD1Rz zcY(RJwyKD(4h`Y!LMnlLv74u&;v8Cq?iYaR?#hQ_pw zAxz$LmuAgtfUG2$Rf6m}?_Ic(thc)c4_huDnc&ohOUohq(N0Mq!bLu>dy;!6hc}ex zhuEPNr}0~NIzBz~bEejB7MSBhBvoQrUoLk&kE>9Err^N;qKD!SUO1@}d%RP)eIY9} zu>F_Mc@pBQCfo|9So_W=-$pOqJ-<08)%$<95Xg(_eqYCM0@ZIWSArc2%pBb_sB``q2T3%)pwHz~rCfX>1EH9Q^Xd zO4hQ#k{-sO?^6f@wUdo2wk4l@V&P|mm^qG8fu zuoAK$e4+>zK|D6DC(w&ALjve-Xmpn+X#-X} zcAYO~G<7?W^|x#&t0+AlJvwqKsi3G^V)<$`eO=a2ik0;(GR~Q?Qh2s5E(9N*;`VrdC`89gBXlbm7=G0?3$DT3K ze^uKJCL4?(M~xd4t7ppP8QhOiWUE6ndyJ+F8aveO?B;alAd*qb<~%UgIVE9R>!)lU zK-&0Q0UFWnb#nWGB(wlNY{e7z&9Xc8)LV^KB8Y0)Cz6m>-BY(ITn2hyrv;4s zEpybnog(c!L}73qB`$s3mK99nJba%4NV5|12c4v!$+~!9JJBZEuslxY2pt+OR6~MA z$I4Nq+z&wcTBG>RZO^UeHVV}t>9(&5f(M&Ikg0+@t}0hEEv*HF#=42S1G^#IHI5=Q z;%ytW@H+ZWB?SBq2~IYp-F{ra$sZamwIvqX)ws0j?MvO2vNsc5E8a~{fAcT^pqg}5 zcnECq)@Yo=5+57GtliPUm|XwXE?Hu7lv+^Zl^-fwTxK9A;lT?G^CkRJnaYl4!CfV7|9 z6B)+}SzJ1b>PA_7CAHe-+JZ*wkon)HdL(Ti{mi^cR3exs?!4FAWHP+tEexCD@1Lw% z6FR3gnzY0CviST<_MKtX6o7TYLGCN!*r3~J-g?VqnXwi}r}q2bc#`5+7civ(sP3@r zJ+t3%Vzuh&a4BI9Ajkd@?%?b!s(v)cgms;ExQ#5-*~YK4JXpG$dzfx!H_Gdgba1hT z=SKcUt8rs#tA^%KGa~%+PPy>EE`~WyG00d|sdd9&E&6*;oZ=mo{?9n=9}JthH=d)j!yGipH2?z(jq@F z?M5?bCz?Y#UQKQ)d-;T$=;Ol~h!)aZ-Swm^OSc;8*xul1tnU!u74==&B#GXd8}ilX zvi7RPx~`e>HOLA!7?r7C4kd{Zu!@|n`ySWgQ#(AO!7nJ#OF>LvVzGc*nD1l;wVIfy zoz^#?(`H%Tr#MC+{Y?s(M18b`6Fe;T5iaYIQQ!X1@q5L3MaDKXgq5a`G?Im5!@v>L zwpwn;$g$=*Cj6w$xZEZ0@fMS76j`I$d@mkr&-g_+8??e{s@MVp&EID5Sy@;LcWX32 zh)k%6eN7kaN-*Re5uTHSNE+ztkr^#d19RRz`$hSyo2r~r2|4vj13ccU(QXFUv`SKOwv|c?Y z=nwojR_z6aTkj?*O_FaZeAKpj#+Bko%D=cTIb7Vwo)SG8?G|ZI+Vnin`ax4b{P|>f z8V^!g65#UnP1*fUrt@1?cYt^h+^kr)x%fC-+U$%O;=(tOHG#Zw3MHQ-C@m*)>9`1J z(B8qCJMyhJ-T{T|wGIei!eYuibSRk;`oKMiz-;=2!{iu%vSR!7|5rp_j5%(3G@|b-9TsDCW8OEz?eLeEh8XnPQVv1* z{m-(}yh$-5{_<_ti#L(|**$Fe`|IS1s2@JqJ$m|z1en@;VJLETp9pwg(AEBr9{eIu z@iZn!5JJTE9I`_dW0RA_ui~o2UNSY46IS0?RVI)ESJg2kQ>k$l>70(hZ6s`$92qF( zFdd=E1kq=EP@ra1BN=yOs+ewas|)`L%)9NsWF;B2c;zt&%9dp8h?=kt!h*2eX`7er zA3RM=t6RT}j3!G31FS|bf|nh>_*S1_l(Sf3xRXHP>WYou1$ETow_%W~2}paU>nBoB z0t2_A=z*hG{5D%xAMz_(bEN2o&82m47mX70l9g?XrpNRlo6Nqs?h`D1zm&5Y_A;}D zf6htr{;h7b*dc-E|U9Xq>u=I9)WT&Dk8dvC2#%_6(^KR7gWfq#1uh);tEg`ZZr?!Mcc?zBpe4FX{zptj~ zEUYkOUf^$BfE!hc7sZ5O?Ht#2p}n#OX*1JJ=g`RUo#hjvTgnIyUB|Sbi$C7vpU5Np zqUhJFI$!(g#zDw^z{Dx$w8fCCCQMwGE0&2s= z)Q5>p0g8l%V2*tHK;nD#;|m+dK?Qsyx>cmH=mmym2?0r7Wm#)T5sMX#B#;W%9!wW0-QT?6nC=fDG5QaNlSbx@Z~R)4GTTo!?xyX_5@3(CQr0__KCaY{nseN z#QKSY`P)Y0O|h^#1++LFX{OW44cP*UsbrA#8akvX4e9}*$%^T< zCBsIcWL`sR163Ip&=we#TNhl(#JP_^@ z4N!%^#EPxw!;i(p=oF$jSiz0<2l)bP>7K^*t`TzN^ohJaNFb%xz1s#Y9L9m`ofNuS z_-%AJ9rwW~FK0cxY;`T@iv?J2+o^55^T>y*c_}FG+4DX~QPTmE`Kwh(m@wQ89Jq<_7G>m@BqfrlYLcEGbZ;Dr8ihl0=K zKLWz+o^PoYDawS%3==9aSStgsBnEL@F6C(V%Va`IW>E?#D9h@AeFpB zuFvM{u;R`S@ioOEAFn%cKxx@8PCTIz{+%BCYQDksqj^+pD9>L6+UF$W9q~p!_;aX- zv-397ZFR^Fo$_=;T*d+NHYAo=yfqmUr|Rl`>H!iw9O+{Lrg-&tsTy~Mz7KZ~PM=K8 zj^^8we3g3Gvi96goDm2|JZ)+Y&${OCt6oW3R{s!wUAv#0G8GORrwIV}ifHF1p2U#) z8f`hii^qyyf6vjenXOyBJktFmlQWIg6t!sp!r!ioG0!^f4MZ3dMIhI<5xFx3BR7yc zEcplcwaZxBcPNz+^~%=m6Wq4RfHis(K@ZqyY8q#8mZCc;ZExEqqXuTlv9oCfAA9rn zz0?^HtYNnFQ-N8JjrFYJbyjhd1D?-gh7K|GD>1ov34csqRD*sh8yz@7sS&$tq_xu= zWO6RG)9d)+2YTG+zCq15Mh-8nVnA*u#ZzKBAVL4LLB(uY-1^#-ifx*-c;acX3`FE< zoNBTuN_$K;4=2w4KPI@o_T9Bd9fa&2J|=goLG%QpHnk;lGh-QzYn(qOtq+>{&V`$9 zhh282cUDvQ2{2K4chs}vM>25TVBiUSSODuBvqwf&O}n4^5pX&Gz?TF(2J;pg2@V#e z1Mrk9t3@eslg36=oU}^6UKpjH*;HR>#hwkEjg(*2jNX2)%6zpPDpHtBqfmMJw(@s5 z?&qqmkoFs1I#VkoqNBJbCsB)`;^IxoI|LH17Y3np*wD7+kyg0R3j9IF*3SS&U1l*q z=knQHgi|!EW^N{iKjXo`>Rdy)UGIr$Jv&#G9t3w+XUFw@Wmh?Fx4YgJEoyOmviNw_ zedcn0WC;lRl=8uJrscQuU0nOHDpE|}DvkEsyWBp9OuIfuEUf6~H4 zh91dg8;}K8$IA>7J}}1uX!YMmvElQs?^L6lxI$TqNo^F@f*^r2~ zMB#7E0rsoEy#h6#K-)Z|*9i{9X5#Gk+Xu_ZArui}i^yt$MbL3TTMgP1qXlY{eN2>y zJ(xqJGulkcolg@_A0)lxa&dB#OIPe`f7QCNWF9*%5!^xf(aC)3hT)Tgm17&b-K)@? zqxwWEZD3aCde?u-?=9&cpAHyu!_Veh_23kCDVVamPdUA>a@X(=r(Aj^i^7Ziue^CN zth;%KL5tK5Ijmn6i3n#~eZ?<)6&*yAbfr4CLPaaxxUa7jpYrl?s`e_Jvgx@8=Y9fN z>VD1``xk~RpZ=mG%x9n@fDlBjE4DYSA+l%TT8}Q>gfu(0U7cV#B=*3E z58Hy?_=a&)f z`)EF9{BePGngopU(N23v4a#NLPnco>-m?qm2icy;u<+#QtR1`-fc$reg0H#Mh2}<@ z1ibBPB4RGT$$+E$d>4z9$Ed@l~X}z zg-z@HUFpHc?eq%bAXoE8m;osXy{SFvag6MX8I6@5YOtz+rcT!#-d>x20R&1#OA zh`6c8ngxTR0G{9Zg@?Bgb>Za-8`eFM*1qZbCwxfr>B62E#l^f9LfU5R%W#?#=r;)A`4n4urE%8rTR;?w>Cbck_ zuw>~dk;^U?mnt<1&8WjluBb!#C4qc5aI^65D6u0%W`>4M^R1EWd`q3Qj4`Q9^e&y{ zw5=8wjhOv8-9^NcJ=PgC(J4xLsm0Ou!9D7BvWPsfzv!yy?NDv=s12A!A#0i6fdU0N*mZ6-nT8S;OBdF+NbkW2-5ZrZ1(5O zK1$Nj4pHydL_(%J60$6j)yDRFT`l zR5Ti6j2Dum&E%)eb94?{S3PjF0DK|_q}_v|G8{0r{LMMGevG68qD3)}jX)AZUaW}Z(=aW_e z3G-gYNzIDL1e<)Ig8ZI(Q@MF*F=9mtdOK$CAjZIkWQ1+$ z^r4FEo2558@eWQ?M@)wJ+QNb7nBe+Q-e!}7m%5F(?!pnxsQoG5qx;V9bqMr6%^0d5VQP4mk1e zGxO4G)t6~dbNd*3dz-T^$GqwUq+%R2qR_qB0K8AxqSu&3ARZTO-(}-dETRrAg4598 z26SY2-og69@1%nl`*GU8%oKm)Cm0j}rp1o7biZ!+I!x+^IBcWf;0~&91#teOr;*Y@ z#P^#jXRNE_ig?`6QRu5HK+z!AvUctxdV9?bp1h;&hmf(-*FW|p#z&s1!F;dbQh;#^ zdoTc4kQqJHR6b%+9M=AW&PgH{5f+lkIjxlZ_E>oJ_mDmemkb;SSyu1~?hg!MhT`-h z%HL0{rNy|LxN5alTADDWuMAIuJh?73KV^S$M+eP^rkOP#gq+7?5Kr@Q<1`WyweHO#*;&o|` zqMw&i^xJpavo^}pO%I>^dn)oc>Jd$Yi+`0m1w=)B`|hqqO8+{!K*G>x!1>eGl#KSY z1EdPCWd+zBV>BVo9})i9fVA99Qq|9**hqh2`LL%V!EgLnVSFx^Aqs83_R0k$n8|{Y z>87wC$8l31q2d}FH;Q9>XPq$9j5`N;uW2Gjbh|rq+|xHn4Z^CQ;z;>-6HppAOe7vg zy>he0g>lHFXRCja4`gx~GXJH0g6+k|MM=M`-5qyNb^5`*g_r0;j z#F&n%)Hl?-{?0zPvN*dl^+3$vDSY*Gx-5Oioiv3yEvK)hge+TX^7`Y^_1Sc1mGxr5 zJ*M|xxg2ox6ty(6e7WIi@1iFnx}%&5jSxh1xbv8W?2UMaoCz+!>=buANM(1O-*e5L z&X+T*wU}&Y(M{}d5I=J8h=#DDt#Xb^V@U3Tyj-9F8fY1p+8A56|{ z!9^8#7A+ZyK2z&d-zPO#5hk(Ih($o)Swb@Nt}eUo6RuVPCTBL&_n_SUI4lFd>~pYB zOD9cxzI6_z5kNWB&f>0ZlRE2d-LxXtIn@YiR+~ILV^8%!%FB2x?qqSrb}*pO=ka)x z#1%r7x+Smi>)swKUItid>&xXYKM>I(kX_!Y24E=qlrj-vgH z@1*r^V`uN-l3J7K`V!1x&gN>RYvS_`*8q`$?UwB84AXYpxiLFY9gH3;m}bpGADj2SmUS}U0VJ}Tt$}-b?^jQR(+~X4sI*6vC5T@lx0$mdXJH+ zW-w&GQT&NJyH2Kxx8x&m{`U^#01Hja)-)1m+9Oo|w5BC^&N+Af4SZ%70h~NVQNmAI z5NaOF1%6(%7rt9=;AszVuyybe?(3$fg^ujnVnO`%SpIDX9xJ=o8mBD_BL2Prr0D7= z#W9c>ui`TIpMsn8pKbds#nV^wimVqmTeqhpPP4ZK2j}k9b|c+RY4fz;tV0b`SI>`6 zT#ru#BReYc8XMlbPsKSgxJ1lTgHM@^P)!_rgEv*Fxb>5JDfkTT)0|)k0`+**`9a-N zz^fIszyCyxCR!;%Uwsc8=ebD*7bU zFE>hlTV$8yecRd`IP5B86&HD{yTv?8vt48EW&b@+7bH;K-(??;o*hwW@7ZtGv8v!z zE2l$k*4>5gcCTqMvdBp?N923S7wY-k&eA=(`LYGB2ueM4dbOD}>#=Ww%XG4b*Ect- zCqt|4cxHbP#eGDK#x^dAzTz=rNOs0pSY#jH^8>*;P0*+g0!FTeP0oDdgy(wO8hgpw zaO~Ns?KdI|Vx^ zuhc0hUE1UuW3sb`w<-KkyLl69>P@kp6bp*Qh1%~TA)Q@s&-Am`dVVc#ZiFVWVV17l zRcw{7stAqTE4vEydzv_!f>2X`OpAZiDri_;$TwQ(f%XD5cw7wO_J1_Bo?*DKV{N>J zX0P@TEl+vw#p&Pmd<8}NPeFW;Y^@YuJ$~{L{!v{cb$3`2_w$mQQawpb^_S*GQkUG- zeY2rv6Z6hvp$Xr4AHUd1YZQU8^yaDNN4pu^;Sg|US=<|jZu(NU_x+Q!H>olwBXI}w zY8tGohJMuDYn7>|#ja62Aemip%gfk>@9c@r_!j{hu!#aL7s1%NB#Hk2j=y{MUkStLYXgvSPZYAUu-p0 zU_^$O?cUthM?9C5IG2}np~ODy^~~y_B`&eFy-_}0CubG0oQ5~gK7iSnY8$*iP=}p; zq3N0$scWW@xg8Xj5`Wex#Y$}{X`o1=P<_)hqW0cqwXM?cq`JJPC(JjJ%H@RcWm{#+ z!E;3e7L7b&&H8bzfwJ8OyUn)7GCy0@Rg1;LFOa(cT-}|v$e>|q?V3NxH-Xi6N1d;eK5sQxI<#F=o+Umg`~A*`{fNxe))*xG-P7eyK09LXP#Y`U6dMn- zJhS*=p1`EM`b2&02>+6GB2TsGP`RU8)$*oqxPH zX}7U{|Hs^4hDG&#apUk13WySefJhDuA{`1yBRK;Sf`H^mOG|gCbPfYZgCHT@LxV~= zbazQgch~>$_4mK8=f!h9Z=d_T*fZzsv-9k|_S$QI)>@spWxHo{%j1=m-Q;W1Rb8eB zMe8|RjuWLb)|AUrm5Smcri(5WqWjAZ%|Ki=;%3;aUP-7h)GbV;8u(^(m=nvmPwEibxcsOu9hA`J$ZLD2kX=hhLPx3mOg{3cVcte41L8&WA^zJCRd z)aUQszVM@iAjGQJ)K}lGPO_@pcxAic4P1);U3x{Ezszm!r=qkNDb9KarU_lKbit zG#~Z^^}*^kW?tP3nygIE%B&w|T{KsFRrDw%dAhgTjVM!z>CAA1u0N%ugpvTW*0 zQa96|(c73Gc&9LYWWp|N&CB1Z2$5Bam6!Q+<>h}J?5Dy5Mhw`>ABIGIK=>SqeE2*S zh6$EI0cV0hab!-=u;tArAOHR6>PPTL(eF2rzy1|QLtmCW?`!jmVlVoH3jW=i8!Gwf zzYhP=?hREh%S=7}og9%>H3aK?I~w`Fhydu?Nx^q|_vYxg%&*}EoVI}S0dPL#N}SSm zw#n8lZ9;rWcQ3tCgJKu}rvTM9e*_0CtO*zr(z~+===b`=-UvtfU;MW~PK`v9FlP8I z5%)h~n!-%{Hz0tPK%Q9tVcNMAmogO507xuAWC_F>eK&($!02lbs&YB`hT>Lk4-~2= zulE4HMur~0YNAZ8H0_*JB>(eYn|}yIw6ehyBnJogPOj$Qd~{`B3;%N?fi7aQWy`cG zlLbmuZ8wA#?{MFO1%UH0a+C?|q6RM~^Gb!G$oJp4`?2~6ZlP;U%5X*&TXt09WC9#w z1J}EHamG8);x-C$s4o^7Bdhh`7a15|&cF6P@ov?qEKp0#yn_wx%Qg7sW-pw1p9zEi zulw!>Ko9hNEy|AAi0WtT+XR_3pr-eu0dWR!mCDj%WaNLT$|y{xD>hPN=n*UsR3>b- z%2DukpuRJf1M6C|Y7O=(xqn0!@}N{l4eByo^8ZySS)B1qhma3FIr-sixMpQPF1P=@ z1>GlfT^(eBIH1~(lUr~=LrufP{~Mj9`tJV%YHSZNu83I5kpWT;mQOvO1@%I#nF&q?<}_a8$r4M-CS z5>yWT+R~mH1k=IXk?S4mENA414(&HDko;FgPpO5R?Kt$OtcEe;0$%D3arcF#bhk3( zC4&@-OP>ZZP|G6{d^|-u4??rB#@^PF`%P$ioJ+M?C#a zQlnv{nA5hm)U15rxVIfBu)-uRr3Q;Cs+Y=)tVrSHewyETaCv&E=q2T!1&bG0G+mjA zAg&hVZjys(QiVK>4o7sq4-ZI|`QM-)^@pAYlcGeZXSI7Ze~){N$emGQiLqy_tl-^SE^pmY!AS6ijaam)69GZjt*r5?|l#OyQA{}R2Pkba{xlAlJafm5W|d# zSu_{!tQuLIbHFcRuXXcX=)E+yF`I0WTgab`fkQ7PZh_MvT`;P9tTGwh+~uF8?1)yj z4@!MlHI~VwiKD#oTmvj$rsBG%x^~^r<2n>KCl3>*o!efP(vbElKKT5K##yu*BBr;k zybwCMVS;&y8gBSVYbWDxU zGZ$LZK~q~-#pO}lG>aT;3sR4r*`QjC(5f*1-)p_v4-**pXAVPz>2mlbyuY`sn(LE; zsqYM8M11=dH(xWIIea7S$_m8&PYna;{}83|az~h+lBc^XJWl35s?p;im>nOFETVsw zy*o_0oO$Q7Z&tl0J7FDFN&daMT_yk4wAFDB9Lrx2m$N*PWiH$D9nXvM?TaQQA~J3` z)FK@pbE(fcO8bpA3O52|H^ zbttuYJUtF1Ma#T1^=VV=(u zb23ZSVu8H6;eDb+pyZfuV)|`W%7F@Ek!*&T*mCzgq;pVS*B!S18#U@$tYS%=8)f7q z4g1AE+@&yi&=D}&ktpLHa43U>HX^SVBVtFnV%I0wrJRE{0)XU{*P9F z0@1*JW)_&2j>F&5(pJt5n(Nr0iG35HLS}-DP)O4dw|dBAuTE_wA#%8@2@+qP{rx`= z9)6sJA&=nN9AQX*ww>q38-UT#+GD;`C{NIU1n zm9C1ADbS`NE;=c&!=ER3TBnSkD?JMgi=XT0X)Bjo(Xk5iT29LO`iEyi%)rg}Ycg%6jY{+eb!B;LBn8#vpo)NM-;A{4T{@_DrcKg8=@foc!2?MaJxX8TSH z9<1<%ng74+rNxb|EAk|Zw|1UF5n-B>r1V3fp%@x)bKK5?r$cqto=pK)c>&Su2LMJ+ zgdYlzDfUnk*-19GaJ{R|hNJQ%sBG)kpaJ3;BqZKE%GqGH_+)*?XI#Eq%D+`b4r=-z zNklV4Ena(S11F3b4vkF@Q7>|!R$FKTDJY9JkG~)y9PgE0hHk$pJG`$`daEHas0VPc zyME9H|7GgG7XA!EwH`*SSJXTebpg}tWrW85;{uNT0gH3Iq$uCXk`m?%{gIlhGokv2 z;OI{Wc^hoBI8eS9GeO+b*G#5X<(ToWu$c=~*Wg(Y^+-sK_n5{J1)`R-e|8iw?3y4)zCZ^Xfa-Ner}2PY!) zkSR~(kAnVc&8_o`g8>KB0-tVy1)v}3@GMnr)(G?$t*UyG4x~Q70i4@mM5H2P@&`#; zO6xb@W>1zX)2BTC&Hx>0%R1%??)&LF@mQjlbzOC*`oXMT3dskV*5E;U89tx0G%;-%#u(D(%*>h@+pP$;0V;J$A@6U#Z&b>po?TCO z2XbmKWY$mL<%w#Hlz{OsZBj|4X4A7d@AJ`^GTPrkjR~ScBQ*KZs%S4 zE#Ir$V7sCJ0$|hm&<;a^{`$9?Ie--=|JFCrS$Sbdn)J7OD<7x)gR)5eFbNPnZ6z!4 z@%%C^F*3+-w#{xrzERl>So}W?=7bFJLje@vZ66Zf!~A><|01p>vyWgB+Om@>ACiAV zfSH&&5M=&05PcM3MCfOkGpAa%oQ!97`YaP^$AXa8Tgon{FaY}vxM=J@1!P%3MOZW^ zeFnd9GQw-Qu;S~)8`TD9HxEd4rHm$#rEA=`uUr-*U&%@Jj_Sd>bM&`nvDWp)`L@fL z#pp9sME;8}M225srJ;P{=5IBWHj(LaJpLcIK1YUX`RT-1y9QIxzGyxeeZ{JTx@Bt%aC^H}at@`YTMF`gtP}4yHvV#=m)?%X zc=AK_9)bLUc));fYdh?^hJ?U*3*oD!=O@E#o$Vxx`yVgrGXqnjWTXo4&nhZ0VxQFL z)J%&my|e3>-cVkfqU_DAN%Y0k-G=S~V{vtmhJ&;J@_-#)iRo*-f(~`PRM}!84KHV) ziFWE>W9CFkMu^2mn2CTS*hO~f7l=b`SbRqHIXBVMc6*eHa_y#a;5k_LGsosn@mi(_ z8dFmcg))K5%l?-J!#{6k0_nH!``S@xn5)dhU7iD0n{>Tet;@4d;u~2piCINEO-%B> zU2|Uf7OR6Gj;l63W!@vJ4U>$~$@FA<4ZIl6PoKwTB@%+k5wKB&oq7o(v;jYmzBPyS zZUu?{2g{#!41nYd4y%kaF$f0QYIgz4v7A);HHt-#_12IJTFrdOK9}e97H|Mx9xuXe z=Khm={Rq-2^39IsdbTA=Tv!eX^yDlvG;i`uP%)Pe^=xc~{dcMS#Y4y+pgul9jtqu`^B<83rWdD_>c>ABZ|1;Y zTJJ}TV2>$8Dwb*dMF?rIUgJY1%B};)Cx(nN0fL_hJ^zx{Nt)y&1p6#p3S0EOCpuT$ z9|b!49~QuP_4|*6_MLFrBC!YRYK}~mn!olA|tZ+O*PHOycT@*Wo>z| z!Q+YMN8v!{?S{*oFb)JjCu)W$v~}n9qr1@zm^c2=HaSol7<|xnb?xmb!3OPp6pqAK z5oY72D1$z|8z(TLoIbz2^nC0z4sqF$ukKENCHn%I5yJmH?Xs9?U4zJ7q?QDrS5{IB z$CGLzB5Q*#A<>sxCoeC4yDN+vbs_x3q#ROng+q?+b3p{~L)^61&@tFkc~|K661*AV z>~IxPj=K!&PgN9NWex=XdKY;#TYJ@|#uchd-p82Oz^uH_79Z0I&>)vbA!?}MEStns zbZm5g)Y48Pp*%OjYI<+1YYb2iNFxE0a>PVsb2*hzp5NCD5pvR}L>XJqJ#R1$no@rV z!4$Onbmz!&;A85L!Lkr4rz82xcaAFblb<7ztt}gVT_lKklN>?I{aaZU)oG zMQNme+Lmv~=3haEG9^;G8zNj7$bbagO#ZGCQ#5zox54n}9(?z5EUuOO{WyNeNkGen z)L>7ja)TGUcV$Rmy7nZ8?O>gDtCYhnA(@YmGfIm7`^$xvQHYueVzXh_fV2O8zbZ>< zyt8}O+ywa0tk79LZKC945_FgpYTDvi&8kXc75T}C7jsEnOf1nN9qTx4(WrjPNJDdx|fN|bzEOdXYP<81VkYNLJDyTDGyu1u9Obl zb(62FpwK@EPvEG}J$-yOBJZyk+U|=*odF(^T9m?T{n6^pp3i%-Z!vUU(2+6_uv9j% zK?SVXT_FI#Vadi@M?O+b`N3pdO!!V47Jx0+gqbYd>j>BjpVqCV{zhV09T8SNR>T68 z<#9QQBne5!6^LE1ZVuafS zo2Y?D+tL{-gi!%l;Hgizv{-HmFqzjXXIlCXad)9m%e`De=DN2qz%tV}50$ZquP)iT zB{7s!3&bz2+PJ*5b3U2fYfJdiZxnIU=}WE2S3kWBKP3mGyeCv4iz=#eMiEO-31S*9 zlFVN_Ed|=BVj`PCBBLiib#z+`Y)cO2_cb*f;4nSXAezYq4_{Km1O1B+iQJzQ&gyjB z-U5PSNAQ0$Bg@8;g)RS*l33ad+4K7E>zjQyua5 z@9zmY`N3B~a%$`HAQwV-QRd@=cHhPbx9++dcM%;Wsmw(JWs6^vp+_SZi!@KDJ8G+) z<6XjnI!P?UIA7<*gl(q{o(EAojy4r97PV=b*NKN2EBW270gaL_7P@(8@KmIU9!EM0 zOSE=po5zT3Vfc3)m&7|)^84Y|oPP2CSwr`DyVcUbx_2BBLnKMjH$&wP!=8@LvQ-vr zMvH8|m!*v!2l{NwBvW55;$J9`Zma7ls=yABSF+)&B`8kE5f$xX1`EMlZ(tl)BUmnY z!TNeO(oi2n5Z=PPNa*63epFpgphiVe72*4F6V6w>$PDH)eUo{ym6S_$;xM?HsRx(tl{utOFAzjN~`N3y{>(>9*aVO`>*+7 zX(t7Be=h!Mks|oP+%{Hv6y}CV0AqViu_=WRuFu{dc869hGnqMZ{$c|45yF3qN~uk{ z{p2A-nZ1TpiaeCw(Q%#TN0n=lCW!bpEa+5zM7|+x6h)r}+c0sDH5oWW)hfp`J*sHb z72qEAZawt4RePA1R^~gh*3#|IM?=ZX8s{9sx@H9rv-ZZf#2s6%o9p^! zy)~h$6A%=t-<>jjZ6WI)9CEGdS)h*u<_h$)CuV{PX7xdjDJ-E>80KB9{88{4g{ z$7@jD+E0cOg{upqrcLamH?d|<)S|NQr6^^0P{E$YI_aBnHgI?GntN_3KB)f z9&c9(+SA-`DZOL+Ir}L+m=vFl{N_fBD@3J)ILqnlNU0;DVlo3_Zxm>FIX`Z2QBWV{ zXcQocDhXZ~y?16QTA%!@x<<^}mu8==&a~Sj zYKwVNZ3md!`^-3N+T=z#vD|ncH-mNO@wHCnjB&bbINJ^*xb=Q%-(st{f%+?<3_`hS z?;jE;+vIDi zS#iNb2%_}1*t#pG^pYSF#z8t1<9n}xy+1)wCNsP(%gDn&m*MVR9_TaeP0dmHZY#Ja==gutl9QnOr47(_rJZLci{y6D8WVbOD}9QS?WQx zllnJ8mbf#0mymcHCCQtbMP_T%0~R!pD3A(16fM69rk)QfEx?Suq3j}QmqZn<+Qz@6 zr#o|^l{yI`hT=`oJ2Xu2+6(EO^@i^aWq(=tw!pNdPbGVob&R&Qm1DT4?Ls+9*Pg{C z%8SN*4h!V-EV^bYAHj|>djkD5boG;AJBZSctx8%WA^C4U2P!>Yq|9sUo8a$^f6`58#=?Ak z$USn+c)ZnWS}C{~xj}bk?LG!n6oUG00>$uQ4fhts{$+x{*S0E~xUo*&T6455h@$aP z%qCf+7cdG^t@Qn?F83n7nXxRlCN>9?j-N|Jj>--r69gPycC0sx^0_SiJ_>k{`uY&) z(g#qVthNSd2U_emx_P7E#o>XvH=m^FU9e5moKq(M^?0K~d(oEt`-|EhyLw@-MB9iX z>Gc#mu;9%Hbn<;JyJW}eoyjPt@p{D4UO!Pwze>6TY0M+orGfUDO}NOJI`!;lkRM<& zLM~t;t@YQIh;fHV*4Duj8>{5Z9`477?oWPCt}UEG1D;zv-mO?N3s{l}@sBYlp|V01 zO(+X1s;$Xhx?e?5W17MQ+b0OnZaY|ggiy1?uAtKZne)S%Z1*j?@a>)S1k*0P>7>>8 zp5=pUjqx#Y-6)6Dw(4@}Z~bF9@ZPuq+R$GuxuL8oB-?|bhDAQ)-|(S{#J5$W?}6IC z25GZ5o?EqMLS<;g60%=(da5{;s_)rQX8gKx&l|_5 zT$kSAOJHNPlKbNEGoJ!p?ULX|Y$gxdSEq;?Q6}a0mu?R{(Qf;vg}NED{_bwlc5+O` z^LuLlmQ+ z{)@f%k6Dd?cUI~uw)EH$2w-jTdKQLqi3x^R>q&cCeA@X{eTgP5xpKV`b^!(3@43+5 zgd=wWxT6rT~MRta(!MrzwDQ~^OF~n#_{>NRDVLc#pR3r+83Tlsa?V@ zc$_W4^$zKD){Itq&X=`S&C@08&B=Z55T%dL<^Ak@m1!@~J7W}Wf7_!>zf0>L+d5tG zd5&bqb=a+9_w5;fDD6Px{1Nh%){j2GeW%{6Vma8CYdeJK%1$&?=5?_xK%=z~1scwA zM?U2vRHn}8uDp}#l7&YGi3>s$FkH~(Zu1e_4hw55Yo-&QXHl3wxJ*OKjL-6`-1D>K8o(-Es z7thz2T@tNVW3msdu$X$p5Mv1QTIMCOS*st-CZ{W)vp-!UQ&^x=bH0$FV1sDtBG&=d z%pr91jNZoL({p%!Dub+Wzx#9h?44IhoMuowj{q2s3s=KnPV2r}#TE^ftQum53&U-lG zxtd5s{^mjV`GE`eZiEUTqbIpB)Pob1EtkmjeL|gRwS@?@jMndLDI9$I>!-?o#hUNJ zq~-1zpD#nE(R{Q1=lu3O{r{S4UOv&RerBKVwnC@g`Vuz9%8R=ST4IsL2y2>RUAQLj z{0HHh;Lk&;h_j95$Sxd5n!sKgQA9Nu*f|5+mjPS!bno`Ui6T92PaX%Rz#TaW=O^8S91C^BJ1R5{mTv!y?L>&$CAc5TxAfI;4x zV@lbW8ckbLFR-p7RnDKX?ImsywYF+}UYT<3(OVW5kZdf%IhEvgc=RiI!!EbSOsBJs zt|mn_wmdri5tq3FSMD?^P{@xtQrOB$({l3efxf}&*5B3TP&_B~@sWT}?kSfpyUa_n zeIo7fpv8)e&7SA6rWZCYsuu#iB%}Ezs-7InOJT(b<>@{0`XC@HKT0#SH2s)#3iW1( z6P^%jseZOh-^Z+U0aX7G>G!Uc=aYYVrpaLF zg?ruu2bl8)>-xp#X|UTa_))?F=4|)tQ##;suR{nwLR(6_f|xlKPyMMIQ6{&3*6lJ; z(=09#Nab!b{V1qdH?l8hH>vtJYmltuqtyuS=@)j?$DNyf8IL?shhK^w!HifHDOKpX zRx1++yR_9heTd%VW}XA(fP6TUq0emFIME%35_L{rZS2|x9RwAzONC7Lskf~4Z^~Td z#cUT;A4v|7u$KF~;orLlyS!w#ZL;Zz29{s5qfzTU zqp}>S>Q9p7EcvLqGqeiDC)Qya0&4-oq*iQTpmj`dAJae(=FkB;ti^$`*DUb5TR?M>cQ0mAv}v~AyE*WB-d#2^GPeo^AWE0GlPs*q$?H-7UjtS{dV-Z327Gw zkhLLoy(<>rQ-6+~$&qyEN{)S~4tPk#EF^tu6(b+6KFn2!$;!7*(yXO~l~bwBBs`uR z=`KF3-VjrB8Y!A?^k6~0DR`aJcJ3KPwjgzuUv`T|kL+cNAIT_9G9~Zi#csWaDJ24* zB)*ACiUoCU?kh21h_Whqhy>wBDEs)Li>vrqs0t27%(VL`vPRPMtwb*>Drc+aT!H=; zP{huf3hXQuY4sCgYEL@FHQXwffgXGb74oQ|r@~hy$Uvuda zS2F@qHX4RwTI-TNY)mEnhy@EWrDOStM)9=*Lv0}rrs(PUjHE+zQ(j#IeWx8E)E-dV zcnyrr>QK%XXzEw|1&`Y+*|%F_i+sFJ=I(!(3p^Q;R4e+dB*x8*QB%;FAytM9D;1hm zrr$+fwksC8AHNvWtr}tj#PDuqADTAD?$SMWd~PheSSg;Q`K46=noKJG0DARgyztAn zAbaug+J>I6-mc-~B?|_E_z_CW3jX7$DD}}EC!+~A0Gac{HEgJ=t`0w05`5lZ!qYf9 zRnz7U3qJ0$|q4!-A;>OUh!j=;*9K<-ZgjmlRT4Xyl-qhYdUvO zwzM#*zvZu2j+s28Vzf+>D#dsJ$*!1@dlvl)5o3WSec7^y4!6Tb?*7l3J3HAa+)=zt zy~U4Px&{Z94kS?m=+KI6qx@jc!^p1DKF4n-h8E#(cZwm2_o&qapaM#3#x=nqRx}@W z?ANn%J-uu%Ze2s1qh_+_~( zG2lB^Bwl2yI{R7XGx)%HMsLO40I+u6abDbmgK^vSj#X^#zCY!j@*%XkVD{U1w(liC z2!2F=%)+m>J$OBBYLyV>a=`B6V(EOP!;qJBhVn5`cF=BLYyS+PueV!ph-XH&S{N_R zeUVhVZu;MgX;q>@m=7tF;Zxscmm(bXBKEUHqAH z_FlUv%Q#!-3%6LDnY!TLkUfIX6_iYsZ8VOYK=d{|R*REgi>uA4G;ksSx_-(_y6n`6 z817?5iqa_OUMFjvyNg{Kv_7R9dMWC3{_eE&298f}P$o9i{2VxQ!zx$aYCZ-xPtji2 zOfMqGp?4HUOBeFJ9H$DhvEycr_S+K%)(GATLD{q10Z` zkK!RXAC{Z`V^UerJbRhU6D*`0Nb(8RX>%5-Juc{)IT}|I_>}?sNr*+AzQh6n_kayZ z;J!KR1vaR8X~_or_Ccf~G!zQD;g32c#qvY2M1vBF?||?q;0+ppQB@E_^#4MJD3G5F z_P_8)(p42`Ua(?}!cP<%1Saq!&;{*M@>vi{0_HD6aDM<+V@|0PEAM;-x>W%Rx(5Y) zmBIc&)Jy@|Wt)8l0?bm+^E|P^dWr=30f_;g;xeF+5zDO?Fi^28wPOAM5%Dw{RJ!1b z4KnDOy6Z=!3;OWL5e@1d$M$PUsJO!bq?g3r#W{-u`Dq*}U? zKg5Oy*}bVe)V)2ll!6|NHCg%We$Zw^{Sg-gSW^c=pa9^{zYxgwUwJ_Iuay6V|6cw7 zk>E564^^js|48DrXnUVIwum?cQzME z@$vUecYCjEdzU{fk@{6ICT|pEv%LWNQU*ooFq}D(qj(KE27N2`vQEw@aNrHpdXv1P zH|@#{q`+CR76VoFMYp-#b2ey0jiRrOUSOraeZ)Q+)RzZ&xH=?soqWBxeGh*ACpJv^ zvm{^v|1wh2Ve$^@4d>Z3k1ILYK({(#NH{(4$k*pJB(Z9WQixv|b2E#W3%tS-_4&|x z4+?Zg3p@0H3)#B;hK_&wf#Wm!^vDNWwqf@ON;qlE^djuk$#aW3uJvfwnMcNKhAL}A z2LpsPVfzioHY*p)xup`#-5$+jmj*0C2JMqA-JAV%C06>LXMxP$Y}ui&n@zWem5*!~ zFoL1BA}Nb=HI|2^sdwuoDAICz4qt%iL`~T5pq$d*=QOzW3$&W_>gMFALq0m)wN}qP zIM-gBvb70TufMdT?=>a;PG81SE#j^aZzt3|%ND&PzVilFx6C&h-?Ypf+8;f5vx~nn}OqmvPYQ(mb|5&50o#@%F zEkgPqoxRaf;i^`zCx+_VHs?M6yBy^1`jvZ5e_85{zDw63G~~7@tdC1PL0O#=CwT=L zV@kC}t?5aS;XNVSb>2MW?xeE&$6-qe(0kj#kiqB9+Y@cMtz|dPYef%~9M3ZBl8zhi zVY0^rsucTF#!ic8?5SZ~TVg~rLPT+0`f~#Ezl$p!F6A0U%LT1Byi%kGA8b5CA$9gs zbR4GfP~*1tskJUP%?W(7`7W@wpDqtxcvG*}FmDB%cAR&`FCu(VyG0#XyTSJ`0zp|; z`!U66Q8hi^P}jI@m*(j%4Vas=MXZLGvGiOalaKOR!wb&W#(L>ODtq4=I!D(o4Fa+d z^t^rP2AZFeH^!A=lqII*mEtGq(uv>1mWZ6HH9^NQ%x_>WNk;-mGW~>onigiH(=xiB zqhm*R(m^=A&SFB%Nd6w9P^-^016s(oO$ZU z4X`ZsOy}pBoK{WgHXf9t08boIQX}2i7_mq@%aS2eYfK0)$R#Lmytc6mndPX(TFqp)XYnUz>pq0x4Si&g8A4bI)Uo8->*ulmK4c8t8+7m*zG6Gzq%Ik8( za(>x%Jpra(7anSe0+u5#mNJv(x$G%dult}z^h^Wtk=eb{0u!|7r7yG@=$@BxnJhU4`d(h{c|Kgwic;fiYu} ze6jDIrOiP&FJv|#R8nUfBEVy6R?D&bFeoS zi6#DMqAe%nQ+gM54%qWu33+2zZT#z&Y%kSp?xB&OSBU?5-kzx0;@B4wr0?O2hU1aCd4^HD~*`Y^A zNl!W?%V&gFHtt~_!;W3!%aZCV{XbyBTO2EE>F|Cqu|W@dVp?tDI*xchIJ%i$xM@Ou zep~V;b~C6m%04Y~-V98>2X}QFP>DeRBo+Zoj^b5h3VJ{aQhnm}e)+n8w6A=A$e114 z)zz6$dM)Uf+cs}yJZd-{`uAJ9P%m-&9V8z15h)AOcBQk|V?+|TR6)SK2iUJm& zQ>L>QCtv%b+@vd-{4xwg-Yo~&PHVu3Q=7;#m79ZXuPRMBVegS4Kh)+=U_5teaix&+#J^~rhsh# zv3IV|>3hY$7qeS{(`Rn(u;e5US8*kEk3?8%Z$gt8LOD*gNqi-SQw-hgC2-=s%|Yxw zFn4LIc3s=$Tv$8vq4mltpE$!cRDy@5I zjc4)H)p%$1+`Lxx=b@&v1zw-?5rZ0GQm%+=nV-7AqQ{OyuuI@;g)M^g{hN62Bakh? zn%<7B|3csoz;OPb5a2xj@t;5lyaC+)FZ@3VfMow)6YQqjI{?gdiOb;PEeH93*vEH! zGfLQ?rb$;ZqFWXd7wmT~Ij4aqv+6p5YrlCLLx9tp0w-peK~+4|F1#9bZ06!)C(irubxQig z5h9_jW8u%Aj2GLeBgz@Uo8%P@;rhnDu5H%xvkTp(s639@ht3DGrq*GA@^C{82l2&t z)C}a*C*Cfy!?@`&wB&8IhCs(U=-`fPd! zzgkL6HR_0O#bUOy^qLHA0_BeO^OARls&2ukvkVq}2f9u|uzdnQ|vp(uBt@Qzx!xkpEZG=t)>PURQlcF&eyoXMR0$=C^9d1fD{dA3Gy zV$MAw*B3_s2RCudnG0U%yM->EW*@C(*PT9XXPgKMW+kqF9-*?Mb}NZW4{fn5`Y^43 z)Dqn=c|y%T`pKle$6$X`+wmddBx*XHX#c|a()xRh&zfd%00~VDOZu$4qYm;&HB0dI z_!ihBGp#6X9S^GY1S%^SV3Eccx^GXbEll(wvyd&wP{|E(wC0>#@*WC#t0o~13<#FY zex^S>-ZvM|sX|VMhTjsSw!+MyF-p+a)0NUO9nWhOchncf?PM%B^v2HLu(RQZ?nxMO66IB_2Y=g-AXoVSXsv||k8mnLn z7+!Dn#~luD>B*1dH@Tj7FhW;cSGtH`l;1*oNT~O8kgbc1MXE;9>D`~Rru@^1>?{RB z2*_flRRB0*gZ9Jfsfj73?IE|Nle5QJ>PVR$rnFf#=xaWzch7@%>)obh)b6kGSVcZ< zq=0TwYE;BOHD^MZScg^-z~5%^Z$%m^-tfUPdv-$>I3)JlboiQvqpq*6wpbkK&j8mY zK3N~?=3Err$SJJ0xC$ljz1(*@-(BOpfUO(K!#r!!H-i$T1?c+IRbcsX40*lm4Bk%# z7F#;7Fz0kAo~?w2qxW9<+DNDR>55{<^|KuT&+mfhD&L;RwT)M+L$!@3csQWT7hBiS z{Y-|nqkk3}jKR=>t&)!ATJ*K|v8;gfM;a-`VQ=O)ivu@aQfj>}ZrwZ6De2Q}NiM%k zB+HwEmTc{>3zJ6}2tG$wPANO+i3+a?j2=`?`~G^omekru2HLHF%If>ukWOT*Mb9kb zDSgV)Uj=ljk6Pf(0BTXyGnG?VQ~_-rCKOaBIIBOg-s+K_YZ=W<7kh(Qfc6g*ev zuPR;+;`I+``!t9A9a@2P#o~fa;do}++GDv_n@YIWqrLAu1*bT`GbSY*sP}IZ<5ICr z#<>5mwO3pOc4el=9`W5Xd#To^Lf|#}c8J?RV#{t6u~5oIJfweV{t|rTxv$7_c15?{ z@%aXkz9|9F*!f&ceHUb#NGTdBsP)<8;(76^hsUx2s@y7g{dohdPWoca_T+4NV56nS z5?OhIa4+hqq%PLWYpky`a!ozfFQH``B2uihEbZv)*gMA97pfR0ls-ZC zqz{xlmSWcVFn~_sDP;1F?$(b?vBuFE!cSRnlrJ|0#xV>%Xgi`#$v886Ji+sgHf$-? zxUpvNH~S|O0fyT0An0MI(w7y(kK=A$q4Uzl? zdJu+2q$2&H7S|K5F{o1`q|DVAO&^!3+t@oO2*lDTfWq} z;qU!yk`O4`XeQ$@DO0{-PObg+SKO<RP$PbMzgvu&wChVCOvCu*s5Dc$KSKGna&8p1*gCuz+{M_piUKMxr4R1~Xz`NTS$ zT})1B?8l|F{VwtCRjEC?X=wb*7={LL8qmCrbJv5LIV)-yKmwnlw8CM^s|!Uzq%9e= zel)oQW%wvBG`We3xra~NVdb1-d7^-_Ylr<~F2FCabu!==_^CCIFkaYt9%%4|5#^V> z#rVscIoCBUmu4zH$3AFMm3a44C*whxzd<9Oc8e~&|PAS;ExY7W(ER>6gdTnmr?5*EBN{<%8vyT{a6<%J;? ze~Vu(i2l5#BoIdq>k|Y1IqI?hBdtz`v#N6j(5E17gWZ^M~zhO^i^x>N-@ zvJWwwBfp!kUXuSYQpdk72h2tdaVvU&TA^i_XlnA8$<)q3=!MDLdDp+Jo`e0HwTFgn z#h(H8N%OZtp25<6a4Msft?=7P)hSlR8ZkO#?1jCw)C99s$eFwu&0UN>>m6(aYl|jf zm`OAnh``As8uxOcNYnfVoalb`j4)azXu3%*95tka15;Xgc%9G6S3M^f z1fu*D((Ld=AT=#^Q^YWX3Gix9Uh(H~?RaV8H_g*Pj!QMq=hff8ljUg8`*W!p-F;so zOn&Hi$4t)k+iL8CvMX$NzbV`=l^|63ajsECUS{r7?-^(3n2q7FkxJJaG5PQjuNIZf z8!w9d;`4dGd~p^%UI@?&!mn2scxG~qKSQyg5YHNNosDHHbS1Mjntjx&va@9B@Jw%|rR2;0vh7EtCC9NtnrZV#l?(AU7Rw@Uhpm#F0uS2yHT`T0!m zqe95Zx7_D$mGLvSuGbfD_I$jn z%6I#|9Xh>Z=I-<-8Lws>S0(ryBKY;d8`=T73WZ@`q zm=z*37yRAc;R2^#i6&9G%fsKb#n2ZAc3-(WVnjuqg-MvL#B|wbYMvy;e<~N9tQ^ox zChA}=iDCp1LTv=FMtygil(^{*(2xVWES#(3vUx1oQvNEzLaAXJIvQ9*R|@5XkIsFl z_dB~GE4IEk{{<|hQ*AoV?eJ-5BerbQJhP(ttmAuQLIb83x9-M|V63&6vr_bz35%lU zwiW}SepcHsGO%_f1w|;Ph5mi2h)p_cxCW6i_w^u#$vi};kG6PmZ|?>Rb+LXBNM8Z#5#IkwW3%}NwBj3zJDbi>-H&S!`Tb^>i z{!2F^5EOaHD&tm?aABtox0Qc~0>OT9mkS7)ekn?V-q5AQXfvDDPj6}1e$@H}6K{D$ zM+G=L@9O&M5`YU`WQg+ff5xK2ls`lTrbT|Q_<(Hrp0qa{^E3|OFABML&cKQVeB`Vh zVkuZj;}>=`>Vg$nsPyApkuh}1nHQP0a_l?&=9xcJh;9&}jv4XGD3q99qR3+>O34Mz zUdsbsXvoTzuX$8fxg8b^oBW=jLfrAmlEQs{u3DKhy*mLxPCgR#;fjgO5zQrBbnZUg zN8GvP))B+sY-2S@K_T)!cf4mv7$cU(8cb>L4lAA=8*6DWzqE;Rs~Bp4N$YCX~g1 zS9s<0715$BGP|9F$6z*5yxrE2-wpMJ-JSD2sU41}>g=!CzWfkqi$GhOJJ-2}f?j+-4k&*uLR}x3! zDmVE>S%UTBoObGA?>3GiX7H7M!oybnAM)PvA*${R8y*2ARAQtg2RMMVAQI9&Ln;E& zNH<7JgX93h%#e~&A|N0wtw;^2bO=aGch`HkfA8}*JRhF(3C@|bW39c{-e<3CU8}BA zZeEt^j$ITv6Dqyl#od`Rb#gneU^;FK^#&hsxbuzxE`Hx4G3^r(4#3VQVV10%YDpw} zDlL%441t1Xtjk7=f3rgMJb($+7B%X^gwN}a4<5k%&7j9r`en<=RN(B_S2am*DjZx1@TALma_XsYwNtSjDFmX02&>zMfsyldN1y*+QvK7BL~f?N^vU~ zAHqjlWyfN0^RZJuDy3!p^FkQPAHtQT8hi@Wk$kklI1&Gud!-PRsJ%vA%8`Qf-b;z^ zo7b=+`gd0Xs6Rz=IpZ=@EJPHs%_dC2Z7K9qJVSiE-E4JE51Pt4gnv87zN+-P>Y6^x zslnVcF>Ufp7c_ZadXF4}Z5Gu|5D=PRXAzuilk5nzf&FH^e^)WM?bCV}E+P9JB6bAnUF9A%yNy(wN9cUf_3XU(CXUgIh3b z$!+LuChLc!%(I4mf%#rd)z>bbO^Y=Jm0R263Veqco-#+aUV37LYXCiE%Oga0!=IsJ z3G)rXU-d{{^spQ*vQVB68iKiv34fhtd3PSQA(8plRR4)%wiXv7TxZD*px8$2N+&Y4{6toWcQ}qSqmecort@9hOzMQ)TPM$uJ~RJikE@O1b@K5`^N%Tpg5tz8~54HEmkfzL=G z4-H>5Mp{>Mzj818O1%)fFb_m^W-^wE3BPVM20fd1kYA>9(C=^%mHd=Sby9%Y!pg2H zE+W}f5tPP1F&wNC9n^c>+qlJQ(BT&RW5X?s)~SqC_@b=z+`p#{(3saNm1=QIy{OEi zZ#1yoHw$(%bgPQ`?9KOkY16AtxRclokFn4I)4GwV{*F)7{R*@HGdwwF=DJNOs^y}%j6dDD+3q7 z8QJCq?-vZ8w)f4a-pykfRZ}lD4}Ee6o)YntQhu>NdgM1(4?%>*59F5uWa+19n9}Ry zJaSxt;%e7d?u@cbBqTtw=BUGO7SFd-@a0Ez`qk~Y7|{f48b^jU6e{wB9QAnE_NMmb zhE^qdXg(E$@PJwx6UT%M1_UJ>R%&xqrhQ|47(L7b=aSXV*9ER76AY8dxvJm51u%Lw zDXHXMWO0AYqFo)We@eJ$Mmg`a8Cy+gdIuns7w@6mi6xHk9{IuaCc@m>CG%oq1yvzP zq6(!bo*TmeCwRdZ%T&GwP74VLs_KLC<|cknUiFf4=?tcX6z(HOTe2<}+_s8gV-*1q z5@6_Z#N+IwdGwjpSjPc6?vVCVK4-G(^)@3NT<{*<3@)@z@4f;D%cR&7co9twZC(ZU zdHuTGX+Q40#CP_B{=*d_r`LXOIvzArf-Bj9fBWq+t;t!FyFnJ06{EB!D9gL`&M?~t zTJuzjsu0BbEyTK}6aB2u;NvCKSNi;vg?qr*6YUl?_N#Wpepci#J_VjBXZ77cMV?y< zz}wJ_t{B~at4DMXrHTW-lZk1!|G0%Lf(bO^0tFLdoY0U(9Cu|N8iAZUV!y4Avc9w2 z80<2c$Ssz{$q^pb80HtWzFKw>TljEjagA9-UcU6;Cpt_WD$@RB@lX=;(bE^NhJ_!P zO5#+$8fj%hh%5e{8?Di7@J%XPt}p6>`w>1vK6>-cpBCn_3!{PSYRLdw%p8kV5co!N z(Oy#UG#;jc(kGD$>^a`VRP5iOk}x&tR?1sQkS`EMSPKsJa|pBrI_KPmqFcGwGG(Ar z)9A{UOpmQt5{bx|5MocSvZ>%6>Z5^W6C)R6`%vFf4g-2yh27wxKW>3=rgP~Eb`N4V zX-$gsq1?z3X4r4hcQ44uz~z~FEff_U#SXKjyoDXWLp6p@1-v%QE+YZvT0hSsnV25d zj+cD&H1d@%>sm9)_td{vo^Kl%!bnPt48*Z-`r9c20Q3o|>rtFy>|Ru&v~sPH4)?HU zGGtq?gF~(Qx*3140fc>My=4l!FxbOM&5CUlCI^n}M~%M#)Z4 zOuQUiYj{yGIt8-h9~N3SPf9Y1qGmF!6a{#hUi?OxjA1m5Zaa6dr9z^OEL)fL-=zsL zIgRXBLoqI|!CVZa_EWv`xEng(E5eyP#H+4{c@64_FX2qtLu9KVC<~pxdHbPJdlT+Q z(H=4p-pBh%?{$)S@?&g{K>~=^XAX)HGQc4l6MB!omC6@d~%>)J^!V$?oS^@Z5Yx+JTLR>Av-64*rDzv1F51O$v4Gn%qU*x{2HW z-ies1e8fB#tG6jQuphWdKq%U^xUtY9V9xb+!cZ2|HvICCVSq^afmUaT6aYBriT9V5 zm6dVI4z@frrz`i$jMU*G8qcJ># zJD(%?e0|#Wct(e99RkJ<4Rrc;(#neV2F^?q)topy9jPn#W+3T0{<9_~;_4+#{^CP^UPF_W!g1A_;Yz{9 zt~oE?qlvfpyKW#(_4VJu%m&z_9Lp@8T9AT)2`Iw-Woyu(!|frD>dRsD7W@e0k$XZF zmZRri93nSGxLh)cgafx_TVoAOGjI2) z3`cyU%Au33aAqfT8e$x7Jo$YA_hdCF+UOD{P;VrU?U%MX^n_1t- zyg!&4@wsHzK^?GbaD-a4&~3HJrYOA}_p@LA^HVRQbh$D&LeJ*^U;$d1i zQP_ADDqmndU2_}Gy$Q>U%Hj78N?85n6-_J)eZ+*6qJuFBs=YK4*Vq#rnazNY6TtxA z<(;=YVpE^E9axjs)fAMB%X*vYRxG|5f%1Wz>_(CjlDx~=s_)WfS)+h%vMhB#5TtsE ztG48NyVP9hk1sx<3SUR?dyerix6+WpWuEXzCoz(@U3t~2E9&6 zExLiN#KOOdps|I#W+YFpadhJ+&U0H-{GC~cdQMM-P>;=ll1zmHMOqmM?t;R*6zE&f z7pFhiJ=kkvwL&_3V}PMxGVnwt4))vRUZ}+ws+z57#(}qnzoJ~=N2p^BC1p-A&yD(y z-oYp94=^(OL5r2Y^%i$7=u|@&1?69S+C>koiDDGG!I0S~kx!P-w3^vPS00W~0GwVu zuT+_#O@>9X@`aII6Hqse#TRPJV5*)Vzt25o4Z|UhAHMZ(k(i{f5$#pq)bW+&?Np7? zZ>hZ7-eWErW?ByJi=?Xe+cPo$teJrRf;I<%>`+n)*o&uId9j*f>dYzg!*^xBap%QV zUp;?HDk&X`hgUyFY0&k(!YQ=w9lL`8zgWYo)vb(&BsU2GKrkw4MorrTA2Ot|fx7~{ zV?fw?jEB);qp`R#q@lKGlaQ5-6VuJq+GH%kBdh8)@P}PnEx^rR2z`*Ka<=4gE$gy! ze2-8{0J@9TjCX9Pj}3DFPc$1b^MjfB=r#{$z-XBQ!^FoWDO!N}@|r}h%Bu42x=n}c zp?ig5?fCr9bomwZe${UJ6SSy-VKHf(mEgDi^JUT*+LnHuHK{FPAM2jn3N=xKUm$!K zWKkgTdBG2bx|sqV&Ahw5)$r8H4{=H`+hwj zAk-xdpGy*I-pF>+bC9D8^Zo*ezoursDa{k%O}PM%x|)-Gpg730-l+uAt39h|SK464 z=&AWyA|mOP$c)Or{fkX3DrEg5se0g1&pevb}z)| zLF%h@la`m%_p)%jQCJ@j-F;*J%#j&_UXk-H&i$$@VBo9iT8ky&}Fummvb911VJi*;Nl+{=C|C%iW(3;kvHW&LYCv9O$3 z@*|IdH4=DeWEi)^9k>7UY5;lSV@z~c&RSxqS4jL9A}nqgwW9d9nN9)(X+eips@eFn z((plyd}-NyXPA5pGm`Q=@xPQ}V7<-s0~2)=PoQqQfQH!uFcO7IXTaderU}yTe^*xS z+j7Y~wC`@66#C?Tp}XAIsr<`!K(op4?#*ZgFOwK<;9){2xn)OjHJInK{O5y_Y_HG% zU=fMIcQ2JI{v4ZjnXb$J@3-Gy#bYOrjxhI`@H9_C7s;>N`C=}XF8}usVggwbfV(=a zJ77kuNHH&w(V$=Z<~ng_ElfaHt*9g}hZ(>>vi$FEkW?Npq3{rSvGT~S1*8)A-xU~( zF>znhnd%E&bQhcdFJ=dPaP??Fu-M{b8zp9_B=r6N|I;gI72?S$TVtYS%=-WO0&eoB zH`$7AL#P#sGEmep3b!9TZTfqewM!7iET!!GW$1S`2EoHA=*V~0H5E>p>9c2^3|f)uoW@wNE}$$)k^ zVp$B_?q0-v%VaTq~akHhzV@#{@7q>VkDLylZzYLDEvfhYO3xTC>Nm;$`Nyo9M zo7`Xv>+4+A)oNH<@tGpyLcD$Zl185k+IXPmgd>(CZPuqX{q#%E8_f!U`+-0#QzA;3)d?ZB|d zA;1~|=9^DnBJ7k~V)IbXv*sWjrlnNZ7PtRM& z85k+scf9=?H>jCD)P&^G!0MoYrG6-aRH5@l+~FLB0?L7yYKl&u^*vv|*hC3Nq`ziq z0SO{|g0h6S*3kws0`88!FNh>G!m4d`ethIGD7b~Wf^irNd|-%Ew9EhsV4@{(Z0mwe zpQsModQtg&&MQwVP&MB*&WEO0ghMP|qyIdG_df)sy}rl&;_{@P9*CGrUfbB6+g(+& zL_1t~g8GPyDm~~@KrQNl#Pz2EsH!nk+lu!a{`mfK2B=g~6IQB007yk^PnFm}SCj|}s-^ZzW?=v^%@|A?7U0bzl95QDr~OYig`LcDRi;-N59IIZ zG&SsW=mAi<_Q7wb{zzzZMG`M<7Ee1`QCO>Pj_V%+LF9(Cl2(ozth5tnx@cuz))3s}ml+GoA;MUt-=dRZxP z!6l26**4Fw0(4*PIVk?za;<)QQeKK14tCu-nI!$|2r*`2<2kVVOB*;Q#4JM)-ml%R z4yqpePsT*&@xh~>(DAj{-$&<_oElwB`d^QvBs?2c!pl5lg@uQRMZeT@rM5Y-A?{1}E5 z>qJ|R?(&m?wF+#-7g3(#nd49YCO7NE&>TfQ$TDlO4AcG3BW&Be{zBUO3*Pz_J^8d* zN#Z>n1~&))3dI4!xzo&u;WQ&JHNzIF%8|~dv7RzqL@Vs~74`w=nenld`*-Gp0sk@g zrg`FpT)|zbJk~O;2gqlsTf3Qaj(f#bnL$D80WFMII=@To*oyA*8&q;Sj14(5mby;8 zXvlf~Y*%%*@HykDlPG-N0rj&viEEcwxWazTv3Ga-vcP^z@7Io*NxGBCi~KHQ_9W+7@ZpSFE{ z`_xKeuJ0~*9{X^5hGe`VIYF+KnJo8FY2ZWn7k*8E{%*%xE511YuWY7azJqSbjSt4H zyy>gon739YzWM&FBD#?8edSG7v@s< zu-~COwKRK~LK^09IZX%UgkY<_HQ(ZkpY)~;CqFcY&Fu}9T0}=!*M#3TKlS9l-K@4M z{bATR^Rjqr$uQ3iz9_Cz^AERqp_t^;<*HkP)1v`}S5kpZIR#;9eI4OW_Km?epoqdg zI#kRZ+EEdJ$Z`6rQ+lsocHi6a0=^Ss^v-YBCa~hcfn3m{2QeZsFXUR!t<8>C^PaRo ziMo1Hd52Bw0aGe0lKL(lQw~rQE&nhDIo&=W_b?Yp6XOjf+o*ccbmVbo$ zrN4d|x#-@*?jJOnMJe&KcXRmf{d+g=J7k$fm9u*QK?7?&OVlyZwd^miTNrAHl`EW0 zpugCwqp88MNY3mw?8D2lpH=P?v(d)XmCWk{x#^%k{@Eq&U0LZa_*Bx6t7Fa#}OP4AU5uP;dti{N@$yw$t|ENBkLl1ysG*AgmJZAwX*$A{cFRJq)KqA{!)RJ z-@$e&Mow{~{*jhU8I+JQ{WE}IMR=nYQSdy{u_kRBw@0u!B4Gd}espg05eM3GEPX-s zlK~HYq4QoCq4iEqfG0ka-qWtEPD}DbZa;R@AH(sRo2{^ELNT^R*ag3(lszmhZ8 z1WiO6lc@xUtx#z@@4|P7?)tdUyAtRjGfhuWIv-!93c*u4$(WXd<-=|KBU`dYh-FN0 zFokqbB)P`?x7oNTu`N^TL_AD`LZr-xM{#pZ?_(QifP)#|%46g0)5qMdf-Q#{buE&^ zqnGCR5sN*OpDoZ|sFkk2$?BV1BCQmCE(B=Mev9=PbF6q!1aX~PvcL+uhxx}QCmwVp zT)vO8xU#%5CsRf^R5P*q0J}TE$@Ezolo13|7ju-V;w9gR1NEP{g_4#cgm+k08G=fi z%dQ}a7-vG03cTq*BzaYP%ra>p(*HcE+sz<9r0E{Ykf;622t4zjjOGuf53ii4etBn{ zs6pXx|MW&CFL7*B4O62hwp3-fP@1(gF7&Azq4jj6L_o1D@n)K$M-WYZ=0E-KdP4_! zDoDS75yM^oIXJ5%S8!@FMOvL@yF(o|ogx5KWTXxR_$7>wS<9C*du;B1_(O{vUMfaN z#c8r=<0*_gX5qXPO~;e}=l!e;nCY5Bp@Fj@PG&oSXdG0YmynceZFqC&3I8g}!bQkq zwc#VLd|JP7_|lwUdfLmvDXb@d%L|xkjER*`=@XrA`-4P*Fl881){SU-v?VfrU$v3R zu?$=b9nG5{3%?vu1mT_WOAByXYqyI6UIq`NVG;NkE`Du>-_kO!6I{M|V*L7d?gF?I z-z4k*mGbk<<@KejclZEUwpjXLvzUW8x$&gIWvBWJFCn9ev z@`FI8$X1+l7A*5Egb?(?36cP{?*Vk5nkcXrXpuz=*3Bv{uz(VI)rL#x0yoU`ZC7hI z4#>CaO1#&cmiw($Uo1_I`JsgxZb!Jg0aOIwYr9;VOm}FOs6966bzjyV8A}pfd zpc)2nG36v=i>P~5pGOJcvSxpOjsQujw+KnTcIZrKe%RJICP$@Me@FOwsP}B@@Wmjw zN41C%Y5n=jeV(=+HkIrGHq5^(zj3$;`{z&e8qB(7;2_p3Myu1HqdJS6urKfM{ zrIi(efMJhhxP64&UbxnTNru&CO+G#5tLXVZ;ppy)B+EdL?`d<7HE}1B+L$S@)T?ru zsKQa!>Kqo8TI6p=ozE4wLEEji`go`?!ysa@hHyK8)u1{M2mX-}7kE*jSWSD#^w1`8 zcARNf+RV;sGj427oeo0)ZK%qm(JIJlc5d#yUlmr7_s=)~84Khth+Es*L3GHCP zi)=0(Pl|}ZLw(%fJ8%mEe$(zI%3*LY-GXpBg1Rr8GZeQlb{ZVZ|AH603X3d$DvAI@ z&pfEKe_{m{^SEY^beNrHaj7K>cx_BdiCSiL2|g6<$?1a+u#{9y$Y9w3QL+j}`#aVL zF<-^Cd8xt5aZsdQi&pbBG46>a?;TqMXoVFG3Ek}?0vJ$!tPy!c!Fd9f{@Wu?YWQrj zHSY5W1tPD_X*4I7He7@!y^3Rkwe%L|Z?pfi2*B`8gp7Wl?BHYx+TvL?Er^-*Ry3Os zbXC2D5OFcW11xRobzvOu>RQH+{)0))d!)gjyf&Q3!iwthWhFddR|v!z;06IU61taY zxtvB~hQtNsFo#XJ*n{cJY01wP6D2EIhdb2riEyXFzo_2yok&OD=WukkteP2|wIIR=ZW{=sq4tLe zHRx=i#4UgYe%jV3I}5V75Jp&}xWbD76spsV5`Jf_(%)-(ND_F}EyPTQxD#Qa?z+T9 z)jr6yG76ho7!w}pTvo7!=ew1#+ z;Wp3*_L)_XWrSfhhFHgY^o zN zg%muQlqmhvC;F@(0K0nzKh?2|UUwA~e(419Ru_RY*fl9)~kTh+Unl7O$ zlsH9zeNEw$41%~XP-4R3T9gJD&^7iCh6|gEnaLnx3JotASeDAPCc_-EYFwNP z47vCqt!I6Vqdj3rp^`_(xsmn37CVlJ8j0lFe^)8Wazkobg_GQ12+9%RoGm&7%iqA& zv+6L5cq=_;3&cEg&l+KP&v}nv)1T=1lPA7Q3*Q^AtAg`1u8yv*jEP$~)rI>nms)0K zXP+VF4!)Q5TsAroonB=Mi!Ix(|9jJKQewfM#$%Nsy{DPbd3|wZZ__>d9KM_RzT0!Q z72_{elQHlMQ9kYTQ_2jx?4HX&pOE2Xbh#*n7?Wz<(3thqsA4n20P8J1OiqDi&u-_s zQyIrN?>CQ`m9I6wU0quXQ)%t-{np5{v|L=fqC|mdNDDrT8$rzzAjCV=TA?p%8L0oso-qajMGEAD6M^&kp~J!QsWc5 zgeK)Xd2%m(XYUF?%_qfX8BV*fkdKG-iy|0!>hR8(<;)cjxXFQ%Hz_EQ#cVBCPiy;| zJ(c1Lypxd~MM?n*Z4Ks&H)2POWVU2Rj~Fazc)Gw9b8ZHCOCMHxpjyV^)<&qU`0C zKVcFTYyH+#aP?PU_SOM=&C#xN;YEq~*J=cO&VBBN(+W`bC3+qJ2Puv7U24h|Ks9=% zNnqxm#R@J|HmlQ?dk`_7f9t2(Kl+pALY+}fm`l*z3*~*974fCAETpG^65cu@5WMK! z$8h%nU!2yHI}Bjl46}2eg|V6tR;k~8#)`Dp2+IZ%B8Y$KBI3h=1csOM8JqM`-QfoBbAMRGqE{>2EzECw8Qcqz$Nuz zSkDH>ml1lKo+B}drq6XbQE=VV+)xL>TU~@}TFN26GK6q|6kQ{p( zGxk;lb+Cvo$3YC*(IG~srQdwuan*LzO)dutU9tFg0jz7Fx%<0z_^i$j%Y4PhbdmG)mjrm4Xoe_{RDx*0n*>AL$UD9No zwNyXD0aB=rx;grP2>}*AY@YfwEEf}Oa^hfz*fEWP{FsK#wbSP;*a)sirz6`(dSHxe zko8yZj8VlwLO^ZRV&B6qIFBCQD!{V8G;%az*N`=EY?}4Uq9S9JF8~5ztU<`jXmPI7 ztUrfMg!e`K%q_P-XO<1#BUACTtk)v|26*aTCfneLT+HRe_r!P}4Qa4}KIeh#6d@a~ zEhNKq5keA-F%ZkcRBszHaPGq&HHPvV6(34!J?SQ_O`2+6BB$-i@sh)AtiLdGajTP~ zm&b!57Z=v)mXE0(uhQZtnIv0o8NUSib28hKcV!HE(so=NB(I%)bP#l3ztO@y8~ow) z#d|(I+VmRp71GdWQlky~t((&WN$q|%&&RSEDicQng@4G0g`(ACFDwV2mtM?>lqz-D z9bSU0xDXBV$&V#$D7t8?{_7LF#J2K$2qujIllg`$f&t@mR`OWU{$&{)njH6OdWn2M z_ApP%xb4ug#=}hSsWmA)(^w!a9p;n7iQOK!@8{K0q?guCAJhOzi!Q_pBWsV z&Tm5iOqz^=CCuBA7+$8s%CTi^W@FlBQD>YnH4?9Fjcv30qBAb>$KWtY8>7IV!Yy?E z*vj7El~H&9Nll3GRUK<|a<=-upXL-?Bbz1gt3vZi&tEjoR*Nk18x`6FmL~lWGuX6CRy8Khjc%Z zFI+yeU~3Yc`fp<%A&O)>mvIMdrz-C(XoAd5K~lf==A>Qyy#L|2py{J#4AE8*Um|m} zPb-9M>rZ!v>P4+{d_gzem3(I>4r2G;X#__4Az6pnJ7Rax`G?bkkAn?a0^R;WzJti% zMVp5tU>o)+9^ha(VyWdpZ|w!~u8Sb5k><ZaVBJPQc<%s!w zLKLWY__J1D?A5uK@Y6tr44$!- zdIyKiSM;xprRof&l6rp|~x2>jRe+BnnOz4*f!@3z0)ovcFsqYKdJLLkw5 z@UruS-%_e%P(|7e^YS+F`|OGZcXQPp7CD<7ULHENt%^NaBdsnW;PZ*t(_a%(J*{u- zuH1Hxk~t5(N%s2Mlx}s2;AP(JCzQ9(>RwZG^nP_PGu8oy+f}8~Qs(meY+kf~+cnA@ zwdn1d;r!WnapewX2L}DnC8AY=3f85JL1Nr!V`clYe7#k8$q5yaf(cNHi3|50^{5%0|poca}^R zX@;xgyLhb|+I2Mu;KK7zp=Lu9nn{tTb1q5qQPW~|hAs^L9te%q*YQ%S!Vb}m37qso zMn(#S^QmiGBr_>8)uEXNV6oq6P2~qHY0ZPH+DkNuJ0?2=bw((@Tv!eb>^;y9eB|Y- zL!ldm>u@%Ewu90zNT2E8xyn3aFVC$t-7i_BCCVBGQ&!DicHh%I-Z5 z=xl7@srS;Ok2Cb{a90(PpH1Kq?%papY!0mND}P3a28{qDg-j{^WxQ^F^Q*!M<13_g z7&Y#^5G^Nx9eY~TL!r%7vycF1Z6kRj0b>@EEatx&=<93{Q~0}&Kv&K zyW6vTw?&^&ulJWxgGKHzgS2Ju*Cs~-^Oat5^M=movt6k=YgYb=_WwMrf~63mcUV&E zqA^X|_%Ji^R_g?o!D2MA?A9Ku^(;5V{dFvt=*+ku>uK*~Lf`@ReV=u}(d9^1i%Ys1 z|F5Q;Se`)Y!Sa5^v9#B-*istUz7{@uZ&j6cjxJs%-uHYdysZ?|@TOIx z_H^1nV{qBpY6ORs{KiHGt%^f+%;Iv#U-qOb9Z){VGNe(*+~w)l?ybJT${gSz5`FAF zekz$c^9**8WZL8w0g{RTj19zL;8WPsOu1L=gg}LZkInNTPvhRBPE1PU2B3itK1509 zgjX6^UoB^Ew4q7lF>IyX)IAyk^EbsxM*-d$R0X^3z@fGr`>zqQ+Xq7P1x=b_>efef%5(`$*x8v?bM8#agvTu_DxQM5F#NcI`B*~Kb7XUhRA22Pw0jqsvR5H}H{R8(8-O^|D|IUF#-03QSi zhPUxWP>07+Gjv;LeI@Z8<%|DE4f3OaeaA)U2DF8r5%4U<+2<*00#Jq<`}I8RVU3}F zK&aK;7!*N`z_1Vw+;r)U%j1S@$-P5)s16Gg2D|}LfMI)>2Ia2?U&bN<)e<`Et0mJL zrT6op$;RX?0!qpxtXgEvQtYEm0q}MZfy$BIUKbw#7y{~7|&<>P4eT^>3 zZkmHCAT$Pl`v|-bL!B@fG)v3ko)*)rRw-oNbWyquhmgLe`8rR#fTs|3!*>Yz>Y7As z&~o(oWdV=X)lDOD*q=E37QA{kYg>Fbs2_C$aSDoMlpg$Lx-OhjDsQ%T775(g%U|$V zmPv1D3oHr##YM<-(v18@IXVpRN2m*ir==M(Hw~7)2av-9qE#=8715~ws@s#61?jrJ z>As2a#e*(9K%}45GK2ISkk=!!!EHOR8LiqWl_psGiKM}3+tMT#$bv8@SP*mnQvzLXf`)eB;Sa)RBVclqC{4 znyBc&!k{3l4%j+}icX!Or;X0-yvI&Ml3IkkSuOc$UT0hF&Au)t|LUYXd$zuDqsK)C zbO?ZO?;7xxuY1x06XxsC1tq(v^T>*l76$mAKv5L88sNhL9ldD)$TLO;Hx`ccUje2g zXM4h*zJfC(u|j99xm3@&(4n*7nhp5^!nFI#4-00kReT)4G|ClU73XEMcvNl}OCc!~ z@H77!&A5`afI7qe*}K5?ArZEM20Dn1$Z^ zHbj^_+hh?)^LeB0#+?^OEOsi<#8@s04 zB=rObD|0-t$6-2TKmE$X=FdvUDSrg=@jpO|S$(=ESMt<*N^DOdEbmBZ+u2q68M^+$ zG1+r=>7~kBY^`6@(zakF#?!cdq7P% zGOsu;6dB0z-{_kTw&;LGxouT0_0T(x+yx{_9f%KC-^Lrds4#}VCXM1u_N!WXqU38+ zoi-~KDG1PKdiF;%{e>LdFfDD5N_0v-%wj6DsFbV) zA?(pRlK3);%7c4UgyZAJArIr?aKBL&@gN`La6pUip2@j7GvQtINg|m)ko{i*Y#eNY zkT%&?(SY^~Csc|{%-jNO^TKHyPMdZ;X51!H804BfkJSnT!XrK<#%UK3&MoRDvh1#> zr5iYTm$fMR&Z?^O8XghRj`aJ^*U7dHM&2kH;-dH$EQc?77cQPg8$%$zyR} z%3<-N<~zIEhUI!G29mmV6Fdw%1ypoq!|X`pv(^I5D(C2$N_lJD)u6>4}`}F(dp6d}uHbdI)wXb!FO_^+*!0E}`wojkc9a`xb z+&x`ZjfjAP;oIw;vjg7GSFqx)>Bnu_uw8T>NOB6=s3xmB7^6r0f<6kE;riKolgmaF zd4xZw$|U9J{3a~2r~tAtx_ee2O=-B!ZA{=qL+wnP$D?~c2^!70wGD&rY2|zdlMp#M z@J;oo+r0b~b7#rl>e)+Kb@`^9u_d`eb$C-AAyEA)IkixC`f&AvJ?`;+s}CQP?84dv z^1#nSNJ=#^*0DB0{-`fT>lw;$O#ct7T3U7NLm)4H;M-EAsB@~oas5ozLaiGoP@T!N z`uRSf$@hT(PzwXn9k0CyRSGUL#4)2x928J%_>Gx_q5QZGC+vJ2o|?l;7v#*e+k5_@ zRYWc(HNK`TFZ+WDztS#FeosYvsBIt%N)J#viHh+C6G9p zCTacb@%vekfecHovfz_U61X*Tx^hr_+G_jX_%h!-;$89(2D&C&YFnhhOR3rX0E=4dDP=r#kVC@uOH*22e z#D`#jCwPXJMH2@}&UU~FSmN7m6u5SNhN8{dm)tW_j*e%PH*@o){%i?)oLUK)aTHf6 zWK1m&Ejmi@#VzUug6n-J^?!|(MD?awacR?~eriaqP*!?3DJ@l$)Agy3v-s-U(A%Vv z+Eu-p*afcDo)!?aVEOdWh-a0I1nb{8I@ew*wYf|k*T8V8G;>Vtd2cq9Cwc!7>PvrlscX~c zFY`xKb&bufygWE%M_RRw20iqF3=BT*aZBjOq4J{&y(46M1fY16diohguKe#_z7g5b zk#aI@a1(dvcd?|$Oy&1GYX2 z+o=W*BLZ!7Uc0{V3nabG(8r?l-gaP}S@R~1?sr#Q1ens@c+tu$&LU_<_xgCeDbHha zA+N;}k5DQMEJK&mIo~p~s2oyXx^nZ(`JFJ{f@QXjy@~xR`+T*?F zMn9Js3~I$SDHVYKY|rmtpbBI1`Nc)+rq`?RDuaiE%{iSn-yW2GjataXk)bS_L5BUv z52-CTpFFx+uw6bK@I2No(Ua}J)bu)qJ(R4NmV+<((opsZ*CeJJ0}xa$5Y@KFo!Ps@ zQIjX#DzFLOlcs*{*i%o%Jl`L7P+Y&!eyWgxKH}<8o`i(8LAaGJ4NnfBLJ_p4}MSHwf!$4iFPR}Pr72xMO-SbE40OS(1h|oDbM2~ z0cbc{Jf7zeZ2-6k4V;HFi_4T)RGL;NDvKNvy2Cd9omcJvu6&6 zB28~{k3vv5NOj;~(np=4P^C&G7;qnBzKJ7xz3OOL03~dy$&5cWV|by0O3`Gz$#evH zy^CxMW2<;N49{%yysU2J+^l%SOmg!b5+mG)>h#jS6u zfjez3{CeM2L_^30T+J+~t;Y>NtZ~xt?a8@WAcF)`j5egTmah`!Tct6xWcBumhe`*L zo;o)Z6{5+E>^Yba|LPA%hB9NF#ivdlHN{O=chhXf{XmqFc%<(ao03RJ;Nkg8k};FVQEa;+^8)z z7FOx_g4O$?aPp4o&TqA5rFx)>t{xz3FSN75pyNgX5Pz;`REU}in+7d}Y>3YzG*DgslhpA_ z{dzMqA+avN+-317jz+u{ax-E+E}~D)eLh@b*W?Ee|Fo*|*ChU6@%H55h+D+JiFEm> z2<5;~vuk6j)N@qzXs=ikdk2_jlJ+9i1_l#|u-*Lcz)1&5Ap(NcxPYXIn!I(LbdPld z+QX+lxzqVh8P7gW)$E#NRrxx*9fw`7W%R{p9E}(cx)diVn$ZzvR3Esv#QdkJGkHva zW0}=d_jFBazkzLnC-o~#%l+BTWH~3))E~jxqW8S_kKCm>8@g`ItV=BB$8=NAJIPJU z@TL@#?m8NjiH-Yx_%`%>z-R`JWSU)hZjx+&zqziEDZ>F}e}Czyet<7aDN}RG;Qwpy z+TWSp$Y6xRxDi{mr3R_EILD+5eqXh z%%g_Qa1zON$z?4O8#8NkzWf#Ehx7RhUZ3~#{=DAL`~7*|ub1J6VpX`hN4|i@J~oC z8f(1Ex~GfmujN2!#1LdD{k=pd-C{kfF6_+R_HVb6y71nx&?pxcjfFSBCi@)Pe7mfx z5|kaz{LCKX>@+8xQ#NI82pPM>%;E{MLKSulB?4t1+mI$CFJ{CIhKZ`Rp>opw5<#|9;Q4>Z|)m|O9>)BYS z3Y2yjOT+4_IbOn9@Vt_;ID^EKC57@>`g%b&vlrrrYU%hpsAZDm9Hku z*MALa4dCwHYoq856*sYXTlF3Lfvy6*U!-|q+>W^@$)y= zPG*ms9ZFLzL5ghr>Z6InsuiKjt$RMc;F;$GhCeF_LDGglsKi&3Vh*Qcm8PsTPcSj1 zLLgNIqYaFVI@X-H`}I1yMP4-bSNsID$7u86yJwb`>g3eNy^W@MyK{l#cB&UgJ+|EO zVN$G9Xtb|J;MFcW44c%@&Q`7wV31B$x9B9Av% zXm2#;+|=lZs!xoCHhp0hhdT66xm!QB*)vA(w?g^GPM;$Qt7;8SLIslxQ+{{+TM!eh zqRV~5i04D~xG!BEwMQ-Lv0F%F?M?~1@Dwq;5-TXSI#Buj9iuNR+(D;X7%{XoDhY4c zLS1}0O#nT_wF>f`t!E@&vE)$d=i(-Y<}b=(%q1)^jqyXS2jz)EGb zrfo!XQ2##1Z<2``g+vzL_l~J0pM(emV zcg=7ui=oAs`n6Ws1rTcTr!AGv`nb^2ZzhZ7F|$>$8u-iz08HuyMkvM_txFPq!ka-Q zigL6c?{G%2BYO!8Lus(uw4x(syiPcS0Vnog{vE(jL}7XmViL9~LUEq|ZkYpu8|BMQ zAl>Xiu~ zn3`?)we?U=Lw{RNJcWP2%*SuTnX7K80f74R1~Ip@Zgh1b;%^myc4VUOcF(vVEpwWi zd)k$GigqF8V3LqAJVu*j@qyYSk2Yx8^86?iudZ&}J_GSCrrA_9u6j}0f?Y{VCY6%N zl|pjeUApxU=X_v<)I1OwkX7@Zx~W_`1!&*?fBu^h=*&!3I3I~VSC}Y!wSy30>ughJ HedGSW=zn+i literal 0 HcmV?d00001 diff --git a/apps/web/public/pools-sitemap.xml b/apps/web/public/pools-sitemap.xml index 08339144e0f..03d6f39b07c 100644 --- a/apps/web/public/pools-sitemap.xml +++ b/apps/web/public/pools-sitemap.xml @@ -13130,4 +13130,24 @@ 2025-10-17T22:04:33.647Z 0.8 + + https://app.uniswap.org/explore/pools/ethereum/0xa3ccaf08a54cf31649f91ae1570a0720c8d4eb1e + 2025-10-24T19:06:27.459Z + 0.8 + + + https://app.uniswap.org/explore/pools/arbitrum/0xd13040d4fe917ee704158cfcb3338dcd2838b245 + 2025-10-24T19:06:27.459Z + 0.8 + + + https://app.uniswap.org/explore/pools/bnb/0x75c5fbf77c1cd517544487aca4cc41e1ad95aced + 2025-10-24T19:06:27.459Z + 0.8 + + + https://app.uniswap.org/explore/pools/bnb/0x8c5a402ede3a33998604c8ba5fe6510896cb3821 + 2025-10-24T19:06:27.459Z + 0.8 + \ No newline at end of file diff --git a/apps/web/public/tokens-sitemap.xml b/apps/web/public/tokens-sitemap.xml index a865ac527e2..cc16efb4f28 100644 --- a/apps/web/public/tokens-sitemap.xml +++ b/apps/web/public/tokens-sitemap.xml @@ -11115,4 +11115,94 @@ 2025-10-17T22:04:33.647Z 0.8 + + https://app.uniswap.org/explore/tokens/solana/METvsvVRapdj9cFLzq4Tr43xK4tAjQfwX76z3n6mWQL + 2025-10-24T19:06:27.459Z + 0.8 + + + https://app.uniswap.org/explore/tokens/solana/Dfh5DzRgSvvCFDoYc2ciTkMrbDfRKybA4SoFbPmApump + 2025-10-24T19:06:27.459Z + 0.8 + + + https://app.uniswap.org/explore/tokens/base/0x815269d17c10f0f3df7249370e0c1b9efe781aa8 + 2025-10-24T19:06:27.459Z + 0.8 + + + https://app.uniswap.org/explore/tokens/solana/SarosY6Vscao718M4A778z4CGtvcwcGef5M9MEH1LGL + 2025-10-24T19:06:27.459Z + 0.8 + + + https://app.uniswap.org/explore/tokens/base/0x93d6afa0e6f11f4f7e9521ec6243f839526af7a6 + 2025-10-24T19:06:27.459Z + 0.8 + + + https://app.uniswap.org/explore/tokens/bnb/0x4c9027e10c5271efca82379d3123917ae3f2374e + 2025-10-24T19:06:27.459Z + 0.8 + + + https://app.uniswap.org/explore/tokens/solana/USDSwr9ApdHk5bvJKMjzff41FfuX8bSxdKcR81vTwcA + 2025-10-24T19:06:27.459Z + 0.8 + + + https://app.uniswap.org/explore/tokens/solana/SW1TCHLmRGTfW5xZknqQdpdarB8PD95sJYWpNp9TbFx + 2025-10-24T19:06:27.459Z + 0.8 + + + https://app.uniswap.org/explore/tokens/solana/3wPQhXYqy861Nhoc4bahtpf7G3e89XCLfZ67ptEfZUSA + 2025-10-24T19:06:27.459Z + 0.8 + + + https://app.uniswap.org/explore/tokens/base/0xdcaa5e062b2be18e52ea6ed7ba232538621ddc10 + 2025-10-24T19:06:27.459Z + 0.8 + + + https://app.uniswap.org/explore/tokens/solana/METAwkXcqyXKy1AtsSgJ8JiUHwGCafnZL38n3vYmeta + 2025-10-24T19:06:27.459Z + 0.8 + + + https://app.uniswap.org/explore/tokens/solana/oreoU2P8bN6jkk3jbaiVxYnG1dCXcYxwhwyK9jSybcp + 2025-10-24T19:06:27.459Z + 0.8 + + + https://app.uniswap.org/explore/tokens/solana/6nR8wBnfsmXfcdDr1hovJKjvFQxNSidN6XFyfAFZpump + 2025-10-24T19:06:27.459Z + 0.8 + + + https://app.uniswap.org/explore/tokens/solana/AjPzK6Sf1G27jFkFe4HViSNqMxa3JLE4D1fm6Pzouq2q + 2025-10-24T19:06:27.459Z + 0.8 + + + https://app.uniswap.org/explore/tokens/bnb/0x0a8d6c86e1bce73fe4d0bd531e1a567306836ea5 + 2025-10-24T19:06:27.459Z + 0.8 + + + https://app.uniswap.org/explore/tokens/solana/JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN + 2025-10-24T19:06:27.459Z + 0.8 + + + https://app.uniswap.org/explore/tokens/solana/pSo1f9nQXWgXibFtKf7NWYxb5enAM4qfP6UJSiXRQfL + 2025-10-24T19:06:27.459Z + 0.8 + + + https://app.uniswap.org/explore/tokens/ethereum/0xf245964bd0a73128e10c4f7c96d0664ea2e436d8 + 2025-10-24T19:06:27.459Z + 0.8 + \ No newline at end of file diff --git a/apps/web/src/appGraphql/data/util.tsx b/apps/web/src/appGraphql/data/util.tsx index 18e459e39c1..a7ffb3e9d86 100644 --- a/apps/web/src/appGraphql/data/util.tsx +++ b/apps/web/src/appGraphql/data/util.tsx @@ -189,6 +189,12 @@ const PROTOCOL_META: { [source in GraphQLApi.PriceSource]: ProtocolMeta } = { color: '$chain_137', gradient: { start: 'rgba(96, 123, 238, 0.20)', end: 'rgba(55, 70, 136, 0.00)' }, }, + [GraphQLApi.PriceSource.External]: { + // TODO (LP-350): Remove this since this protocol chart does not exist anymore + name: 'external', + color: '$neutral1', + gradient: { start: 'rgba(252, 116, 254, 0.20)', end: 'rgba(252, 116, 254, 0.00)' }, + }, /* [GraphQLApi.PriceSource.UniswapX]: { name: 'UniswapX', color: purple } */ } diff --git a/apps/web/src/assets/images/portfolio-page-promo/dark.svg b/apps/web/src/assets/images/portfolio-page-promo/dark.svg new file mode 100644 index 00000000000..1ae6e3cb76e --- /dev/null +++ b/apps/web/src/assets/images/portfolio-page-promo/dark.svg @@ -0,0 +1,778 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web/src/assets/images/portfolio-page-promo/light.svg b/apps/web/src/assets/images/portfolio-page-promo/light.svg new file mode 100644 index 00000000000..8e9c0579fe4 --- /dev/null +++ b/apps/web/src/assets/images/portfolio-page-promo/light.svg @@ -0,0 +1,778 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web/src/assets/svg/Emblem/A.svg b/apps/web/src/assets/svg/Emblem/A.svg new file mode 100644 index 00000000000..46c5ecdf931 --- /dev/null +++ b/apps/web/src/assets/svg/Emblem/A.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/web/src/assets/svg/Emblem/B.svg b/apps/web/src/assets/svg/Emblem/B.svg new file mode 100644 index 00000000000..9ba0cad2077 --- /dev/null +++ b/apps/web/src/assets/svg/Emblem/B.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/web/src/assets/svg/Emblem/C.svg b/apps/web/src/assets/svg/Emblem/C.svg new file mode 100644 index 00000000000..df525ee3977 --- /dev/null +++ b/apps/web/src/assets/svg/Emblem/C.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/apps/web/src/assets/svg/Emblem/D.svg b/apps/web/src/assets/svg/Emblem/D.svg new file mode 100644 index 00000000000..6673c60e7b6 --- /dev/null +++ b/apps/web/src/assets/svg/Emblem/D.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/web/src/assets/svg/Emblem/E.svg b/apps/web/src/assets/svg/Emblem/E.svg new file mode 100644 index 00000000000..f1d262aa1fd --- /dev/null +++ b/apps/web/src/assets/svg/Emblem/E.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/web/src/assets/svg/Emblem/F.svg b/apps/web/src/assets/svg/Emblem/F.svg new file mode 100644 index 00000000000..f7f9944dbfa --- /dev/null +++ b/apps/web/src/assets/svg/Emblem/F.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/web/src/assets/svg/Emblem/G.svg b/apps/web/src/assets/svg/Emblem/G.svg new file mode 100644 index 00000000000..44e41f65357 --- /dev/null +++ b/apps/web/src/assets/svg/Emblem/G.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/web/src/assets/svg/Emblem/default.svg b/apps/web/src/assets/svg/Emblem/default.svg new file mode 100644 index 00000000000..1839d363511 --- /dev/null +++ b/apps/web/src/assets/svg/Emblem/default.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/web/src/components/AccountDrawer/AuthenticatedHeader.tsx b/apps/web/src/components/AccountDrawer/AuthenticatedHeader.tsx index 638bf5898ad..72bdd2ef2ee 100644 --- a/apps/web/src/components/AccountDrawer/AuthenticatedHeader.tsx +++ b/apps/web/src/components/AccountDrawer/AuthenticatedHeader.tsx @@ -1,5 +1,6 @@ import { NetworkStatus } from '@apollo/client' import { CurrencyAmount, Token } from '@uniswap/sdk-core' +import { FeatureFlags, useFeatureFlag } from '@universe/gating' import { MultiBlockchainAddressDisplay } from 'components/AccountDetails/MultiBlockchainAddressDisplay' import { ActionTile } from 'components/AccountDrawer/ActionTile' import { DisconnectButton } from 'components/AccountDrawer/DisconnectButton' @@ -38,8 +39,6 @@ import { UniverseChainId } from 'uniswap/src/features/chains/types' import { usePortfolioTotalValue } from 'uniswap/src/features/dataApi/balances/balancesRest' import { FiatCurrency } from 'uniswap/src/features/fiatCurrency/constants' import { useAppFiatCurrency, useAppFiatCurrencyInfo } from 'uniswap/src/features/fiatCurrency/hooks' -import { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' import { useLocalizationContext } from 'uniswap/src/features/language/LocalizationContext' import { Platform } from 'uniswap/src/features/platforms/types/Platform' import { useHasAccountMismatchOnAnyChain } from 'uniswap/src/features/smartWallet/mismatch/hooks' diff --git a/apps/web/src/components/AccountDrawer/DisconnectButton.tsx b/apps/web/src/components/AccountDrawer/DisconnectButton.tsx index 4cd815d1f91..0e23c16c916 100644 --- a/apps/web/src/components/AccountDrawer/DisconnectButton.tsx +++ b/apps/web/src/components/AccountDrawer/DisconnectButton.tsx @@ -1,3 +1,4 @@ +import { FeatureFlags, useFeatureFlag } from '@universe/gating' import { useAccountDrawer } from 'components/AccountDrawer/MiniPortfolio/hooks' import { MenuStateVariant, useSetMenu } from 'components/AccountDrawer/menuState' import { Power } from 'components/Icons/Power' @@ -14,8 +15,6 @@ import { PlusCircle } from 'ui/src/components/icons/PlusCircle' import { SwitchArrows } from 'ui/src/components/icons/SwitchArrows' import { AppTFunction } from 'ui/src/i18n/types' import { CONNECTION_PROVIDER_IDS } from 'uniswap/src/constants/web3' -import { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' import { Platform } from 'uniswap/src/features/platforms/types/Platform' import { setIsTestnetModeEnabled } from 'uniswap/src/features/settings/slice' import { ElementName } from 'uniswap/src/features/telemetry/constants' diff --git a/apps/web/src/components/AccountDrawer/MiniPortfolio/Activity/ActivityTab.anvil.e2e.test.ts b/apps/web/src/components/AccountDrawer/MiniPortfolio/Activity/ActivityTab.anvil.e2e.test.ts index 413b3f15890..432c2f5f253 100644 --- a/apps/web/src/components/AccountDrawer/MiniPortfolio/Activity/ActivityTab.anvil.e2e.test.ts +++ b/apps/web/src/components/AccountDrawer/MiniPortfolio/Activity/ActivityTab.anvil.e2e.test.ts @@ -19,6 +19,7 @@ test.describe('ActivityTab activity history', () => { }) await stubTradingApiEndpoint({ page, endpoint: uniswapUrls.tradingApiPaths.swap }) + await stubTradingApiEndpoint({ page, endpoint: uniswapUrls.tradingApiPaths.quote }) await graphql.intercept('ActivityWeb', Mocks.Account.activity_history) await page.goto(`/swap?inputCurrency=ETH&outputCurrency=${USDC_MAINNET.address}`) diff --git a/apps/web/src/components/AccountDrawer/MiniPortfolio/ExtensionDeeplinks.tsx b/apps/web/src/components/AccountDrawer/MiniPortfolio/ExtensionDeeplinks.tsx index f163e30df46..34d31b6c4dc 100644 --- a/apps/web/src/components/AccountDrawer/MiniPortfolio/ExtensionDeeplinks.tsx +++ b/apps/web/src/components/AccountDrawer/MiniPortfolio/ExtensionDeeplinks.tsx @@ -1,4 +1,4 @@ -import { PositionStatus } from '@uniswap/client-pools/dist/pools/v1/types_pb' +import { PositionStatus } from '@uniswap/client-data-api/dist/data/v1/poolTypes_pb' import { useOpenLimitOrders, usePendingActivity } from 'components/AccountDrawer/MiniPortfolio/Activity/hooks' import { useAccountDrawer } from 'components/AccountDrawer/MiniPortfolio/hooks' import { MenuStateVariant, useSetMenu } from 'components/AccountDrawer/menuState' diff --git a/apps/web/src/components/AccountDrawer/MiniPortfolio/Pools/PoolsTab.tsx b/apps/web/src/components/AccountDrawer/MiniPortfolio/Pools/PoolsTab.tsx index d4bb8b966c4..5dbd6c74049 100644 --- a/apps/web/src/components/AccountDrawer/MiniPortfolio/Pools/PoolsTab.tsx +++ b/apps/web/src/components/AccountDrawer/MiniPortfolio/Pools/PoolsTab.tsx @@ -1,4 +1,4 @@ -import { PositionStatus, ProtocolVersion } from '@uniswap/client-pools/dist/pools/v1/types_pb' +import { PositionStatus, ProtocolVersion } from '@uniswap/client-data-api/dist/data/v1/poolTypes_pb' import { ExpandoRow } from 'components/AccountDrawer/MiniPortfolio/ExpandoRow' import { useAccountDrawer } from 'components/AccountDrawer/MiniPortfolio/hooks' import { EmptyPools } from 'components/AccountDrawer/MiniPortfolio/Pools/EmptyPools' diff --git a/apps/web/src/components/AccountDrawer/MiniPortfolio/PortfolioRow.tsx b/apps/web/src/components/AccountDrawer/MiniPortfolio/PortfolioRow.tsx index 7c5c73a38ee..404fd569423 100644 --- a/apps/web/src/components/AccountDrawer/MiniPortfolio/PortfolioRow.tsx +++ b/apps/web/src/components/AccountDrawer/MiniPortfolio/PortfolioRow.tsx @@ -1,5 +1,5 @@ -import { LoadingBubble } from 'components/Tokens/loading' -import { Flex, FlexProps } from 'ui/src' +import { TextLoader } from 'components/Liquidity/Loader' +import { Circle, Flex, FlexProps, Shine } from 'ui/src' const PortfolioRowWrapper = ({ children, className, ...rest }: FlexProps) => ( - - - - - - - {shrinkRight ? ( - - ) : ( - <> - - - - )} + + + + + + + + + + + + + + + + + + + + + - + ) } -export function PortfolioSkeleton({ shrinkRight = false }: { shrinkRight?: boolean }) { +export function PortfolioSkeleton() { return ( - <> + {Array.from({ length: 5 }).map((_, i) => ( - + ))} - + ) } diff --git a/apps/web/src/components/ActivityTable/ActivityAddressCell.tsx b/apps/web/src/components/ActivityTable/ActivityAddressCell.tsx new file mode 100644 index 00000000000..b47c5acf5cd --- /dev/null +++ b/apps/web/src/components/ActivityTable/ActivityAddressCell.tsx @@ -0,0 +1,27 @@ +import { AddressWithAvatar } from 'components/ActivityTable/AddressWithAvatar' +import { buildActivityRowFragments } from 'components/ActivityTable/registry' +import { Flex } from 'ui/src' +import { ArrowRight } from 'ui/src/components/icons/ArrowRight' +import { TransactionDetails } from 'uniswap/src/features/transactions/types/transactionDetails' +import { getValidAddress } from 'uniswap/src/utils/addresses' + +interface ActivityAddressCellProps { + transaction: TransactionDetails +} + +export function ActivityAddressCell({ transaction }: ActivityAddressCellProps) { + const { counterparty } = buildActivityRowFragments(transaction) + + // Use counterparty from adapter if available, otherwise fall back to from address + const rawAddress = counterparty ?? transaction.from + const otherPartyAddress = rawAddress ? getValidAddress({ address: rawAddress, chainId: transaction.chainId }) : null + + return ( + + {otherPartyAddress && } + + + + + ) +} diff --git a/apps/web/src/components/ActivityTable/ActivityAmountCell.tsx b/apps/web/src/components/ActivityTable/ActivityAmountCell.tsx new file mode 100644 index 00000000000..b3cddbac36b --- /dev/null +++ b/apps/web/src/components/ActivityTable/ActivityAmountCell.tsx @@ -0,0 +1,255 @@ +import { buildActivityRowFragments } from 'components/ActivityTable/registry' +import { TokenAmountDisplay } from 'components/ActivityTable/TokenAmountDisplay' +import { useTranslation } from 'react-i18next' +import { Flex, Text } from 'ui/src' +import { ArrowRight } from 'ui/src/components/icons/ArrowRight' +import { useFormattedCurrencyAmountAndUSDValue } from 'uniswap/src/components/activity/hooks/useFormattedCurrencyAmountAndUSDValue' +import { CurrencyInfo } from 'uniswap/src/features/dataApi/types' +import { useLocalizationContext } from 'uniswap/src/features/language/LocalizationContext' +import { + useCurrencyInfo, + useNativeCurrencyInfo, + useWrappedNativeCurrencyInfo, +} from 'uniswap/src/features/tokens/useCurrencyInfo' +import { TransactionDetails } from 'uniswap/src/features/transactions/types/transactionDetails' +import { getSymbolDisplayText } from 'uniswap/src/utils/currency' +import { NumberType } from 'utilities/src/format/types' + +interface ActivityAmountCellProps { + transaction: TransactionDetails +} + +function EmptyCell() { + return ( + + — + + ) +} + +interface DualTokenLayoutProps { + inputCurrency: CurrencyInfo | null | undefined + outputCurrency: CurrencyInfo | null | undefined + inputFormattedAmount: string | null + outputFormattedAmount: string | null + inputUsdValue: string | null + outputUsdValue: string | null + separator?: React.ReactNode +} + +function Separator({ children }: { children: React.ReactNode }) { + return ( + + {typeof children === 'string' ? ( + + {children} + + ) : ( + children + )} + + ) +} + +function DualTokenLayout({ + inputCurrency, + outputCurrency, + inputFormattedAmount, + outputFormattedAmount, + inputUsdValue, + outputUsdValue, + separator = , +}: DualTokenLayoutProps) { + return ( + + + {separator} + + + ) +} + +function formatAmountWithSymbol(amount: string | undefined, symbol: string | undefined): string | null { + return amount ? `${amount}${getSymbolDisplayText(symbol)}` : null +} + +function getUsdValue(value: string | undefined): string | null { + return value !== '-' ? (value ?? null) : null +} + +export function ActivityAmountCell({ transaction }: ActivityAmountCellProps) { + const formatter = useLocalizationContext() + const { t } = useTranslation() + const { chainId } = transaction + const { amount } = buildActivityRowFragments(transaction) + + // Hook up currency info based on amount model + const inputCurrencyInfo = useCurrencyInfo(amount?.kind === 'pair' ? amount.inputCurrencyId : undefined) + const outputCurrencyInfo = useCurrencyInfo(amount?.kind === 'pair' ? amount.outputCurrencyId : undefined) + const singleCurrencyInfo = useCurrencyInfo( + amount?.kind === 'single' || amount?.kind === 'approve' ? amount.currencyId : undefined, + ) + const currency0Info = useCurrencyInfo(amount?.kind === 'liquidity-pair' ? amount.currency0Id : undefined) + const currency1Info = useCurrencyInfo(amount?.kind === 'liquidity-pair' ? amount.currency1Id : undefined) + + const nativeCurrencyInfo = useNativeCurrencyInfo(chainId) + const wrappedCurrencyInfo = useWrappedNativeCurrencyInfo(chainId) + + // Format amounts based on kind + const inputFormattedData = useFormattedCurrencyAmountAndUSDValue({ + currency: inputCurrencyInfo?.currency, + currencyAmountRaw: amount?.kind === 'pair' ? (amount.inputAmountRaw ?? '') : '', + formatter, + isApproximateAmount: false, + }) + + const outputFormattedData = useFormattedCurrencyAmountAndUSDValue({ + currency: outputCurrencyInfo?.currency, + currencyAmountRaw: amount?.kind === 'pair' ? (amount.outputAmountRaw ?? '') : '', + formatter, + isApproximateAmount: false, + }) + + const singleFormattedData = useFormattedCurrencyAmountAndUSDValue({ + currency: singleCurrencyInfo?.currency, + currencyAmountRaw: amount?.kind === 'single' ? (amount.amountRaw ?? '') : '', + formatter, + isApproximateAmount: false, + }) + + const wrapAmountRaw = amount?.kind === 'wrap' ? (amount.amountRaw ?? '') : '' + const wrapInputCurrency = amount?.kind === 'wrap' && amount.unwrapped ? wrappedCurrencyInfo : nativeCurrencyInfo + const wrapOutputCurrency = amount?.kind === 'wrap' && amount.unwrapped ? nativeCurrencyInfo : wrappedCurrencyInfo + + const wrapInputFormattedData = useFormattedCurrencyAmountAndUSDValue({ + currency: wrapInputCurrency?.currency, + currencyAmountRaw: wrapAmountRaw, + formatter, + isApproximateAmount: false, + }) + + const wrapOutputFormattedData = useFormattedCurrencyAmountAndUSDValue({ + currency: wrapOutputCurrency?.currency, + currencyAmountRaw: wrapAmountRaw, + formatter, + isApproximateAmount: false, + }) + + const currency0FormattedData = useFormattedCurrencyAmountAndUSDValue({ + currency: currency0Info?.currency, + currencyAmountRaw: amount?.kind === 'liquidity-pair' ? amount.currency0AmountRaw : '', + formatter, + isApproximateAmount: false, + }) + + const currency1FormattedData = useFormattedCurrencyAmountAndUSDValue({ + currency: currency1Info?.currency, + currencyAmountRaw: amount?.kind === 'liquidity-pair' ? (amount.currency1AmountRaw ?? '') : '', + formatter, + isApproximateAmount: false, + }) + + if (!amount) { + return + } + + // Guard against missing currency data before formatting + if (amount.kind === 'pair' && (!inputCurrencyInfo || !outputCurrencyInfo)) { + return + } + + if (amount.kind === 'liquidity-pair' && (!currency0Info || !currency1Info)) { + return + } + + switch (amount.kind) { + case 'pair': { + // Dual token layout for swaps and bridges: Token1 → Token2 + return ( + + ) + } + + case 'approve': { + // Single token layout for approvals + let formattedAmount: string | null = null + + if (singleCurrencyInfo && amount.approvalAmount !== undefined) { + const amountText = + amount.approvalAmount === 'INF' + ? t('transaction.amount.unlimited') + : amount.approvalAmount && amount.approvalAmount !== '0.0' + ? formatter.formatNumberOrString({ value: amount.approvalAmount, type: NumberType.TokenNonTx }) + : '' + + formattedAmount = `${amountText ? amountText + ' ' : ''}${getSymbolDisplayText(singleCurrencyInfo.currency.symbol) ?? ''}` + } + + return + } + + case 'wrap': { + // Dual token layout for wraps: ETH ↔ WETH + return ( + + ) + } + + case 'single': { + // Single token layout for transfers + return ( + + ) + } + + case 'liquidity-pair': { + // Dual token layout for liquidity: Token0 and Token1 + return ( + + ) + } + } +} diff --git a/apps/web/src/components/ActivityTable/ActivityTable.tsx b/apps/web/src/components/ActivityTable/ActivityTable.tsx new file mode 100644 index 00000000000..97b62e13d1d --- /dev/null +++ b/apps/web/src/components/ActivityTable/ActivityTable.tsx @@ -0,0 +1,123 @@ +import { createColumnHelper, Row } from '@tanstack/react-table' +import { ActivityAddressCell } from 'components/ActivityTable/ActivityAddressCell' +import { ActivityAmountCell } from 'components/ActivityTable/ActivityAmountCell' +import { TimeCell } from 'components/ActivityTable/TimeCell' +import { TransactionTypeCell } from 'components/ActivityTable/TransactionTypeCell' +import { Table } from 'components/Table' +import { Cell } from 'components/Table/Cell' +import { HeaderCell } from 'components/Table/styled' +import { memo, useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { Text } from 'ui/src' +import { TransactionDetails } from 'uniswap/src/features/transactions/types/transactionDetails' + +interface ActivityTableProps { + data: TransactionDetails[] + loading?: boolean + error?: boolean + rowWrapper?: (row: Row, content: JSX.Element) => JSX.Element +} + +function _ActivityTable({ data, loading = false, error = false, rowWrapper }: ActivityTableProps): JSX.Element { + const { t } = useTranslation() + const columnHelper = useMemo(() => createColumnHelper(), []) + const showLoadingSkeleton = loading || error + + const columns = useMemo( + () => [ + // Time Column + columnHelper.accessor('addedTime', { + header: () => ( + + + {t('portfolio.activity.table.column.time')} + + + ), + cell: (info) => { + if (showLoadingSkeleton) { + return + } + return ( + + + + ) + }, + }), + + // Type Column + columnHelper.accessor((row) => row.typeInfo.type, { + id: 'type', + header: () => ( + + + {t('portfolio.activity.table.column.type')} + + + ), + cell: (info) => { + if (showLoadingSkeleton) { + return + } + return ( + + + + ) + }, + }), + + // Amount Column + columnHelper.display({ + id: 'amount', + header: () => ( + + + {t('portfolio.activity.table.column.amount')} + + + ), + cell: (info) => { + if (showLoadingSkeleton) { + return + } + return ( + + + + ) + }, + minSize: 280, + size: 300, + }), + + // Address Column + columnHelper.display({ + id: 'address', + header: () => ( + + + {t('portfolio.activity.table.column.address')} + + + ), + cell: (info) => { + if (showLoadingSkeleton) { + return + } + return ( + + + + ) + }, + }), + ], + [t, columnHelper, showLoadingSkeleton], + ) + + return +} + +export const ActivityTable = memo(_ActivityTable) diff --git a/apps/web/src/components/ActivityTable/AddressWithAvatar.tsx b/apps/web/src/components/ActivityTable/AddressWithAvatar.tsx new file mode 100644 index 00000000000..4abee3d5de7 --- /dev/null +++ b/apps/web/src/components/ActivityTable/AddressWithAvatar.tsx @@ -0,0 +1,42 @@ +import { Flex, Text } from 'ui/src' +import { Unitag } from 'ui/src/components/icons/Unitag' +import { useUnitagsAddressQuery } from 'uniswap/src/data/apiClients/unitagsApi/useUnitagsAddressQuery' +import { AccountIcon } from 'uniswap/src/features/accounts/AccountIcon' +import { useENSName } from 'uniswap/src/features/ens/api' +import { shortenAddress } from 'utilities/src/addresses' + +interface AddressWithAvatarProps { + address: Address + size?: number + showAvatar?: boolean +} + +export function AddressWithAvatar({ address, size = 20, showAvatar = true }: AddressWithAvatarProps) { + const { data: ENSName } = useENSName(address) + const { data: unitag } = useUnitagsAddressQuery({ + params: address ? { address } : undefined, + }) + const uniswapUsername = unitag?.username + + const displayName = uniswapUsername ?? ENSName ?? shortenAddress({ address }) + const hasUnitag = Boolean(uniswapUsername) + + return ( + + {showAvatar && ( + + )} + + {displayName} + + {hasUnitag && } + + ) +} diff --git a/apps/web/src/components/ActivityTable/TimeCell.tsx b/apps/web/src/components/ActivityTable/TimeCell.tsx new file mode 100644 index 00000000000..f90326cfbb8 --- /dev/null +++ b/apps/web/src/components/ActivityTable/TimeCell.tsx @@ -0,0 +1,15 @@ +import { TableText } from 'components/Table/styled' +import { useFormattedTimeForActivity } from 'uniswap/src/components/activity/hooks/useFormattedTime' + +interface TimeCellProps { + timestamp: number +} + +export function TimeCell({ timestamp }: TimeCellProps) { + const formattedTime = useFormattedTimeForActivity(timestamp) + return ( + + {formattedTime} + + ) +} diff --git a/apps/web/src/components/ActivityTable/TokenAmountDisplay.tsx b/apps/web/src/components/ActivityTable/TokenAmountDisplay.tsx new file mode 100644 index 00000000000..ef04e9384bf --- /dev/null +++ b/apps/web/src/components/ActivityTable/TokenAmountDisplay.tsx @@ -0,0 +1,32 @@ +import { TableText } from 'components/Table/styled' +import { Flex } from 'ui/src' +import { CurrencyLogo } from 'uniswap/src/components/CurrencyLogo/CurrencyLogo' +import { useCurrencyInfo } from 'uniswap/src/features/tokens/useCurrencyInfo' + +interface TokenAmountDisplayProps { + currencyInfo: ReturnType + formattedAmount: string | null + usdValue: string | null +} + +export function TokenAmountDisplay({ currencyInfo, formattedAmount, usdValue }: TokenAmountDisplayProps) { + if (!currencyInfo || !formattedAmount) { + return null + } + + return ( + + + + + {formattedAmount} + + {usdValue && ( + + {usdValue} + + )} + + + ) +} diff --git a/apps/web/src/components/ActivityTable/TransactionTypeCell.tsx b/apps/web/src/components/ActivityTable/TransactionTypeCell.tsx new file mode 100644 index 00000000000..5c3f52d05e7 --- /dev/null +++ b/apps/web/src/components/ActivityTable/TransactionTypeCell.tsx @@ -0,0 +1,30 @@ +import { buildActivityRowFragments } from 'components/ActivityTable/registry' +import { TableText } from 'components/Table/styled' +import { getTransactionTypeFilterOptions } from 'pages/Portfolio/Activity/Filters/utils' +import { useTranslation } from 'react-i18next' +import { Flex } from 'ui/src' +import { TransactionDetails } from 'uniswap/src/features/transactions/types/transactionDetails' + +interface TransactionTypeCellProps { + transaction: TransactionDetails +} + +export function TransactionTypeCell({ transaction }: TransactionTypeCellProps) { + const { t } = useTranslation() + const { typeLabel } = buildActivityRowFragments(transaction) + + // Get the icon from the filter options based on base group + const transactionTypeOptions = getTransactionTypeFilterOptions(t) + const typeOption = typeLabel?.baseGroup ? transactionTypeOptions[typeLabel.baseGroup] : null + const IconComponent = typeOption?.icon + + // Use override label key if provided, otherwise use the base group label + const label = typeLabel?.overrideLabelKey ? t(typeLabel.overrideLabelKey) : (typeOption?.label ?? 'Transaction') + + return ( + + {IconComponent && } + {label} + + ) +} diff --git a/apps/web/src/components/ActivityTable/activityTableModels.ts b/apps/web/src/components/ActivityTable/activityTableModels.ts new file mode 100644 index 00000000000..f1fc9d10af1 --- /dev/null +++ b/apps/web/src/components/ActivityTable/activityTableModels.ts @@ -0,0 +1,61 @@ +/** + * Models for activity table presentation layer. + * These types describe table-ready data from transaction parsers, without formatting or i18n. + * Each adapter returns raw IDs, amounts, addresses, and translation keys. + */ + +/** + * Represents the amount/token data for different transaction types + */ +type ActivityAmountModel = + | { + kind: 'pair' + inputCurrencyId: string + outputCurrencyId: string + inputAmountRaw?: string + outputAmountRaw?: string + } + | { + kind: 'single' + currencyId?: string + amountRaw?: string + } + | { + kind: 'approve' + currencyId?: string + approvalAmount?: string | 'INF' + } + | { + kind: 'wrap' + unwrapped: boolean + amountRaw?: string + } + | { + kind: 'liquidity-pair' + currency0Id: string + currency1Id: string + currency0AmountRaw: string + currency1AmountRaw?: string + } + +/** + * Represents the type label and grouping for a transaction + */ +interface ActivityTypeLabel { + /** Base group for filtering and icon mapping */ + baseGroup: 'swaps' | 'sent' | 'received' | 'deposits' | null + /** Optional override translation key for custom labels (e.g., "Wrapped"/"Unwrapped") */ + overrideLabelKey?: string +} + +/** + * Complete row data fragments for a single transaction in the activity table + */ +export interface ActivityRowFragments { + /** Amount/token data for the transaction */ + amount?: ActivityAmountModel | null + /** Counterparty address (sender/recipient/spender) */ + counterparty?: Address | null + /** Type label and grouping information */ + typeLabel?: ActivityTypeLabel | null +} diff --git a/apps/web/src/components/ActivityTable/registry.ts b/apps/web/src/components/ActivityTable/registry.ts new file mode 100644 index 00000000000..889b6d0451a --- /dev/null +++ b/apps/web/src/components/ActivityTable/registry.ts @@ -0,0 +1,246 @@ +import { UNI_ADDRESSES } from '@uniswap/sdk-core' +import { ActivityRowFragments } from 'components/ActivityTable/activityTableModels' +import { AssetType } from 'uniswap/src/entities/assets' +import { TransactionDetails, TransactionType } from 'uniswap/src/features/transactions/types/transactionDetails' +import { getValidAddress } from 'uniswap/src/utils/addresses' +import { buildCurrencyId } from 'uniswap/src/utils/currencyId' + +/** + * Builds activity row fragments for a transaction by mapping from parsed typeInfo. + * Returns empty object for unsupported transaction types. + * + * @param details - The transaction details with parsed typeInfo + * @returns Activity row fragments containing amount, counterparty, and type label data + */ +export function buildActivityRowFragments(details: TransactionDetails): ActivityRowFragments { + const { typeInfo, chainId } = details + + switch (typeInfo.type) { + case TransactionType.Swap: + return { + amount: { + kind: 'pair', + inputCurrencyId: typeInfo.inputCurrencyId, + outputCurrencyId: typeInfo.outputCurrencyId, + inputAmountRaw: 'inputCurrencyAmountRaw' in typeInfo ? typeInfo.inputCurrencyAmountRaw : undefined, + outputAmountRaw: 'outputCurrencyAmountRaw' in typeInfo ? typeInfo.outputCurrencyAmountRaw : undefined, + }, + counterparty: null, + typeLabel: { + baseGroup: 'swaps', + overrideLabelKey: 'transaction.status.swap.success', + }, + } + + case TransactionType.Bridge: + return { + amount: { + kind: 'pair', + inputCurrencyId: typeInfo.inputCurrencyId, + outputCurrencyId: typeInfo.outputCurrencyId, + inputAmountRaw: 'inputCurrencyAmountRaw' in typeInfo ? typeInfo.inputCurrencyAmountRaw : undefined, + outputAmountRaw: 'outputCurrencyAmountRaw' in typeInfo ? typeInfo.outputCurrencyAmountRaw : undefined, + }, + counterparty: null, + typeLabel: { + baseGroup: 'swaps', + }, + } + + case TransactionType.Send: { + const currencyId = + typeInfo.assetType === AssetType.Currency ? buildCurrencyId(chainId, typeInfo.tokenAddress) : undefined + + return { + amount: { + kind: 'single', + currencyId, + amountRaw: typeInfo.currencyAmountRaw, + }, + counterparty: typeInfo.recipient ? getValidAddress({ address: typeInfo.recipient, chainId }) : null, + typeLabel: { + baseGroup: 'sent', + }, + } + } + + case TransactionType.Receive: { + const currencyId = + typeInfo.assetType === AssetType.Currency ? buildCurrencyId(chainId, typeInfo.tokenAddress) : undefined + + return { + amount: { + kind: 'single', + currencyId, + amountRaw: typeInfo.currencyAmountRaw, + }, + counterparty: typeInfo.sender ? getValidAddress({ address: typeInfo.sender, chainId }) : null, + typeLabel: { + baseGroup: 'received', + }, + } + } + + case TransactionType.Approve: { + const currencyId = buildCurrencyId(chainId, typeInfo.tokenAddress) + + return { + amount: { + kind: 'approve', + currencyId, + approvalAmount: typeInfo.approvalAmount, + }, + counterparty: typeInfo.spender ? getValidAddress({ address: typeInfo.spender, chainId }) : null, + typeLabel: { + baseGroup: null, + overrideLabelKey: 'common.approved', + }, + } + } + + case TransactionType.Wrap: + return { + amount: { + kind: 'wrap', + unwrapped: typeInfo.unwrapped, + amountRaw: typeInfo.currencyAmountRaw, + }, + counterparty: null, + typeLabel: { + baseGroup: 'swaps', + overrideLabelKey: typeInfo.unwrapped ? 'common.unwrapped' : 'common.wrapped', + }, + } + + case TransactionType.CreatePool: + case TransactionType.CreatePair: + return { + amount: { + kind: 'liquidity-pair', + currency0Id: typeInfo.currency0Id, + currency1Id: typeInfo.currency1Id, + currency0AmountRaw: typeInfo.currency0AmountRaw, + currency1AmountRaw: typeInfo.currency1AmountRaw, + }, + counterparty: typeInfo.dappInfo?.address + ? getValidAddress({ address: typeInfo.dappInfo.address, chainId }) + : null, + typeLabel: { + baseGroup: null, + overrideLabelKey: 'pool.create', + }, + } + + case TransactionType.LiquidityIncrease: + return { + amount: { + kind: 'liquidity-pair', + currency0Id: typeInfo.currency0Id, + currency1Id: typeInfo.currency1Id, + currency0AmountRaw: typeInfo.currency0AmountRaw, + currency1AmountRaw: typeInfo.currency1AmountRaw, + }, + counterparty: typeInfo.dappInfo?.address + ? getValidAddress({ address: typeInfo.dappInfo.address, chainId }) + : null, + typeLabel: { + baseGroup: 'deposits', + overrideLabelKey: 'common.addLiquidity', + }, + } + + case TransactionType.LiquidityDecrease: + return { + amount: { + kind: 'liquidity-pair', + currency0Id: typeInfo.currency0Id, + currency1Id: typeInfo.currency1Id, + currency0AmountRaw: typeInfo.currency0AmountRaw, + currency1AmountRaw: typeInfo.currency1AmountRaw, + }, + counterparty: typeInfo.dappInfo?.address + ? getValidAddress({ address: typeInfo.dappInfo.address, chainId }) + : null, + typeLabel: { + baseGroup: null, + overrideLabelKey: 'pool.removeLiquidity', + }, + } + + case TransactionType.NFTMint: { + const currencyId = typeInfo.purchaseCurrencyId + return { + amount: { + kind: 'single', + currencyId, + amountRaw: typeInfo.purchaseCurrencyAmountRaw, + }, + counterparty: typeInfo.dappInfo?.address + ? getValidAddress({ address: typeInfo.dappInfo.address, chainId }) + : null, + typeLabel: { + baseGroup: null, + overrideLabelKey: 'transaction.status.mint.success', + }, + } + } + + case TransactionType.CollectFees: + return { + amount: typeInfo.currency1Id + ? { + kind: 'liquidity-pair', + currency0Id: typeInfo.currency0Id, + currency1Id: typeInfo.currency1Id, + currency0AmountRaw: typeInfo.currency0AmountRaw, + currency1AmountRaw: typeInfo.currency1AmountRaw, + } + : { + kind: 'single', + currencyId: typeInfo.currency0Id, + amountRaw: typeInfo.currency0AmountRaw, + }, + counterparty: null, + typeLabel: { + baseGroup: null, + overrideLabelKey: 'transaction.status.collected.fees', + }, + } + + case TransactionType.LPIncentivesClaimRewards: { + const currencyId = buildCurrencyId(chainId, typeInfo.tokenAddress) + return { + amount: { + kind: 'single', + currencyId, + amountRaw: undefined, + }, + counterparty: null, + typeLabel: { + baseGroup: null, + overrideLabelKey: 'transaction.status.collected.fees', + }, + } + } + + case TransactionType.ClaimUni: { + const tokenAddress = UNI_ADDRESSES[chainId] + const currencyId = tokenAddress ? buildCurrencyId(chainId, tokenAddress) : undefined + return { + amount: { + kind: 'single', + currencyId, + amountRaw: typeInfo.uniAmountRaw, + }, + counterparty: getValidAddress({ address: typeInfo.recipient, chainId }), + typeLabel: { + baseGroup: null, + overrideLabelKey: 'common.claimed', + }, + } + } + + default: + return {} + } +} diff --git a/apps/web/src/components/Banner/BridgingPopularTokens/BridgingPopularTokensBanner.tsx b/apps/web/src/components/Banner/BridgingPopularTokens/BridgingPopularTokensBanner.tsx new file mode 100644 index 00000000000..2957dd9ef47 --- /dev/null +++ b/apps/web/src/components/Banner/BridgingPopularTokens/BridgingPopularTokensBanner.tsx @@ -0,0 +1,108 @@ +import { SharedEventName } from '@uniswap/analytics-events' +import { useCallback } from 'react' +import { useTranslation } from 'react-i18next' +import { useNavigate } from 'react-router' +import { useAppDispatch } from 'state/hooks' +import { Flex, IconButton, Image, styled, Text, TouchableArea } from 'ui/src' +import { BRIDGED_ASSETS_V2_WEB_BANNER } from 'ui/src/assets' +import { X } from 'ui/src/components/icons/X' +import { useUniswapContext } from 'uniswap/src/contexts/UniswapContext' +import { setHasDismissedBridgedAssetsBannerV2 } from 'uniswap/src/features/behaviorHistory/slice' +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 { Trace } from 'uniswap/src/features/telemetry/Trace' + +const BRIDGING_POPULAR_TOKENS_BANNER_HEIGHT = 152 +const GRADIENT_BACKGROUND_HEIGHT = 64 +const BANNER_PADDING = 16 + +const BannerContainer = styled(TouchableArea, { + borderRadius: '$rounded16', + width: 260, + height: BRIDGING_POPULAR_TOKENS_BANNER_HEIGHT, + shadowColor: '$shadowColor', + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.4, + shadowRadius: 10, + overflow: 'hidden', + padding: BANNER_PADDING, + backgroundColor: '$surface1', + borderWidth: 1, + borderColor: '$surface3', + gap: '$spacing16', + + '$platform-web': { + position: 'fixed', + bottom: 29, + left: 40, + }, +}) + +export function BridgingPopularTokensBanner() { + const dispatch = useAppDispatch() + const { t } = useTranslation() + const navigate = useNavigate() + const { setIsSwapTokenSelectorOpen, setSwapOutputChainId } = useUniswapContext() + + const handleBannerClose = useCallback(() => { + dispatch(setHasDismissedBridgedAssetsBannerV2(true)) + sendAnalyticsEvent(SharedEventName.ELEMENT_CLICKED, { + element: ElementName.CloseButton, + modal: ElementName.BridgedAssetsBannerV2, + }) + }, [dispatch]) + + const handleBannerClick = useCallback(() => { + navigate('/swap?outputChain=unichain') + setSwapOutputChainId(UniverseChainId.Unichain) + setIsSwapTokenSelectorOpen(true) + dispatch(setHasDismissedBridgedAssetsBannerV2(true)) + }, [dispatch, navigate, setIsSwapTokenSelectorOpen, setSwapOutputChainId]) + + return ( + + + + + + + + + {t('onboarding.home.intro.bridgedAssets.title')} + + + {t('bridgingPopularTokens.banner.description')} + + + + + ) +} + +function BannerXButton({ handleClose }: { handleClose: () => void }) { + return ( + + { + e.stopPropagation() + handleClose() + }} + hoverStyle={{ opacity: 0.8 }} + icon={} + p={2} + /> + + ) +} diff --git a/apps/web/src/components/Banner/shared/Banners.tsx b/apps/web/src/components/Banner/shared/Banners.tsx index 9ccc4ece9a5..5e16d747a68 100644 --- a/apps/web/src/components/Banner/shared/Banners.tsx +++ b/apps/web/src/components/Banner/shared/Banners.tsx @@ -1,12 +1,13 @@ import { manualChainOutageAtom, useChainOutageConfig } from 'featureFlags/flags/outageBanner' +import { FeatureFlags, useFeatureFlag } from '@universe/gating' +import { BridgingPopularTokensBanner } from 'components/Banner/BridgingPopularTokens/BridgingPopularTokensBanner' import { getOutageBannerSessionStorageKey, OutageBanner } from 'components/Banner/Outage/OutageBanner' import { SOLANA_PROMO_BANNER_STORAGE_KEY, SolanaPromoBanner } from 'components/Banner/SolanaPromo/SolanaPromoBanner' import { useAtomValue } from 'jotai/utils' import { useMemo } from 'react' import { useLocation } from 'react-router' +import { useAppSelector } from 'state/hooks' import { UniverseChainId } from 'uniswap/src/features/chains/types' -import { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' import { InterfacePageName } from 'uniswap/src/features/telemetry/constants' import { getChainIdFromChainUrlParam, isChainUrlParam } from 'utils/chainParams' import { getCurrentPageFromLocation } from 'utils/urlRoutes' @@ -15,6 +16,10 @@ export function Banners() { const { pathname } = useLocation() const currentPage = getCurrentPageFromLocation(pathname) const isSolanaPromoEnabled = useFeatureFlag(FeatureFlags.SolanaPromo) + const isBridgedAssetsBannerV2Enabled = useFeatureFlag(FeatureFlags.BridgedAssetsBannerV2) + const hasDismissedBridgedAssetsBannerV2 = useAppSelector( + (state) => state.uniswapBehaviorHistory.hasDismissedBridgedAssetsBannerV2, + ) // Read from both sources: error-detected (from GraphQL failures) and Statsig (manual config) const statsigOutage = useChainOutageConfig() @@ -55,5 +60,9 @@ export function Banners() { return } + if (isBridgedAssetsBannerV2Enabled && !hasDismissedBridgedAssetsBannerV2) { + return + } + return null } diff --git a/apps/web/src/components/Charts/D3LiquidityRangeInput/D3LiquidityRangeChart/D3LiquidityRangeChart.tsx b/apps/web/src/components/Charts/D3LiquidityRangeInput/D3LiquidityRangeChart/D3LiquidityRangeChart.tsx index 685f409f924..33afc62d804 100644 --- a/apps/web/src/components/Charts/D3LiquidityRangeInput/D3LiquidityRangeChart/D3LiquidityRangeChart.tsx +++ b/apps/web/src/components/Charts/D3LiquidityRangeInput/D3LiquidityRangeChart/D3LiquidityRangeChart.tsx @@ -12,6 +12,7 @@ import { ChartEntry } from 'components/Charts/LiquidityRangeInput/types' import { PriceChartData } from 'components/Charts/PriceChart' import { ChartType } from 'components/Charts/utils' import { useLiquidityUrlState } from 'components/Liquidity/Create/hooks/useLiquidityUrlState' +import { InitialPosition } from 'components/Liquidity/Create/types' import { ChartQueryResult } from 'components/Tokens/TokenDetails/ChartSection/util' import * as d3 from 'd3' import { useEffect, useMemo, useRef } from 'react' @@ -22,11 +23,13 @@ const D3LiquidityRangeChart = ({ liquidityData, quoteCurrency, baseCurrency, + initialPosition, }: { priceData: ChartQueryResult liquidityData: ChartEntry[] quoteCurrency: Currency baseCurrency: Currency + initialPosition?: InitialPosition }) => { const colors = useSporeColors() const svgRef = useRef(null) @@ -125,6 +128,11 @@ const D3LiquidityRangeChart = ({ useEffect(() => { let minPrice let maxPrice + + if (initialPosition) { + return + } + if (priceRangeState.minPrice && !isNaN(parseFloat(priceRangeState.minPrice))) { minPrice = parseFloat(priceRangeState.minPrice) } @@ -136,7 +144,7 @@ const D3LiquidityRangeChart = ({ minPrice, maxPrice, }) - }, [priceData.dataHash, reset]) + }, [priceData.dataHash, initialPosition, reset]) return ( diff --git a/apps/web/src/components/Charts/D3LiquidityRangeInput/D3LiquidityRangeChart/components/D3LiquidityMinMaxInput.tsx b/apps/web/src/components/Charts/D3LiquidityRangeInput/D3LiquidityRangeChart/components/D3LiquidityMinMaxInput.tsx index ee3695042fb..1e0723c11af 100644 --- a/apps/web/src/components/Charts/D3LiquidityRangeInput/D3LiquidityRangeChart/components/D3LiquidityMinMaxInput.tsx +++ b/apps/web/src/components/Charts/D3LiquidityRangeInput/D3LiquidityRangeChart/components/D3LiquidityMinMaxInput.tsx @@ -92,15 +92,26 @@ export function D3LiquidityMinMaxInput() { } const price = input === RangeSelectionInput.MIN ? minPrice : maxPrice - if (input === RangeSelectionInput.MIN && ticksAtLimit[0]) { + + if (input === RangeSelectionInput.MIN && ticksAtLimit[0] && !positionState.initialPosition) { return '0' } - if (input === RangeSelectionInput.MAX && ticksAtLimit[1]) { + if (input === RangeSelectionInput.MAX && ticksAtLimit[1] && !positionState.initialPosition) { return '∞' } + return price?.toString() ?? '' }, - [displayUserTypedValue, typedValue, inputMode, priceDifferences, minPrice, maxPrice, ticksAtLimit], + [ + displayUserTypedValue, + typedValue, + inputMode, + priceDifferences, + minPrice, + maxPrice, + ticksAtLimit, + positionState.initialPosition, + ], ) // Sets chart state but does not update liquidity context diff --git a/apps/web/src/components/Charts/D3LiquidityRangeInput/D3LiquidityRangeChart/store/types.ts b/apps/web/src/components/Charts/D3LiquidityRangeInput/D3LiquidityRangeChart/store/types.ts index 4efa9bf8fd5..d6eec00d3de 100644 --- a/apps/web/src/components/Charts/D3LiquidityRangeInput/D3LiquidityRangeChart/store/types.ts +++ b/apps/web/src/components/Charts/D3LiquidityRangeInput/D3LiquidityRangeChart/store/types.ts @@ -1,4 +1,4 @@ -import { ProtocolVersion } from '@uniswap/client-pools/dist/pools/v1/types_pb' +import { ProtocolVersion } from '@uniswap/client-data-api/dist/data/v1/poolTypes_pb' import { Currency } from '@uniswap/sdk-core' import { GraphQLApi } from '@universe/api' import { TickAlignment } from 'components/Charts/D3LiquidityRangeInput/D3LiquidityRangeChart/utils/priceToY' diff --git a/apps/web/src/components/Charts/D3LiquidityRangeInput/D3LiquidityRangeChart/utils/tickUtils.ts b/apps/web/src/components/Charts/D3LiquidityRangeInput/D3LiquidityRangeChart/utils/tickUtils.ts index a3385a91640..5b7eb089cf3 100644 --- a/apps/web/src/components/Charts/D3LiquidityRangeInput/D3LiquidityRangeChart/utils/tickUtils.ts +++ b/apps/web/src/components/Charts/D3LiquidityRangeInput/D3LiquidityRangeChart/utils/tickUtils.ts @@ -1,4 +1,4 @@ -import { ProtocolVersion } from '@uniswap/client-pools/dist/pools/v1/types_pb' +import { ProtocolVersion } from '@uniswap/client-data-api/dist/data/v1/poolTypes_pb' import { nearestUsableTick, priceToClosestTick, TickMath, tickToPrice as tickToPriceV3 } from '@uniswap/v3-sdk' import { priceToClosestTick as priceToClosestV4Tick, tickToPrice as tickToPriceV4 } from '@uniswap/v4-sdk' import { TickNavigationParams } from 'components/Charts/D3LiquidityRangeInput/D3LiquidityRangeChart/store/types' diff --git a/apps/web/src/components/Charts/D3LiquidityRangeInput/D3LiquidityRangeInput.tsx b/apps/web/src/components/Charts/D3LiquidityRangeInput/D3LiquidityRangeInput.tsx index 205c72ed202..71128df63fe 100644 --- a/apps/web/src/components/Charts/D3LiquidityRangeInput/D3LiquidityRangeInput.tsx +++ b/apps/web/src/components/Charts/D3LiquidityRangeInput/D3LiquidityRangeInput.tsx @@ -1,4 +1,4 @@ -import { ProtocolVersion } from '@uniswap/client-pools/dist/pools/v1/types_pb' +import { ProtocolVersion } from '@uniswap/client-data-api/dist/data/v1/poolTypes_pb' import { Currency, Price } from '@uniswap/sdk-core' import { GraphQLApi } from '@universe/api' import { D3LiquidityChartHeader } from 'components/Charts/D3LiquidityRangeInput/D3LiquidityRangeChart/components/D3LiquidityChartHeader' @@ -13,7 +13,7 @@ import { ChartEntry } from 'components/Charts/LiquidityRangeInput/types' import { ChartSkeleton } from 'components/Charts/LoadingState' import { PriceChartData } from 'components/Charts/PriceChart' import { ChartType } from 'components/Charts/utils' -import { RangeAmountInputPriceMode } from 'components/Liquidity/Create/types' +import { InitialPosition, RangeAmountInputPriceMode } from 'components/Liquidity/Create/types' import { usePoolPriceChartData } from 'hooks/usePoolPriceChartData' import { UTCTimestamp } from 'lightweight-charts' import { useMemo, useState } from 'react' @@ -42,6 +42,7 @@ export function D3LiquidityRangeInput({ price, hook, currentPrice, + initialPosition, isFullRange, minPrice, maxPrice, @@ -73,6 +74,7 @@ export function D3LiquidityRangeInput({ minPrice?: number maxPrice?: number inputMode?: RangeAmountInputPriceMode + initialPosition?: InitialPosition setInputMode: (inputMode: RangeAmountInputPriceMode) => void setMinPrice: (minPrice?: number | null) => void setMaxPrice: (maxPrice?: number | null) => void @@ -194,6 +196,7 @@ export function D3LiquidityRangeInput({ baseCurrency={baseCurrency} priceData={finalPriceData} liquidityData={sortedLiquidityData} + initialPosition={initialPosition} /> ) : ( diff --git a/apps/web/src/components/Charts/LiquidityChart/index.tsx b/apps/web/src/components/Charts/LiquidityChart/index.tsx index cf4dfbf7f2a..b5aff745efb 100644 --- a/apps/web/src/components/Charts/LiquidityChart/index.tsx +++ b/apps/web/src/components/Charts/LiquidityChart/index.tsx @@ -1,5 +1,5 @@ import { BigNumber } from '@ethersproject/bignumber' -import { ProtocolVersion } from '@uniswap/client-pools/dist/pools/v1/types_pb' +import { ProtocolVersion } from '@uniswap/client-data-api/dist/data/v1/poolTypes_pb' import { Currency, CurrencyAmount, Token } from '@uniswap/sdk-core' import { FeeAmount, Pool as PoolV3, TICK_SPACINGS, TickMath as TickMathV3, tickToPrice } from '@uniswap/v3-sdk' import { Pool as PoolV4, tickToPrice as tickToPriceV4 } from '@uniswap/v4-sdk' diff --git a/apps/web/src/components/Charts/LiquidityPositionRangeChart/LiquidityPositionRangeChart.tsx b/apps/web/src/components/Charts/LiquidityPositionRangeChart/LiquidityPositionRangeChart.tsx index f22dce5006c..e820c97f067 100644 --- a/apps/web/src/components/Charts/LiquidityPositionRangeChart/LiquidityPositionRangeChart.tsx +++ b/apps/web/src/components/Charts/LiquidityPositionRangeChart/LiquidityPositionRangeChart.tsx @@ -1,4 +1,4 @@ -import { PositionStatus, ProtocolVersion } from '@uniswap/client-pools/dist/pools/v1/types_pb' +import { PositionStatus, ProtocolVersion } from '@uniswap/client-data-api/dist/data/v1/poolTypes_pb' import { Currency, Price } from '@uniswap/sdk-core' import { Pair } from '@uniswap/v2-sdk' import { FeeAmount, Pool as V3Pool } from '@uniswap/v3-sdk' diff --git a/apps/web/src/components/Charts/LiquidityRangeInput/LiquidityRangeInput.tsx b/apps/web/src/components/Charts/LiquidityRangeInput/LiquidityRangeInput.tsx index af38a075534..6ad03dc900c 100644 --- a/apps/web/src/components/Charts/LiquidityRangeInput/LiquidityRangeInput.tsx +++ b/apps/web/src/components/Charts/LiquidityRangeInput/LiquidityRangeInput.tsx @@ -1,4 +1,4 @@ -import { ProtocolVersion } from '@uniswap/client-pools/dist/pools/v1/types_pb' +import { ProtocolVersion } from '@uniswap/client-data-api/dist/data/v1/poolTypes_pb' import { Currency } from '@uniswap/sdk-core' import { GraphQLApi } from '@universe/api' import { ActiveLiquidityChart } from 'components/Charts/ActiveLiquidityChart/ActiveLiquidityChart' diff --git a/apps/web/src/components/Charts/LiquidityRangeInput/hooks.ts b/apps/web/src/components/Charts/LiquidityRangeInput/hooks.ts index 907bb46ba51..22f595d3efc 100644 --- a/apps/web/src/components/Charts/LiquidityRangeInput/hooks.ts +++ b/apps/web/src/components/Charts/LiquidityRangeInput/hooks.ts @@ -1,5 +1,5 @@ import { useQuery } from '@tanstack/react-query' -import { ProtocolVersion } from '@uniswap/client-pools/dist/pools/v1/types_pb' +import { ProtocolVersion } from '@uniswap/client-data-api/dist/data/v1/poolTypes_pb' import { Currency } from '@uniswap/sdk-core' import { calculateTokensLockedV3, calculateTokensLockedV4 } from 'components/Charts/LiquidityChart' import { ChartEntry } from 'components/Charts/LiquidityRangeInput/types' diff --git a/apps/web/src/components/Charts/PriceChart/index.tsx b/apps/web/src/components/Charts/PriceChart/index.tsx index f3767252cd6..2313ca3d480 100644 --- a/apps/web/src/components/Charts/PriceChart/index.tsx +++ b/apps/web/src/components/Charts/PriceChart/index.tsx @@ -25,6 +25,7 @@ import { Trans } from 'react-i18next' import { Flex, styled, Text } from 'ui/src' import { opacify } from 'ui/src/theme' import { isLowVarianceRange } from 'uniswap/src/components/charts/utils' +import { useFormatChartFiatDelta } from 'uniswap/src/features/fiatCurrency/hooks/useFormatChartFiatDelta' import { useLocalizationContext } from 'uniswap/src/features/language/LocalizationContext' import { NumberType } from 'utilities/src/format/types' @@ -230,19 +231,54 @@ export class PriceChartModel extends ChartModel { } interface PriceChartDeltaProps { - startingPrice: PriceChartData - endingPrice: PriceChartData + startingPrice: number + endingPrice: number noColor?: boolean + shouldIncludeFiatDelta?: boolean + shouldTreatAsStablecoin?: boolean } -export function PriceChartDelta({ startingPrice, endingPrice, noColor }: PriceChartDeltaProps) { - const delta = calculateDelta(startingPrice.close, endingPrice.close) - const { formatPercent } = useLocalizationContext() +export function PriceChartDelta({ + startingPrice, + endingPrice, + noColor, + shouldIncludeFiatDelta = false, + shouldTreatAsStablecoin = false, +}: PriceChartDeltaProps) { + const { formatPercent, convertFiatAmount } = useLocalizationContext() + const { formatChartFiatDelta } = useFormatChartFiatDelta() + + const delta = calculateDelta(startingPrice, endingPrice) + const formattedDelta = useMemo(() => { + return delta !== undefined ? formatPercent(Math.abs(delta)) : '-' + }, [delta, formatPercent]) + + const fiatDelta = useMemo(() => { + if (!shouldIncludeFiatDelta) { + return null + } + + const convertedStart = convertFiatAmount(startingPrice) + const convertedEnd = convertFiatAmount(endingPrice) + + return formatChartFiatDelta({ + startingPrice: convertedStart.amount, + endingPrice: convertedEnd.amount, + isStablecoin: shouldTreatAsStablecoin, + }) + }, [ + shouldIncludeFiatDelta, + formatChartFiatDelta, + startingPrice, + endingPrice, + convertFiatAmount, + shouldTreatAsStablecoin, + ]) return ( - {delta && } - {delta ? formatPercent(Math.abs(delta)) : '-'} + {delta !== undefined && } + {fiatDelta ? `${fiatDelta.formatted} (${formattedDelta})` : formattedDelta} ) } @@ -288,7 +324,14 @@ function CandlestickTooltip({ data }: { data: PriceChartData }) { } export function PriceChart({ data, height, type, stale, timePeriod }: PriceChartProps) { + const startingPrice = data[0] const lastPrice = data[data.length - 1] + const { min, max } = getCandlestickPriceBounds(data) + const shouldTreatAsStablecoin = isLowVarianceRange({ + min, + max, + duration: timePeriod, + }) return ( ( } + additionalFields={ + + } valueFormatterType={NumberType.FiatTokenPrice} time={crosshairData?.time} /> diff --git a/apps/web/src/components/Expand/index.tsx b/apps/web/src/components/Expand/index.tsx index aa3303a3209..d9831b552f8 100644 --- a/apps/web/src/components/Expand/index.tsx +++ b/apps/web/src/components/Expand/index.tsx @@ -1,31 +1,8 @@ -import Column from 'components/deprecated/Column' -import Row, { RowBetween } from 'components/deprecated/Row' -import styled from 'lib/styled-components' import { PropsWithChildren, ReactElement } from 'react' -import { ChevronDown } from 'react-feather' -import { HeightAnimator } from 'ui/src' +import { Flex, FlexProps, HeightAnimator } from 'ui/src' +import { RotatableChevron } from 'ui/src/components/icons/RotatableChevron' import { iconSizes } from 'ui/src/theme' -const ButtonContainer = styled(Row)` - cursor: pointer; - justify-content: flex-end; - width: unset; -` - -const ExpandIcon = styled(ChevronDown)<{ $isOpen: boolean }>` - color: ${({ theme }) => theme.neutral2}; - transform: ${({ $isOpen }) => ($isOpen ? 'rotate(180deg)' : 'rotate(0deg)')}; - transition: transform ${({ theme }) => theme.transition.duration.medium}; -` - -const Content = styled(Column)` - padding-top: ${({ theme }) => theme.grids.md}; -` - -const Wrapper = styled(Column)<{ $padding?: string }>` - padding: ${({ $padding }) => $padding}; -` - export default function Expand({ header, button, @@ -35,27 +12,41 @@ export default function Expand({ padding, onToggle, iconSize = 'icon24', + paddingTop, + width, }: PropsWithChildren<{ header?: ReactElement button: ReactElement testId?: string isOpen: boolean - padding?: string + padding?: FlexProps['p'] onToggle: () => void iconSize?: keyof typeof iconSizes + paddingTop?: FlexProps['pt'] + width?: FlexProps['width'] }>) { return ( - - + + {header} - + {button} - - - + + + - {children} + + {children} + - + ) } diff --git a/apps/web/src/components/FeatureFlagModal/FeatureFlagModal.tsx b/apps/web/src/components/FeatureFlagModal/FeatureFlagModal.tsx index d419cc0f67d..0b3d9913486 100644 --- a/apps/web/src/components/FeatureFlagModal/FeatureFlagModal.tsx +++ b/apps/web/src/components/FeatureFlagModal/FeatureFlagModal.tsx @@ -1,3 +1,15 @@ +import type { DynamicConfigKeys } from '@universe/gating' +import { + DynamicConfigs, + Experiments, + ExternallyConnectableExtensionConfigKey, + FeatureFlags, + getFeatureFlagName, + getOverrideAdapter, + Layers, + NetworkRequestsConfigKey, + useFeatureFlagWithExposureLoggingDisabled, +} from '@universe/gating' import { useModalState } from 'hooks/useModalState' import styledDep from 'lib/styled-components' import { useExternallyConnectableExtensionId } from 'pages/ExtensionPasskeyAuthPopUp/useExternallyConnectableExtensionId' @@ -6,16 +18,6 @@ import { useCallback } from 'react' import { Button, Flex, ModalCloseIcon, styled, Text } from 'ui/src' import { ExperimentRow, LayerRow } from 'uniswap/src/components/gating/Rows' import { Modal } from 'uniswap/src/components/modals/Modal' -import type { DynamicConfigKeys } from 'uniswap/src/features/gating/configs' -import { - DynamicConfigs, - ExternallyConnectableExtensionConfigKey, - NetworkRequestsConfigKey, -} from 'uniswap/src/features/gating/configs' -import { Experiments, Layers } from 'uniswap/src/features/gating/experiments' -import { FeatureFlags, getFeatureFlagName } from 'uniswap/src/features/gating/flags' -import { useFeatureFlagWithExposureLoggingDisabled } from 'uniswap/src/features/gating/hooks' -import { getOverrideAdapter } from 'uniswap/src/features/gating/sdk/statsig' import { ModalName } from 'uniswap/src/features/telemetry/constants' import { isPlaywrightEnv } from 'utilities/src/environment/env' import { TRUSTED_CHROME_EXTENSION_IDS } from 'utilities/src/environment/extensionId' @@ -276,6 +278,7 @@ export default function FeatureFlagModal() { + @@ -286,6 +289,7 @@ export default function FeatureFlagModal() { + diff --git a/apps/web/src/components/HelpModal/HelpContent.tsx b/apps/web/src/components/HelpModal/HelpContent.tsx index b67856643ae..4e3332c04b6 100644 --- a/apps/web/src/components/HelpModal/HelpContent.tsx +++ b/apps/web/src/components/HelpModal/HelpContent.tsx @@ -32,7 +32,8 @@ export function HelpContent({ onClose }: HelpContentProps) { return ( - setIsOpen(open)}> + setIsOpen(open)} + > - + + + setIsOpen(false)} /> diff --git a/apps/web/src/components/Liquidity/ClaimFeeModal.tsx b/apps/web/src/components/Liquidity/ClaimFeeModal.tsx index 330fceecad5..bd924749dad 100644 --- a/apps/web/src/components/Liquidity/ClaimFeeModal.tsx +++ b/apps/web/src/components/Liquidity/ClaimFeeModal.tsx @@ -1,4 +1,4 @@ -import { ProtocolVersion } from '@uniswap/client-pools/dist/pools/v1/types_pb' +import { ProtocolVersion } from '@uniswap/client-data-api/dist/data/v1/poolTypes_pb' import { CurrencyAmount } from '@uniswap/sdk-core' import { TradingApi } from '@universe/api' import { ErrorCallout } from 'components/ErrorCallout' diff --git a/apps/web/src/components/Liquidity/Create/AddHook.tsx b/apps/web/src/components/Liquidity/Create/AddHook.tsx index 2620a7cf2e9..506d1aa9dc6 100644 --- a/apps/web/src/components/Liquidity/Create/AddHook.tsx +++ b/apps/web/src/components/Liquidity/Create/AddHook.tsx @@ -1,4 +1,4 @@ -import { ProtocolVersion } from '@uniswap/client-pools/dist/pools/v1/types_pb' +import { ProtocolVersion } from '@uniswap/client-data-api/dist/data/v1/poolTypes_pb' import { AdvancedButton } from 'components/Liquidity/Create/AdvancedButton' import { useLiquidityUrlState } from 'components/Liquidity/Create/hooks/useLiquidityUrlState' import { DEFAULT_POSITION_STATE } from 'components/Liquidity/Create/types' diff --git a/apps/web/src/components/Liquidity/Create/EditStep.tsx b/apps/web/src/components/Liquidity/Create/EditStep.tsx index bd96f6af8bc..ba5d25fd7c7 100644 --- a/apps/web/src/components/Liquidity/Create/EditStep.tsx +++ b/apps/web/src/components/Liquidity/Create/EditStep.tsx @@ -1,4 +1,4 @@ -import { ProtocolVersion } from '@uniswap/client-pools/dist/pools/v1/types_pb' +import { ProtocolVersion } from '@uniswap/client-data-api/dist/data/v1/poolTypes_pb' import CreatingPoolInfo from 'components/CreatingPoolInfo/CreatingPoolInfo' import { useDefaultInitialPrice } from 'components/Liquidity/Create/hooks/useDefaultInitialPrice' import { PositionFlowStep } from 'components/Liquidity/Create/types' diff --git a/apps/web/src/components/Liquidity/Create/FormWrapper.tsx b/apps/web/src/components/Liquidity/Create/FormWrapper.tsx index 8a172f68a51..07713ff025b 100644 --- a/apps/web/src/components/Liquidity/Create/FormWrapper.tsx +++ b/apps/web/src/components/Liquidity/Create/FormWrapper.tsx @@ -1,4 +1,4 @@ -import { ProtocolVersion } from '@uniswap/client-pools/dist/pools/v1/types_pb' +import { ProtocolVersion } from '@uniswap/client-data-api/dist/data/v1/poolTypes_pb' import { Currency } from '@uniswap/sdk-core' import { BreadcrumbNavContainer, BreadcrumbNavLink } from 'components/BreadcrumbNav' import { Container } from 'components/Liquidity/Create/Container' diff --git a/apps/web/src/components/Liquidity/Create/PositionOutOfRangeError.tsx b/apps/web/src/components/Liquidity/Create/PositionOutOfRangeError.tsx index b5e243de478..76aab9ca1cc 100644 --- a/apps/web/src/components/Liquidity/Create/PositionOutOfRangeError.tsx +++ b/apps/web/src/components/Liquidity/Create/PositionOutOfRangeError.tsx @@ -1,4 +1,4 @@ -import { PositionStatus } from '@uniswap/client-pools/dist/pools/v1/types_pb' +import { PositionStatus } from '@uniswap/client-data-api/dist/data/v1/poolTypes_pb' import { ErrorCallout } from 'components/ErrorCallout' import { PositionInfo } from 'components/Liquidity/types' import { useTranslation } from 'react-i18next' diff --git a/apps/web/src/components/Liquidity/Create/RangeSelectionStep.tsx b/apps/web/src/components/Liquidity/Create/RangeSelectionStep.tsx index 80c51c9c44e..7ad8a3b54b9 100644 --- a/apps/web/src/components/Liquidity/Create/RangeSelectionStep.tsx +++ b/apps/web/src/components/Liquidity/Create/RangeSelectionStep.tsx @@ -1,4 +1,5 @@ -import { ProtocolVersion } from '@uniswap/client-pools/dist/pools/v1/types_pb' +import { ProtocolVersion } from '@uniswap/client-data-api/dist/data/v1/poolTypes_pb' +import { FeatureFlags, useFeatureFlag } from '@universe/gating' import { D3LiquidityRangeInput } from 'components/Charts/D3LiquidityRangeInput/D3LiquidityRangeInput' import { LiquidityRangeInput } from 'components/Charts/LiquidityRangeInput/LiquidityRangeInput' import { useDefaultInitialPrice } from 'components/Liquidity/Create/hooks/useDefaultInitialPrice' @@ -23,8 +24,6 @@ import { AlertTriangleFilled } from 'ui/src/components/icons/AlertTriangleFilled import { fonts, zIndexes } from 'ui/src/theme' import { AmountInput } from 'uniswap/src/components/AmountInput/AmountInput' import { WarningSeverity } from 'uniswap/src/components/modals/WarningModal/types' -import { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' enum RangeSelection { FULL = 'FULL', @@ -535,6 +534,7 @@ export const SelectPriceRangeStep = ({ price={price} currentPrice={Number(price?.toSignificant())} inputMode={priceRangeState.inputMode} + initialPosition={initialPosition} minPrice={rangeInputMinPrice} maxPrice={rangeInputMaxPrice} isFullRange={priceRangeState.fullRange} diff --git a/apps/web/src/components/Liquidity/Create/SelectTokenStep.tsx b/apps/web/src/components/Liquidity/Create/SelectTokenStep.tsx index 86470371e61..95762e764d7 100644 --- a/apps/web/src/components/Liquidity/Create/SelectTokenStep.tsx +++ b/apps/web/src/components/Liquidity/Create/SelectTokenStep.tsx @@ -1,6 +1,13 @@ import { PrefetchBalancesWrapper } from 'appGraphql/data/apollo/AdaptiveTokenBalancesProvider' -import { ProtocolVersion } from '@uniswap/client-pools/dist/pools/v1/types_pb' +import { ProtocolVersion } from '@uniswap/client-data-api/dist/data/v1/poolTypes_pb' import type { Currency, Percent } from '@uniswap/sdk-core' +import { + AllowedV4WethHookAddressesConfigKey, + DynamicConfigs, + FeatureFlags, + useDynamicConfigValue, + useFeatureFlag, +} from '@universe/gating' import CreatingPoolInfo from 'components/CreatingPoolInfo/CreatingPoolInfo' import { ErrorCallout } from 'components/ErrorCallout' import { AddHook } from 'components/Liquidity/Create/AddHook' @@ -41,9 +48,6 @@ import { useUrlContext } from 'uniswap/src/contexts/UrlContext' import { useEnabledChains } from 'uniswap/src/features/chains/hooks/useEnabledChains' import { UniverseChainId } from 'uniswap/src/features/chains/types' import type { CurrencyInfo } from 'uniswap/src/features/dataApi/types' -import { AllowedV4WethHookAddressesConfigKey, DynamicConfigs } from 'uniswap/src/features/gating/configs' -import { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { useDynamicConfigValue, useFeatureFlag } from 'uniswap/src/features/gating/hooks' import { useLocalizationContext } from 'uniswap/src/features/language/LocalizationContext' import { Platform } from 'uniswap/src/features/platforms/types/Platform' import { isSVMChain } from 'uniswap/src/features/platforms/utils/chains' @@ -225,7 +229,7 @@ export function SelectTokensStep({ }) const { - positionState: { hook, userApprovedHook, fee }, + positionState: { hook, userApprovedHook, fee, initialPosition }, setPositionState, protocolVersion, creatingPoolOrPair, @@ -586,7 +590,7 @@ export function SelectTokensStep({ + + + ) +} diff --git a/apps/web/src/pages/Portfolio/ConnectWalletBottomOverlay.tsx b/apps/web/src/pages/Portfolio/ConnectWalletBottomOverlay.tsx new file mode 100644 index 00000000000..1d3023714b3 --- /dev/null +++ b/apps/web/src/pages/Portfolio/ConnectWalletBottomOverlay.tsx @@ -0,0 +1,47 @@ +import { useAccountDrawer } from 'components/AccountDrawer/MiniPortfolio/hooks' +import { useTranslation } from 'react-i18next' +import { Button, Flex, Text } from 'ui/src' + +export function ConnectWalletBottomOverlay(): JSX.Element { + const accountDrawer = useAccountDrawer() + const { t } = useTranslation() + + return ( + + + + {t('portfolio.disconnected.connectWallet.cta')} + + + + + ) +} diff --git a/apps/web/src/pages/Portfolio/ConnectWalletView.tsx b/apps/web/src/pages/Portfolio/ConnectWalletView.tsx deleted file mode 100644 index 2cbbd7b7d11..00000000000 --- a/apps/web/src/pages/Portfolio/ConnectWalletView.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { useAccountDrawer } from 'components/AccountDrawer/MiniPortfolio/hooks' -import { useTranslation } from 'react-i18next' -import { Button, Flex, Text } from 'ui/src' -import { LineChartDots } from 'ui/src/components/icons/LineChartDots' -import { iconSizes } from 'ui/src/theme' -import { useEnabledChains } from 'uniswap/src/features/chains/hooks/useEnabledChains' - -export default function PortfolioConnectWalletView() { - const { t } = useTranslation() - const accountDrawer = useAccountDrawer() - const { chains } = useEnabledChains() - - return ( - - - - - - - {t('common.getStarted')} - - {t('portfolio.connectWallet.summary', { amount: chains.length })} - - - - - - ) -} diff --git a/apps/web/src/pages/Portfolio/Header/Header.tsx b/apps/web/src/pages/Portfolio/Header/Header.tsx index dabe6593763..c08767e2216 100644 --- a/apps/web/src/pages/Portfolio/Header/Header.tsx +++ b/apps/web/src/pages/Portfolio/Header/Header.tsx @@ -1,14 +1,11 @@ import NetworkFilter from 'components/NetworkFilter/NetworkFilter' -import { useAccount } from 'hooks/useAccount' -import { useScroll } from 'hooks/useScroll' import { usePortfolioParams } from 'pages/Portfolio/Header/hooks/usePortfolioParams' +import PortfolioAddressDisplay from 'pages/Portfolio/Header/PortfolioAddressDisplay/PortfolioAddressDisplay' import { PortfolioTabs } from 'pages/Portfolio/Header/Tabs' import { PortfolioTab } from 'pages/Portfolio/types' -import { useEffect, useState } from 'react' import { useNavigate } from 'react-router' import { Flex } from 'ui/src' import { INTERFACE_NAV_HEIGHT } from 'ui/src/theme/heights' -import { AddressDisplay } from 'uniswap/src/components/accounts/AddressDisplay' import { UniverseChainId } from 'uniswap/src/features/chains/types' import { useEvent } from 'utilities/src/react/hooks' import { getChainUrlParam } from 'utils/chainParams' @@ -22,21 +19,6 @@ function buildPortfolioUrl(tab: PortfolioTab | undefined, chainId: UniverseChain export default function PortfolioHeader() { const navigate = useNavigate() const { tab, chainId: currentChainId } = usePortfolioParams() - const { height: scrollHeight } = useScroll() - const [isCompact, setIsCompact] = useState(false) - const account = useAccount() - - useEffect(() => { - setIsCompact((prevIsCompact) => { - if (!prevIsCompact && scrollHeight > 120) { - return true - } - if (prevIsCompact && scrollHeight < 80) { - return false - } - return prevIsCompact - }) - }, [scrollHeight]) const onNetworkPress = useEvent((chainId: UniverseChainId | undefined) => { navigate(buildPortfolioUrl(tab, chainId)) @@ -56,14 +38,8 @@ export default function PortfolioHeader() { > - + + { + setIsCompact((prevIsCompact) => { + if (!prevIsCompact && scrollHeight > 120) { + return true + } + if (prevIsCompact && scrollHeight < 80) { + return false + } + return prevIsCompact + }) + }, [scrollHeight]) + + if (!account.address) { + return null + } + + return ( + + ) +} diff --git a/apps/web/src/pages/Portfolio/Header/PortfolioAddressDisplay/DemoAddressDisplay.tsx b/apps/web/src/pages/Portfolio/Header/PortfolioAddressDisplay/DemoAddressDisplay.tsx new file mode 100644 index 00000000000..4d6166b96cf --- /dev/null +++ b/apps/web/src/pages/Portfolio/Header/PortfolioAddressDisplay/DemoAddressDisplay.tsx @@ -0,0 +1,37 @@ +import { ReactComponent as Unicon } from 'assets/svg/Emblem/default.svg' +import { useTranslation } from 'react-i18next' +import { Flex, Text, Tooltip, useSporeColors } from 'ui/src' +import { Eye } from 'ui/src/components/icons/Eye' +import { iconSizes } from 'ui/src/theme' + +export default function DemoAddressDisplay() { + const colors = useSporeColors() + const { t } = useTranslation() + + return ( + + + + + + + + + {t('portfolio.disconnected.demoWallet.title')} + + + + + + {t('portfolio.disconnected.demoWallet.description')} + + + + ) +} diff --git a/apps/web/src/pages/Portfolio/Header/PortfolioAddressDisplay/PortfolioAddressDisplay.tsx b/apps/web/src/pages/Portfolio/Header/PortfolioAddressDisplay/PortfolioAddressDisplay.tsx new file mode 100644 index 00000000000..c2f6a8b9fbf --- /dev/null +++ b/apps/web/src/pages/Portfolio/Header/PortfolioAddressDisplay/PortfolioAddressDisplay.tsx @@ -0,0 +1,9 @@ +import useIsConnected from 'pages/Portfolio/Header/hooks/useIsConnected' +import ConnectedAddressDisplay from 'pages/Portfolio/Header/PortfolioAddressDisplay/ConnectedAddressDisplay' +import DemoAddressDisplay from 'pages/Portfolio/Header/PortfolioAddressDisplay/DemoAddressDisplay' + +export default function PortfolioAddressDisplay(): JSX.Element { + const isConnected = useIsConnected() + + return isConnected ? : +} diff --git a/apps/web/src/pages/Portfolio/Header/hooks/useIsConnected.ts b/apps/web/src/pages/Portfolio/Header/hooks/useIsConnected.ts new file mode 100644 index 00000000000..6c381c705ec --- /dev/null +++ b/apps/web/src/pages/Portfolio/Header/hooks/useIsConnected.ts @@ -0,0 +1,7 @@ +/* eslint-disable-next-line no-restricted-imports, no-restricted-syntax */ +import { useAccount } from 'hooks/useAccount' + +export default function useIsConnected() { + const account = useAccount() + return !!account.address +} diff --git a/apps/web/src/pages/Portfolio/NFTs/NFTCard.tsx b/apps/web/src/pages/Portfolio/NFTs/NFTCard.tsx new file mode 100644 index 00000000000..878c0a3cab8 --- /dev/null +++ b/apps/web/src/pages/Portfolio/NFTs/NFTCard.tsx @@ -0,0 +1,144 @@ +import { SharedEventName } from '@uniswap/analytics-events' +import { useCallback, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { AnimateTransition, Flex, Text, TouchableArea, useSporeColors } from 'ui/src' +import { ArrowUpRight } from 'ui/src/components/icons/ArrowUpRight' +import { MoreHorizontal } from 'ui/src/components/icons/MoreHorizontal' +import { zIndexes } from 'ui/src/theme' +import { iconSizes } from 'ui/src/theme/iconSizes' +import { NetworkLogo } from 'uniswap/src/components/CurrencyLogo/NetworkLogo' +import { NftView, NftViewProps } from 'uniswap/src/components/nfts/NftView' +import { fromGraphQLChain } from 'uniswap/src/features/chains/utils' +import { ElementName, SectionName } from 'uniswap/src/features/telemetry/constants' +import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { getOpenseaLink, openUri } from 'uniswap/src/utils/linking' + +const FLOAT_UP_ON_HOVER_OFFSET = -4 + +/** + * Generates a unique rotation angle for an element based on its ID + * @param id - Unique identifier for the element + * @returns CSS custom property object with rotation value + */ +function generateRotationStyle(id: string) { + // Generate hash from ID + const hashCode = id.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0) + + // Determine rotation direction (positive or negative) + const direction = hashCode % 2 === 0 ? 1 : -1 + + // Generate rotation amount between 0.5 and 2.5 degrees + const rotationAmount = 0.5 + (hashCode % 201) / 100 // Range: 0.5 to 2.5 + return direction * rotationAmount +} + +type NftCardProps = Omit & { + owner: Address + id: string + onPress?: () => void +} + +export function NFTCard(props: NftCardProps): JSX.Element { + const [isHovered, setIsHovered] = useState(false) + const colors = useSporeColors() + const { t } = useTranslation() + + // Generate OpenSea URL for the NFT + const openseaUrl = useMemo(() => { + if (props.item.chain && props.item.contractAddress && props.item.tokenId) { + const chainId = fromGraphQLChain(props.item.chain) + if (chainId) { + return getOpenseaLink({ + chainId, + contractAddress: props.item.contractAddress, + tokenId: props.item.tokenId, + }) + } + } + return null + }, [props.item.chain, props.item.contractAddress, props.item.tokenId]) + + const handlePress = useCallback(async () => { + if (openseaUrl) { + await openUri({ uri: openseaUrl }) + } + sendAnalyticsEvent(SharedEventName.ELEMENT_CLICKED, { + element: ElementName.PortfolioNftItem, + section: SectionName.PortfolioNftsTab, + collection_name: props.item.collectionName, + collection_address: props.item.contractAddress, + token_id: props.item.tokenId, + }) + props.onPress?.() + }, [openseaUrl, props.item.collectionName, props.item.contractAddress, props.item.tokenId, props.onPress]) + + return ( + + setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + onPress={handlePress} + > + {/* Context menu trigger icon */} + {/* TODO: open NFT context menu on click */} + event.stopPropagation()} + > + + + + {/* Let the parent card handle the onPress */} + {}} /> + + + + {props.item.name} + + + + + {props.item.collectionName} + + {props.item.chain && } + + + + {t('common.opensea.link')} + + + + + + + + ) +} diff --git a/apps/web/src/pages/Portfolio/NFTs/Nfts.tsx b/apps/web/src/pages/Portfolio/NFTs/Nfts.tsx new file mode 100644 index 00000000000..d876b5a78f2 --- /dev/null +++ b/apps/web/src/pages/Portfolio/NFTs/Nfts.tsx @@ -0,0 +1,75 @@ +import { SearchInput } from 'pages/Portfolio/components/SearchInput' +import { usePortfolioAddress } from 'pages/Portfolio/hooks/usePortfolioAddress' +import { NFTCard } from 'pages/Portfolio/NFTs/NFTCard' +import { filterNft } from 'pages/Portfolio/NFTs/utils/filterNfts' +import { useCallback, useMemo, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Flex, Text } from 'ui/src' +import { useNftListRenderData } from 'uniswap/src/components/nfts/hooks/useNftListRenderData' +import { NftsList } from 'uniswap/src/components/nfts/NftsList' +import { NFTItem } from 'uniswap/src/features/nfts/types' +import { InterfacePageName } from 'uniswap/src/features/telemetry/constants' +import Trace from 'uniswap/src/features/telemetry/Trace' +import { assume0xAddress } from 'utils/wagmi' + +const LOADING_SKELETON_COUNT = 10 + +export default function PortfolioNfts(): JSX.Element { + const { t } = useTranslation() + const owner = usePortfolioAddress() + const nftsContainerRef = useRef(null) + + const [search, setSearch] = useState('') + const lowercaseSearch = useMemo(() => search.trim().toLowerCase(), [search]) + + const { numShown } = useNftListRenderData({ owner: assume0xAddress(owner), skip: !owner }) + + const renderNFTItem = useCallback( + (item: NFTItem) => { + if (!filterNft(item, lowercaseSearch)) { + return + } + + return ( + + + + + + ) + }, + [lowercaseSearch, owner], + ) + + return ( + + + + + {numShown ? `${numShown}` : ''} {t('portfolio.nfts.title')} + + + + + + + + + + ) +} diff --git a/apps/web/src/pages/Portfolio/NFTs/utils/filterNfts.test.ts b/apps/web/src/pages/Portfolio/NFTs/utils/filterNfts.test.ts new file mode 100644 index 00000000000..b208030e60d --- /dev/null +++ b/apps/web/src/pages/Portfolio/NFTs/utils/filterNfts.test.ts @@ -0,0 +1,257 @@ +import { filterNft } from 'pages/Portfolio/NFTs/utils/filterNfts' +import { NFTItem } from 'uniswap/src/features/nfts/types' + +describe('filterNft', () => { + const createMockNft = (overrides: Partial = {}): NFTItem => ({ + name: 'Bored Ape #1234', + collectionName: 'Bored Ape Yacht Club', + tokenId: '1234', + contractAddress: '0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D', + ...overrides, + }) + + describe('when search query is empty', () => { + it('should return true for empty string', () => { + const nft = createMockNft() + expect(filterNft(nft, '')).toBe(true) + }) + + it('should return true for whitespace-only string', () => { + const nft = createMockNft() + expect(filterNft(nft, ' ')).toBe(true) + }) + + it('should return true for null/undefined search', () => { + const nft = createMockNft() + expect(filterNft(nft, '')).toBe(true) + }) + }) + + describe('when searching by NFT name', () => { + it('should match exact name', () => { + const nft = createMockNft({ name: 'Bored Ape #1234' }) + expect(filterNft(nft, 'Bored Ape #1234')).toBe(true) + }) + + it('should match partial name', () => { + const nft = createMockNft({ name: 'Bored Ape #1234' }) + expect(filterNft(nft, 'Bored')).toBe(true) + }) + + it('should be case-insensitive', () => { + const nft = createMockNft({ name: 'Bored Ape #1234' }) + expect(filterNft(nft, 'bored')).toBe(true) + expect(filterNft(nft, 'BORED')).toBe(true) + expect(filterNft(nft, 'BoReD')).toBe(true) + }) + + it('should not match when name does not contain search term', () => { + const nft = createMockNft({ name: 'Bored Ape #1234' }) + expect(filterNft(nft, 'CryptoPunk')).toBe(false) + }) + + it('should handle undefined name', () => { + const nft = createMockNft({ + name: undefined, + collectionName: undefined, + tokenId: undefined, + contractAddress: undefined, + }) + expect(filterNft(nft, 'Bored')).toBe(false) + }) + + it('should handle null name', () => { + const nft = createMockNft({ + name: null as any, + collectionName: undefined, + tokenId: undefined, + contractAddress: undefined, + }) + expect(filterNft(nft, 'Bored')).toBe(false) + }) + }) + + describe('when searching by collection name', () => { + it('should match exact collection name', () => { + const nft = createMockNft({ collectionName: 'Bored Ape Yacht Club' }) + expect(filterNft(nft, 'Bored Ape Yacht Club')).toBe(true) + }) + + it('should match partial collection name', () => { + const nft = createMockNft({ collectionName: 'Bored Ape Yacht Club' }) + expect(filterNft(nft, 'Yacht')).toBe(true) + }) + + it('should be case-insensitive', () => { + const nft = createMockNft({ collectionName: 'Bored Ape Yacht Club' }) + expect(filterNft(nft, 'yacht')).toBe(true) + expect(filterNft(nft, 'YACHT')).toBe(true) + expect(filterNft(nft, 'YaChT')).toBe(true) + }) + + it('should not match when collection name does not contain search term', () => { + const nft = createMockNft({ collectionName: 'Bored Ape Yacht Club' }) + expect(filterNft(nft, 'CryptoPunks')).toBe(false) + }) + + it('should handle undefined collection name', () => { + const nft = createMockNft({ collectionName: undefined }) + expect(filterNft(nft, 'Yacht')).toBe(false) + }) + }) + + describe('when searching by token ID', () => { + it('should match exact token ID', () => { + const nft = createMockNft({ tokenId: '1234' }) + expect(filterNft(nft, '1234')).toBe(true) + }) + + it('should match partial token ID', () => { + const nft = createMockNft({ tokenId: '1234' }) + expect(filterNft(nft, '123')).toBe(true) + }) + + it('should be case-insensitive', () => { + const nft = createMockNft({ tokenId: 'ABC123' }) + expect(filterNft(nft, 'abc')).toBe(true) + expect(filterNft(nft, 'ABC')).toBe(true) + expect(filterNft(nft, 'AbC')).toBe(true) + }) + + it('should not match when token ID does not contain search term', () => { + const nft = createMockNft({ tokenId: '1234' }) + expect(filterNft(nft, '5678')).toBe(false) + }) + + it('should handle undefined token ID', () => { + const nft = createMockNft({ + tokenId: undefined, + name: undefined, + collectionName: undefined, + contractAddress: undefined, + }) + expect(filterNft(nft, '1234')).toBe(false) + }) + }) + + describe('when searching by contract address', () => { + it('should match exact contract address', () => { + const nft = createMockNft({ contractAddress: '0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D' }) + expect(filterNft(nft, '0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D')).toBe(true) + }) + + it('should match partial contract address', () => { + const nft = createMockNft({ contractAddress: '0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D' }) + expect(filterNft(nft, 'BC4CA0')).toBe(true) + }) + + it('should be case-insensitive', () => { + const nft = createMockNft({ contractAddress: '0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D' }) + expect(filterNft(nft, 'bc4ca0')).toBe(true) + expect(filterNft(nft, 'BC4CA0')).toBe(true) + expect(filterNft(nft, 'Bc4Ca0')).toBe(true) + }) + + it('should not match when contract address does not contain search term', () => { + const nft = createMockNft({ contractAddress: '0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D' }) + expect(filterNft(nft, '0x123456789')).toBe(false) + }) + + it('should handle undefined contract address', () => { + const nft = createMockNft({ contractAddress: undefined }) + expect(filterNft(nft, 'BC4CA0')).toBe(false) + }) + }) + + describe('when searching with whitespace', () => { + it('should trim leading and trailing whitespace', () => { + const nft = createMockNft({ name: 'Bored Ape #1234' }) + expect(filterNft(nft, ' Bored ')).toBe(true) + expect(filterNft(nft, '\tBored\n')).toBe(true) + }) + + it('should handle whitespace-only search as empty search', () => { + const nft = createMockNft() + expect(filterNft(nft, ' ')).toBe(true) + expect(filterNft(nft, '\t\n')).toBe(true) + }) + }) + + describe('edge cases', () => { + it('should handle NFT with all undefined fields', () => { + const nft = createMockNft({ + name: undefined, + collectionName: undefined, + tokenId: undefined, + contractAddress: undefined, + }) + expect(filterNft(nft, 'anything')).toBe(false) + }) + + it('should handle NFT with empty string fields', () => { + const nft = createMockNft({ + name: '', + collectionName: '', + tokenId: '', + contractAddress: '', + }) + expect(filterNft(nft, 'anything')).toBe(false) + }) + + it('should handle special characters in search', () => { + const nft = createMockNft({ name: 'NFT #1234' }) + expect(filterNft(nft, '#')).toBe(true) + expect(filterNft(nft, '1234')).toBe(true) + }) + + it('should handle unicode characters', () => { + const nft = createMockNft({ name: '🚀 Rocket NFT' }) + expect(filterNft(nft, '🚀')).toBe(true) + expect(filterNft(nft, 'Rocket')).toBe(true) + }) + }) + + describe('real-world examples', () => { + it('should match Bored Ape Yacht Club NFT', () => { + const nft = createMockNft({ + name: 'Bored Ape #1234', + collectionName: 'Bored Ape Yacht Club', + tokenId: '1234', + contractAddress: '0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D', + }) + + expect(filterNft(nft, 'bored')).toBe(true) + expect(filterNft(nft, 'ape')).toBe(true) + expect(filterNft(nft, 'yacht')).toBe(true) + expect(filterNft(nft, '1234')).toBe(true) + expect(filterNft(nft, 'BC4CA0')).toBe(true) + }) + + it('should match CryptoPunks NFT', () => { + const nft = createMockNft({ + name: 'CryptoPunk #1234', + collectionName: 'CryptoPunks', + tokenId: '1234', + contractAddress: '0xb47e3cd837dDF8e4c57F05d70Ab865de6e193BBB', + }) + + expect(filterNft(nft, 'crypto')).toBe(true) + expect(filterNft(nft, 'punk')).toBe(true) + expect(filterNft(nft, 'punks')).toBe(true) + expect(filterNft(nft, '1234')).toBe(true) + }) + + it('should not match unrelated NFTs', () => { + const nft = createMockNft({ + name: 'Bored Ape #1234', + collectionName: 'Bored Ape Yacht Club', + tokenId: '1234', + contractAddress: '0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D', + }) + + expect(filterNft(nft, 'cryptopunk')).toBe(false) + expect(filterNft(nft, 'azuki')).toBe(false) + expect(filterNft(nft, '5678')).toBe(false) + }) + }) +}) diff --git a/apps/web/src/pages/Portfolio/NFTs/utils/filterNfts.ts b/apps/web/src/pages/Portfolio/NFTs/utils/filterNfts.ts new file mode 100644 index 00000000000..643712033f0 --- /dev/null +++ b/apps/web/src/pages/Portfolio/NFTs/utils/filterNfts.ts @@ -0,0 +1,32 @@ +import { NFTItem } from 'uniswap/src/features/nfts/types' + +/** + * Filters an NFT item based on a search query. + * The search is case-insensitive and matches against: + * - NFT name + * - Collection name + * - Token ID + * - Contract address + * + * @param item - The NFT item to filter + * @param searchQuery - The search query (will be converted to lowercase) + * @returns true if the item matches the search query, false otherwise + */ +export function filterNft(item: NFTItem, searchQuery: string): boolean { + if (!searchQuery.trim()) { + return true + } + + const lowercaseSearch = searchQuery.trim().toLowerCase() + const name = item.name?.toLowerCase() ?? '' + const collectionName = item.collectionName?.toLowerCase() ?? '' + const tokenId = item.tokenId?.toLowerCase() ?? '' + const contract = item.contractAddress?.toLowerCase() ?? '' + + return ( + name.includes(lowercaseSearch) || + collectionName.includes(lowercaseSearch) || + tokenId.includes(lowercaseSearch) || + contract.includes(lowercaseSearch) + ) +} diff --git a/apps/web/src/pages/Portfolio/Nfts.tsx b/apps/web/src/pages/Portfolio/Nfts.tsx deleted file mode 100644 index 49fdcff4e64..00000000000 --- a/apps/web/src/pages/Portfolio/Nfts.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { useTranslation } from 'react-i18next' -import { Flex, Text } from 'ui/src' -import { InterfacePageName } from 'uniswap/src/features/telemetry/constants' -import Trace from 'uniswap/src/features/telemetry/Trace' - -export default function PortfolioNfts() { - const { t } = useTranslation() - - return ( - - - {t('portfolio.nfts.title')} - - Coming Soon - - This feature is under development and will be available soon. - - - - - ) -} diff --git a/apps/web/src/pages/Portfolio/Portfolio.tsx b/apps/web/src/pages/Portfolio/Portfolio.tsx index b58e47ab710..8959a955b19 100644 --- a/apps/web/src/pages/Portfolio/Portfolio.tsx +++ b/apps/web/src/pages/Portfolio/Portfolio.tsx @@ -1,70 +1,59 @@ -import { useAccount } from 'hooks/useAccount' -import PortfolioActivity from 'pages/Portfolio/Activity/Activity' -import PortfolioConnectWalletView from 'pages/Portfolio/ConnectWalletView' -import PortfolioDefi from 'pages/Portfolio/Defi' +import { Layers, PortfolioDisconnectedDemoViewProperties, useExperimentValueFromLayer } from '@universe/gating' +import PortfolioConnectWalletBanner from 'pages/Portfolio/ConnectWalletBanner' +import { ConnectWalletBottomOverlay } from 'pages/Portfolio/ConnectWalletBottomOverlay' import PortfolioHeader from 'pages/Portfolio/Header/Header' -import { usePortfolioParams } from 'pages/Portfolio/Header/hooks/usePortfolioParams' -import { usePortfolioTabsAnimation } from 'pages/Portfolio/Header/hooks/usePortfolioTabsAnimation' -import PortfolioNfts from 'pages/Portfolio/Nfts' -import PortfolioOverview from 'pages/Portfolio/Overview' -import PortfolioTokens from 'pages/Portfolio/Tokens/Tokens' -import { PortfolioTab } from 'pages/Portfolio/types' -import { useLocation } from 'react-router' +import useIsConnected from 'pages/Portfolio/Header/hooks/useIsConnected' +import { PortfolioContent } from 'pages/Portfolio/PortfolioContent' +import PortfolioDisconnectedView from 'pages/Portfolio/PortfolioDisconnectedView' import { Flex } from 'ui/src' -import { TransitionItem } from 'ui/src/animations/components/AnimatePresencePager' import { InterfacePageName } from 'uniswap/src/features/telemetry/constants' import Trace from 'uniswap/src/features/telemetry/Trace' -const renderPortfolioContent = (tab: PortfolioTab | undefined) => { - switch (tab) { - case PortfolioTab.Overview: - return - case PortfolioTab.Tokens: - return - case PortfolioTab.Defi: - return - case PortfolioTab.Nfts: - return - case PortfolioTab.Activity: - return - default: - return - } -} - // eslint-disable-next-line import/no-unused-modules -- used in RouteDefinitions.tsx via lazy import export default function Portfolio() { - const { pathname } = useLocation() - const account = useAccount() - const animationType = usePortfolioTabsAnimation(pathname) - const { tab } = usePortfolioParams() + const isConnected = useIsConnected() + const showDemoView = useExperimentValueFromLayer({ + layerName: Layers.PortfolioPage, + param: PortfolioDisconnectedDemoViewProperties.DemoViewEnabled, + defaultValue: false, + }) return ( - - {account.address ? ( - <> - + {!showDemoView && !isConnected ? ( + + ) : ( + + {!isConnected && } + {!isConnected && } + + {isConnected ? ( + <> + + + {/* Animated Content Area - All routes show same content, filtered by chain */} + + + ) : ( + <> + - {/* Animated Content Area - All routes show same content, filtered by chain */} - - - {renderPortfolioContent(tab)} - - - - ) : ( - - )} - + {/* Animated Content Area - All routes show same content, filtered by chain */} + + + + + )} + + )} ) } diff --git a/apps/web/src/pages/Portfolio/PortfolioContent.tsx b/apps/web/src/pages/Portfolio/PortfolioContent.tsx new file mode 100644 index 00000000000..9f859aaa643 --- /dev/null +++ b/apps/web/src/pages/Portfolio/PortfolioContent.tsx @@ -0,0 +1,42 @@ +import PortfolioActivity from 'pages/Portfolio/Activity/Activity' +import PortfolioDefi from 'pages/Portfolio/Defi' +import { usePortfolioParams } from 'pages/Portfolio/Header/hooks/usePortfolioParams' +import { usePortfolioTabsAnimation } from 'pages/Portfolio/Header/hooks/usePortfolioTabsAnimation' +import PortfolioNfts from 'pages/Portfolio/NFTs/Nfts' +import PortfolioOverview from 'pages/Portfolio/Overview' +import PortfolioTokens from 'pages/Portfolio/Tokens/Tokens' +import { PortfolioTab } from 'pages/Portfolio/types' +import { useLocation } from 'react-router' +import { Flex } from 'ui/src' +import { TransitionItem } from 'ui/src/animations/components/AnimatePresencePager' + +const renderPortfolioContent = (tab: PortfolioTab | undefined) => { + switch (tab) { + case PortfolioTab.Overview: + return + case PortfolioTab.Tokens: + return + case PortfolioTab.Defi: + return + case PortfolioTab.Nfts: + return + case PortfolioTab.Activity: + return + default: + return + } +} + +export function PortfolioContent({ disabled }: { disabled?: boolean }): JSX.Element { + const { pathname } = useLocation() + const animationType = usePortfolioTabsAnimation(pathname) + const { tab } = usePortfolioParams() + + return ( + + + {renderPortfolioContent(tab)} + + + ) +} diff --git a/apps/web/src/pages/Portfolio/PortfolioDisconnectedView.tsx b/apps/web/src/pages/Portfolio/PortfolioDisconnectedView.tsx new file mode 100644 index 00000000000..fe9e4c354d6 --- /dev/null +++ b/apps/web/src/pages/Portfolio/PortfolioDisconnectedView.tsx @@ -0,0 +1,105 @@ +import DISCONNECTED_B_DARK from 'assets/images/portfolio-page-promo/dark.svg' +import DISCONNECTED_B_LIGHT from 'assets/images/portfolio-page-promo/light.svg' +import { useAccountDrawer } from 'components/AccountDrawer/MiniPortfolio/hooks' +import { useTranslation } from 'react-i18next' +import { Button, Flex, Image, Text, useIsDarkMode, useSporeColors } from 'ui/src' +import { INTERFACE_NAV_HEIGHT } from 'ui/src/theme' +import { useEnabledChains } from 'uniswap/src/features/chains/hooks/useEnabledChains' + +const PADDING_TOP = 60 +const NAV_BORDER_WIDTH = 1 +const OFFSET_TOP = INTERFACE_NAV_HEIGHT + NAV_BORDER_WIDTH +const LEFT_CONTENT_MAX_WIDTH = 262 + +export default function PortfolioDisconnectedView() { + const { t } = useTranslation() + const enabledChains = useEnabledChains() + const isDarkMode = useIsDarkMode() + const accountDrawer = useAccountDrawer() + const colors = useSporeColors() + + return ( + + + + + {t('common.getStarted')} + + + {t('portfolio.disconnected.cta.description', { numNetworks: enabledChains.chains.length })} + + + + + + + + + + + ) +} diff --git a/apps/web/src/pages/Portfolio/Tokens/Table/TokensContextMenuWrapper.tsx b/apps/web/src/pages/Portfolio/Tokens/Table/TokensContextMenuWrapper.tsx index ae185fdcc14..3bd16b1bd30 100644 --- a/apps/web/src/pages/Portfolio/Tokens/Table/TokensContextMenuWrapper.tsx +++ b/apps/web/src/pages/Portfolio/Tokens/Table/TokensContextMenuWrapper.tsx @@ -1,3 +1,4 @@ +import useIsConnected from 'pages/Portfolio/Header/hooks/useIsConnected' import { TokenData } from 'pages/Portfolio/Tokens/hooks/useTransformTokenTableData' import { PropsWithChildren, useMemo } from 'react' import { ContextMenuTriggerMode } from 'uniswap/src/components/menus/types' @@ -9,6 +10,7 @@ export default function TokensContextMenuWrapper({ triggerMode, children, }: PropsWithChildren<{ tokenData: TokenData; triggerMode?: ContextMenuTriggerMode }>): React.ReactNode { + const isConnected = useIsConnected() const portfolioBalance: PortfolioBalance | undefined = useMemo(() => { if (!tokenData.currencyInfo) { return undefined @@ -25,7 +27,7 @@ export default function TokensContextMenuWrapper({ } }, [tokenData.currencyInfo, tokenData.id, tokenData.balance.value, tokenData.change1d, tokenData.rawValue]) - if (!portfolioBalance) { + if (!portfolioBalance || !isConnected) { return children } diff --git a/apps/web/src/pages/Portfolio/Tokens/Table/TokensTable.tsx b/apps/web/src/pages/Portfolio/Tokens/Table/TokensTable.tsx new file mode 100644 index 00000000000..39c41307320 --- /dev/null +++ b/apps/web/src/pages/Portfolio/Tokens/Table/TokensTable.tsx @@ -0,0 +1,56 @@ +import { NetworkStatus } from '@apollo/client' +import { TokenData } from 'pages/Portfolio/Tokens/hooks/useTransformTokenTableData' +import TokensTableInner from 'pages/Portfolio/Tokens/Table/TokensTableInner' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { ScrollSync } from 'react-scroll-sync' +import { Flex, HeightAnimator, Text, TouchableArea } from 'ui/src' +import { AnglesDownUp } from 'ui/src/components/icons/AnglesDownUp' +import { SortVertical } from 'ui/src/components/icons/SortVertical' + +interface TokensTableProps { + visible: TokenData[] + hidden: TokenData[] + loading: boolean + refetching?: boolean + networkStatus: NetworkStatus + error?: Error | undefined +} + +export default function TokensTable({ visible, hidden, loading, refetching, networkStatus, error }: TokensTableProps) { + const { t } = useTranslation() + const [isOpen, setIsOpen] = useState(false) + const tableLoading = loading && !refetching + + return ( + // Scroll Sync Architecture: + // - Outer ScrollSync coordinates horizontal scrolling between visible and hidden tables + // - Each TokensTableInner uses externalScrollSync=true to skip its own ScrollSync wrapper + // - Both tables use ScrollSyncPane with scrollGroup="portfolio-tokens" for coordination + // - DO NOT remove this outer ScrollSync wrapper without updating the Table components + + + + {hidden.length > 0 && ( + <> + setIsOpen(!isOpen)} row gap="$gap8" p="$spacing16"> + + {t('hidden.tokens.info.text.button', { numHidden: hidden.length })} + + + {isOpen ? ( + + ) : ( + + )} + + + + + + + )} + + + ) +} diff --git a/apps/web/src/pages/Portfolio/Tokens/Table/Table.tsx b/apps/web/src/pages/Portfolio/Tokens/Table/TokensTableInner.tsx similarity index 66% rename from apps/web/src/pages/Portfolio/Tokens/Table/Table.tsx rename to apps/web/src/pages/Portfolio/Tokens/Table/TokensTableInner.tsx index e7f13d1fd33..aeb4600af6a 100644 --- a/apps/web/src/pages/Portfolio/Tokens/Table/Table.tsx +++ b/apps/web/src/pages/Portfolio/Tokens/Table/TokensTableInner.tsx @@ -15,12 +15,29 @@ import { useMemo } from 'react' import { useTranslation } from 'react-i18next' import { Text } from 'ui/src' -export default function TokensTable({ tokenData }: { tokenData: TokenData[] }) { +const hasRow = (obj: unknown): obj is { row: { original: T } } => { + const maybeRow = (obj as { row?: unknown }).row + return typeof maybeRow === 'object' && maybeRow !== null && 'original' in maybeRow && maybeRow.original !== undefined +} + +export default function TokensTableInner({ + tokenData, + hideHeader, + loading = false, + error, +}: { + tokenData: TokenData[] + hideHeader?: boolean + loading?: boolean + error?: Error | undefined +}) { const { t } = useTranslation() + const showLoadingSkeleton = loading || !!error // Create table columns const columns = useMemo(() => { const columnHelper = createColumnHelper() + return [ columnHelper.accessor('currencyInfo', { header: () => ( @@ -31,10 +48,9 @@ export default function TokensTable({ tokenData }: { tokenData: TokenData[] }) { ), cell: (info) => { - const currencyInfo = info.getValue() return ( - - + + ) }, @@ -48,10 +64,9 @@ export default function TokensTable({ tokenData }: { tokenData: TokenData[] }) { ), cell: (info) => { - const value = info.getValue() return ( - - + + ) }, @@ -65,10 +80,9 @@ export default function TokensTable({ tokenData }: { tokenData: TokenData[] }) { ), cell: (info) => { - const value = info.getValue() return ( - - + + ) }, @@ -82,10 +96,9 @@ export default function TokensTable({ tokenData }: { tokenData: TokenData[] }) { ), cell: (info) => { - const value = info.getValue() return ( - - + + ) }, @@ -99,11 +112,9 @@ export default function TokensTable({ tokenData }: { tokenData: TokenData[] }) { ), cell: (info) => { - const value = info.getValue() - return ( - - + + ) }, @@ -117,10 +128,9 @@ export default function TokensTable({ tokenData }: { tokenData: TokenData[] }) { ), cell: (info) => { - const value = info.getValue() return ( - - + + ) }, @@ -130,28 +140,33 @@ export default function TokensTable({ tokenData }: { tokenData: TokenData[] }) { size: 40, header: () => , cell: (info) => { - const tokenData = info.row.original + const tokenData = hasRow(info) ? info.row.original : undefined return ( - - + + {tokenData && } ) }, }), ] - }, [t]) + }, [t, showLoadingSkeleton]) return (
row.id} - rowWrapper={(row, content) => ( - {content} - )} + rowWrapper={ + loading + ? undefined + : (row, content) => {content} + } /> ) } diff --git a/apps/web/src/pages/Portfolio/Tokens/Table/columns/Balance.tsx b/apps/web/src/pages/Portfolio/Tokens/Table/columns/Balance.tsx index 8a0d4a018f0..9a9a00ac191 100644 --- a/apps/web/src/pages/Portfolio/Tokens/Table/columns/Balance.tsx +++ b/apps/web/src/pages/Portfolio/Tokens/Table/columns/Balance.tsx @@ -10,7 +10,7 @@ const Balance = memo(function Balance({ value, symbol }: TokenData['balance']) { } return ( - + {symbol} ) diff --git a/apps/web/src/pages/Portfolio/Tokens/Tokens.tsx b/apps/web/src/pages/Portfolio/Tokens/Tokens.tsx index 3b44943cf6f..4e44c419bcf 100644 --- a/apps/web/src/pages/Portfolio/Tokens/Tokens.tsx +++ b/apps/web/src/pages/Portfolio/Tokens/Tokens.tsx @@ -1,27 +1,122 @@ -import { useAccount } from 'hooks/useAccount' +import { SearchInput } from 'pages/Portfolio/components/SearchInput' +import { usePortfolioParams } from 'pages/Portfolio/Header/hooks/usePortfolioParams' +import { usePortfolioAddress } from 'pages/Portfolio/hooks/usePortfolioAddress' import { useTransformTokenTableData } from 'pages/Portfolio/Tokens/hooks/useTransformTokenTableData' -import TokensTable from 'pages/Portfolio/Tokens/Table/Table' import { TokensAllocationChart } from 'pages/Portfolio/Tokens/Table/TokensAllocationChart' -import { Flex } from 'ui/src' +import TokensTable from 'pages/Portfolio/Tokens/Table/TokensTable' +import { filterTokensBySearch } from 'pages/Portfolio/Tokens/utils/filterTokensBySearch' +import { memo, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Flex, RemoveScroll, Text } from 'ui/src' +import { useEnabledChains } from 'uniswap/src/features/chains/hooks/useEnabledChains' import { PortfolioBalance } from 'uniswap/src/features/portfolio/PortfolioBalance/PortfolioBalance' import { InterfacePageName } from 'uniswap/src/features/telemetry/constants' import Trace from 'uniswap/src/features/telemetry/Trace' +import { parseChainFromTokenSearchQuery } from 'uniswap/src/utils/search/parseChainFromTokenSearchQuery' + +const TokenCountIndicator = memo(({ count }: { count: number }) => { + const { t } = useTranslation() + + return ( + + + + {t('portfolio.tokens.balance.totalTokens', { numTokens: count })} + + + ) +}) + +TokenCountIndicator.displayName = 'TokenCountIndicator' export default function PortfolioTokens() { - const account = useAccount() - const tokenData = useTransformTokenTableData() + const portfolioAddress = usePortfolioAddress() + const { t } = useTranslation() + const [search, setSearch] = useState('') + const { chains: enabledChains } = useEnabledChains() + const { chainId: urlChainId } = usePortfolioParams() + + // Parse search query to extract chain filter and search term + const { chainFilter, searchTerm } = useMemo(() => { + return parseChainFromTokenSearchQuery(search, enabledChains) + }, [search, enabledChains]) + + // Use URL chain ID as primary filter, search chain filter as fallback + const effectiveChainId = urlChainId || chainFilter + + // Get token data filtered by chain at API level + const { + visible: tokenData, + hidden: hiddenTokenData, + loading, + refetching, + networkStatus, + error, + } = useTransformTokenTableData({ + chainIds: effectiveChainId ? [effectiveChainId] : undefined, + }) + + // Filter tokens by search term at client level (chain filtering is handled at API level) + const filteredTokenData = useMemo(() => { + return filterTokensBySearch({ tokens: tokenData || [], searchTerm }) + // return filterTokensBySearch({ tokens: tokenData, searchTerm }) || [] + }, [tokenData, searchTerm]) + + const filteredHiddenTokenData = useMemo(() => { + return filterTokensBySearch({ tokens: hiddenTokenData || [], searchTerm }) || [] + }, [hiddenTokenData, searchTerm]) return ( - - {account.address && ( + + - - - + + : undefined} + /> + + - + {(tokenData && tokenData.length > 0) || loading ? ( + <> + + {(filteredTokenData?.length ?? 0) > 0 || loading ? ( + - )} - + + ) } diff --git a/apps/web/src/pages/Portfolio/Tokens/hooks/useTransformTokenTableData.ts b/apps/web/src/pages/Portfolio/Tokens/hooks/useTransformTokenTableData.ts index 082c0562e57..2812331fa28 100644 --- a/apps/web/src/pages/Portfolio/Tokens/hooks/useTransformTokenTableData.ts +++ b/apps/web/src/pages/Portfolio/Tokens/hooks/useTransformTokenTableData.ts @@ -1,6 +1,9 @@ -import { useAccount } from 'hooks/useAccount' +import { NetworkStatus } from '@apollo/client' +import { usePortfolioAddress } from 'pages/Portfolio/hooks/usePortfolioAddress' import { useMemo } from 'react' +import { UniverseChainId } from 'uniswap/src/features/chains/types' import { useSortedPortfolioBalances } from 'uniswap/src/features/dataApi/balances/balances' +import type { PortfolioBalance } from 'uniswap/src/features/dataApi/types' import { CurrencyInfo } from 'uniswap/src/features/dataApi/types' import { useLocalizationContext } from 'uniswap/src/features/language/LocalizationContext' import { NumberType } from 'utilities/src/format/types' @@ -20,41 +23,54 @@ export interface TokenData { } // Custom hook to format portfolio data -export function useTransformTokenTableData(): TokenData[] { - const account = useAccount() - const { data: portfolioData, loading } = useSortedPortfolioBalances({ - evmAddress: account.address || undefined, - }) +export function useTransformTokenTableData({ chainIds }: { chainIds?: UniverseChainId[] }): { + visible: TokenData[] | null + hidden: TokenData[] | null + loading: boolean + refetching: boolean + error: Error | undefined + refetch: (() => void) | undefined + networkStatus: NetworkStatus +} { + const portfolioAddress = usePortfolioAddress() const { convertFiatAmountFormatted, formatNumberOrString } = useLocalizationContext() + const { + data: sortedBalances, + loading, + error, + refetch, + networkStatus, + } = useSortedPortfolioBalances({ + evmAddress: portfolioAddress, + chainIds, + }) + return useMemo(() => { - if (!account.address || !portfolioData?.balances || loading) { - return [] + // Only show empty state on initial load, not during refetch + const isInitialLoading = loading && !sortedBalances + const isRefetching = loading && !!sortedBalances + + if (isInitialLoading) { + return { visible: null, hidden: null, loading, refetching: false, error, refetch, networkStatus } + } + + if (!sortedBalances) { + return { visible: [], hidden: [], loading, refetching: false, error, refetch, networkStatus } } // Compute total USD across visible balances to determine allocation per token - const totalUSD = portfolioData.balances.reduce((sum, b) => sum + (b.balanceUSD ?? 0), 0) + const totalUSDVisible = sortedBalances.balances.reduce((sum, b) => sum + (b.balanceUSD ?? 0), 0) - return portfolioData.balances.map((balance) => { - // Format price (using balanceUSD / quantity for now, could be improved with actual price data) + const mapBalanceToTokenData = (balance: PortfolioBalance, allocationFromTotal?: number): TokenData => { const price = balance.balanceUSD && balance.quantity > 0 ? convertFiatAmountFormatted(balance.balanceUSD / balance.quantity, NumberType.FiatTokenPrice) : '$0.00' - // Format balance quantity - const formattedBalance = formatNumberOrString({ - value: balance.quantity, - type: NumberType.TokenNonTx, - }) - - // Format USD value + const formattedBalance = formatNumberOrString({ value: balance.quantity, type: NumberType.TokenNonTx }) const value = convertFiatAmountFormatted(balance.balanceUSD, NumberType.PortfolioBalance) - // Allocation percentage of this token vs total portfolio USD (0..100) - const balanceUSD = balance.balanceUSD ?? 0 - const allocation = totalUSD > 0 ? (balanceUSD / totalUSD) * 100 : 0 - return { id: balance.id, currencyInfo: balance.currencyInfo, @@ -66,8 +82,18 @@ export function useTransformTokenTableData(): TokenData[] { }, value, rawValue: balance.balanceUSD, - allocation, + allocation: allocationFromTotal ?? 0, } + } + + const visible = sortedBalances.balances.map((b) => { + const balanceUSD = b.balanceUSD ?? 0 + const allocation = totalUSDVisible > 0 ? (balanceUSD / totalUSDVisible) * 100 : 0 + return mapBalanceToTokenData(b, allocation) }) - }, [account.address, portfolioData?.balances, loading, convertFiatAmountFormatted, formatNumberOrString]) + + const hidden = sortedBalances.hiddenBalances.map((b) => mapBalanceToTokenData(b, 0)) + + return { visible, hidden, loading, refetching: isRefetching, refetch, networkStatus, error } + }, [loading, sortedBalances, convertFiatAmountFormatted, formatNumberOrString, error, refetch, networkStatus]) } diff --git a/apps/web/src/pages/Portfolio/Tokens/utils/filterTokensBySearch.test.ts b/apps/web/src/pages/Portfolio/Tokens/utils/filterTokensBySearch.test.ts new file mode 100644 index 00000000000..ab64034b7bb --- /dev/null +++ b/apps/web/src/pages/Portfolio/Tokens/utils/filterTokensBySearch.test.ts @@ -0,0 +1,280 @@ +import { Currency } from '@uniswap/sdk-core' +import { filterTokensBySearch } from 'pages/Portfolio/Tokens/utils/filterTokensBySearch' +import { TEST_TOKEN_1 } from 'test-utils/constants' + +// Mock the doesTokenMatchSearchTerm function to have full control over test scenarios +vi.mock('uniswap/src/utils/search/doesTokenMatchSearchTerm', () => ({ + doesTokenMatchSearchTerm: vi.fn(), +})) + +import { doesTokenMatchSearchTerm } from 'uniswap/src/utils/search/doesTokenMatchSearchTerm' + +const mockDoesTokenMatchSearchTerm = vi.mocked(doesTokenMatchSearchTerm) + +// Test data factory functions using test tokens +const createMockCurrencyInfo = ( + overrides: Partial<{ currencyId: string; currency: Currency }> = {}, +): { currencyId: string; currency: Currency } => ({ + currencyId: 'TEST', + currency: TEST_TOKEN_1, // Default to TEST_TOKEN_1 + ...overrides, +}) + +const createMockTokenWithInfo = ( + overrides: Partial<{ currencyInfo: { currencyId: string; currency: Currency } | null }> = {}, +): { currencyInfo: { currencyId: string; currency: Currency } | null } => ({ + currencyInfo: createMockCurrencyInfo(), + ...overrides, +}) + +describe('filterTokensBySearch', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('when searchTerm is empty or undefined', () => { + it('should return all tokens when searchTerm is undefined', () => { + const tokens = [createMockTokenWithInfo(), createMockTokenWithInfo()] + + const result = filterTokensBySearch({ + tokens, + searchTerm: undefined, + }) + + expect(result).toBe(tokens) + expect(mockDoesTokenMatchSearchTerm).not.toHaveBeenCalled() + }) + + it('should return all tokens when searchTerm is null', () => { + const tokens = [createMockTokenWithInfo(), createMockTokenWithInfo()] + + const result = filterTokensBySearch({ + tokens, + searchTerm: null, + }) + + expect(result).toBe(tokens) + expect(mockDoesTokenMatchSearchTerm).not.toHaveBeenCalled() + }) + + it('should return all tokens when searchTerm is empty string', () => { + const tokens = [createMockTokenWithInfo(), createMockTokenWithInfo()] + + const result = filterTokensBySearch({ + tokens, + searchTerm: '', + }) + + expect(result).toBe(tokens) + expect(mockDoesTokenMatchSearchTerm).not.toHaveBeenCalled() + }) + + it('should return all tokens when searchTerm is only whitespace', () => { + const tokens = [createMockTokenWithInfo(), createMockTokenWithInfo()] + + const result = filterTokensBySearch({ + tokens, + searchTerm: ' ', + }) + + expect(result).toBe(tokens) + expect(mockDoesTokenMatchSearchTerm).not.toHaveBeenCalled() + }) + }) + + describe('when tokens array is undefined', () => { + it('should return undefined when tokens is undefined', () => { + const result = filterTokensBySearch({ + tokens: undefined, + searchTerm: 'test', + }) + + expect(result).toBeUndefined() + expect(mockDoesTokenMatchSearchTerm).not.toHaveBeenCalled() + }) + }) + + describe('when tokens array is empty', () => { + it('should return empty array when tokens is empty', () => { + const result = filterTokensBySearch({ + tokens: [], + searchTerm: 'test', + }) + + expect(result).toEqual([]) + expect(mockDoesTokenMatchSearchTerm).not.toHaveBeenCalled() + }) + }) + + describe('when filtering with valid search term', () => { + it('should filter tokens based on doesTokenMatchSearchTerm results', () => { + const token1 = createMockTokenWithInfo() + const token2 = createMockTokenWithInfo() + const token3 = createMockTokenWithInfo() + const tokens = [token1, token2, token3] + + // Mock the search function to return different results for each token + mockDoesTokenMatchSearchTerm + .mockReturnValueOnce(true) // token1 matches + .mockReturnValueOnce(false) // token2 doesn't match + .mockReturnValueOnce(true) // token3 matches + + const result = filterTokensBySearch({ + tokens, + searchTerm: 'test', + }) + + expect(result).toEqual([token1, token3]) + expect(mockDoesTokenMatchSearchTerm).toHaveBeenCalledTimes(3) + expect(mockDoesTokenMatchSearchTerm).toHaveBeenCalledWith(token1, 'test') + expect(mockDoesTokenMatchSearchTerm).toHaveBeenCalledWith(token2, 'test') + expect(mockDoesTokenMatchSearchTerm).toHaveBeenCalledWith(token3, 'test') + }) + + it('should return empty array when no tokens match', () => { + const token1 = createMockTokenWithInfo() + const token2 = createMockTokenWithInfo() + const tokens = [token1, token2] + + mockDoesTokenMatchSearchTerm.mockReturnValueOnce(false).mockReturnValueOnce(false) + + const result = filterTokensBySearch({ + tokens, + searchTerm: 'nonexistent', + }) + + expect(result).toEqual([]) + expect(mockDoesTokenMatchSearchTerm).toHaveBeenCalledTimes(2) + }) + + it('should return all tokens when all tokens match', () => { + const token1 = createMockTokenWithInfo() + const token2 = createMockTokenWithInfo() + const tokens = [token1, token2] + + mockDoesTokenMatchSearchTerm.mockReturnValueOnce(true).mockReturnValueOnce(true) + + const result = filterTokensBySearch({ + tokens, + searchTerm: 'common', + }) + + expect(result).toEqual(tokens) + expect(mockDoesTokenMatchSearchTerm).toHaveBeenCalledTimes(2) + }) + }) + + describe('with different token types', () => { + it('should work with tokens that have currencyInfo', () => { + const token = createMockTokenWithInfo({ + currencyInfo: createMockCurrencyInfo({ + currencyId: 'ABC', + currency: TEST_TOKEN_1, // Use TEST_TOKEN_1 (symbol: 'ABC', name: 'Abc') + }), + }) + + mockDoesTokenMatchSearchTerm.mockReturnValue(true) + + const result = filterTokensBySearch({ + tokens: [token], + searchTerm: 'abc', + }) + + expect(result).toEqual([token]) + expect(mockDoesTokenMatchSearchTerm).toHaveBeenCalledWith(token, 'abc') + }) + + it('should work with tokens that have null currencyInfo', () => { + const token = createMockTokenWithInfo({ + currencyInfo: null, + }) + + mockDoesTokenMatchSearchTerm.mockReturnValue(false) + + const result = filterTokensBySearch({ + tokens: [token], + searchTerm: 'test', + }) + + expect(result).toEqual([]) + expect(mockDoesTokenMatchSearchTerm).toHaveBeenCalledWith(token, 'test') + }) + }) + + describe('edge cases', () => { + it('should handle single token array', () => { + const token = createMockTokenWithInfo() + mockDoesTokenMatchSearchTerm.mockReturnValue(true) + + const result = filterTokensBySearch({ + tokens: [token], + searchTerm: 'test', + }) + + expect(result).toEqual([token]) + expect(mockDoesTokenMatchSearchTerm).toHaveBeenCalledTimes(1) + }) + + it('should preserve original array reference when no filtering occurs', () => { + const tokens = [createMockTokenWithInfo()] + + const result = filterTokensBySearch({ + tokens, + searchTerm: undefined, + }) + + expect(result).toBe(tokens) + }) + + it('should handle search term with special characters', () => { + const token = createMockTokenWithInfo() + mockDoesTokenMatchSearchTerm.mockReturnValue(true) + + const result = filterTokensBySearch({ + tokens: [token], + searchTerm: 'test@#$%^&*()', + }) + + expect(result).toEqual([token]) + expect(mockDoesTokenMatchSearchTerm).toHaveBeenCalledWith(token, 'test@#$%^&*()') + }) + + it('should handle very long search terms', () => { + const token = createMockTokenWithInfo() + const longSearchTerm = 'a'.repeat(1000) + mockDoesTokenMatchSearchTerm.mockReturnValue(false) + + const result = filterTokensBySearch({ + tokens: [token], + searchTerm: longSearchTerm, + }) + + expect(result).toEqual([]) + expect(mockDoesTokenMatchSearchTerm).toHaveBeenCalledWith(token, longSearchTerm) + }) + }) + + describe('type safety', () => { + it('should work with generic token types', () => { + interface ExtendedToken { + currencyInfo: { currencyId: string; currency: Currency } | null + customProperty: string + } + + const extendedToken: ExtendedToken = { + currencyInfo: createMockCurrencyInfo(), + customProperty: 'test', + } + + mockDoesTokenMatchSearchTerm.mockReturnValue(true) + + const result = filterTokensBySearch({ + tokens: [extendedToken], + searchTerm: 'test', + }) + + expect(result).toEqual([extendedToken]) + expect(mockDoesTokenMatchSearchTerm).toHaveBeenCalledWith(extendedToken, 'test') + }) + }) +}) diff --git a/apps/web/src/pages/Portfolio/Tokens/utils/filterTokensBySearch.ts b/apps/web/src/pages/Portfolio/Tokens/utils/filterTokensBySearch.ts new file mode 100644 index 00000000000..7fa87992045 --- /dev/null +++ b/apps/web/src/pages/Portfolio/Tokens/utils/filterTokensBySearch.ts @@ -0,0 +1,28 @@ +import { Currency } from '@uniswap/sdk-core' +import { doesTokenMatchSearchTerm } from 'uniswap/src/utils/search/doesTokenMatchSearchTerm' + +/** + * Filters tokens based on search criteria (name, symbol, address, chain name). + * This is a pure utility function for client-side filtering. + * + * @param tokens - Array of tokens to filter + * @param searchTerm - Search term to match against + * @param enabledChains - Array of enabled chain IDs to search within + * @returns Filtered array of tokens that match the search criteria + */ +export function filterTokensBySearch({ + tokens, + searchTerm, +}: { + tokens: T[] | undefined + searchTerm: string | undefined | null +}): T[] | undefined { + const trimmedSearchTerm = searchTerm?.trim() + if (!trimmedSearchTerm) { + return tokens + } + + return tokens?.filter((token) => { + return doesTokenMatchSearchTerm(token, trimmedSearchTerm) + }) +} diff --git a/apps/web/src/pages/Portfolio/hooks/usePortfolioAddress.ts b/apps/web/src/pages/Portfolio/hooks/usePortfolioAddress.ts new file mode 100644 index 00000000000..b3909dc2a0d --- /dev/null +++ b/apps/web/src/pages/Portfolio/hooks/usePortfolioAddress.ts @@ -0,0 +1,13 @@ +/* eslint-disable-next-line no-restricted-imports, no-restricted-syntax */ +import { useAccount } from 'hooks/useAccount' + +// This is the address used for the disconnected demo view. It is only used in the disconnected state for the portfolio page. +const DEMO_WALLET_ADDRESS = '0x8796207d877194d97a2c360c041f13887896FC79' + +export function usePortfolioAddress() { + const account = useAccount() + if (!account.address) { + return DEMO_WALLET_ADDRESS + } + return account.address +} diff --git a/apps/web/src/pages/Positions/ClaimFees.anvil.e2e.test.ts b/apps/web/src/pages/Positions/ClaimFees.anvil.e2e.test.ts index 1d50f7b36b5..670e989ec5c 100644 --- a/apps/web/src/pages/Positions/ClaimFees.anvil.e2e.test.ts +++ b/apps/web/src/pages/Positions/ClaimFees.anvil.e2e.test.ts @@ -1,4 +1,4 @@ -import { getPosition } from '@uniswap/client-pools/dist/pools/v1/api-PoolsService_connectquery' +import { getPosition } from '@uniswap/client-data-api/dist/data/v1/api-DataApiService_connectquery' import { createExpectSingleTransaction } from 'playwright/anvil/transactions' import { expect, getTest } from 'playwright/fixtures' import { DEFAULT_TEST_GAS_LIMIT, stubTradingApiEndpoint } from 'playwright/fixtures/tradingApi' diff --git a/apps/web/src/pages/Positions/PositionPage.tsx b/apps/web/src/pages/Positions/PositionPage.tsx index 44a4eda09fb..5f923abfd40 100644 --- a/apps/web/src/pages/Positions/PositionPage.tsx +++ b/apps/web/src/pages/Positions/PositionPage.tsx @@ -1,7 +1,8 @@ import { BigNumber } from '@ethersproject/bignumber' -import { Position, PositionStatus, ProtocolVersion } from '@uniswap/client-pools/dist/pools/v1/types_pb' +import { Position, PositionStatus, ProtocolVersion } from '@uniswap/client-data-api/dist/data/v1/poolTypes_pb' import { Currency, CurrencyAmount, Percent, Price } from '@uniswap/sdk-core' import { GraphQLApi } from '@universe/api' +import { FeatureFlags, useFeatureFlag } from '@universe/gating' import { BreadcrumbNavContainer, BreadcrumbNavLink } from 'components/BreadcrumbNav' import { WrappedLiquidityPositionRangeChart } from 'components/Charts/LiquidityPositionRangeChart/LiquidityPositionRangeChart' import { Dropdown } from 'components/Dropdowns/Dropdown' @@ -56,8 +57,6 @@ import { getChainInfo } from 'uniswap/src/features/chains/chainInfo' import { useSupportedChainId } from 'uniswap/src/features/chains/hooks/useSupportedChainId' import { EVMUniverseChainId, UniverseChainId } from 'uniswap/src/features/chains/types' import { CurrencyInfo } from 'uniswap/src/features/dataApi/types' -import { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' import { useLocalizationContext } from 'uniswap/src/features/language/LocalizationContext' import { isEVMChain } from 'uniswap/src/features/platforms/utils/chains' import { InterfacePageName } from 'uniswap/src/features/telemetry/constants' diff --git a/apps/web/src/pages/Positions/TopPools.tsx b/apps/web/src/pages/Positions/TopPools.tsx index 18a5f69a9ff..92b14c1ebe2 100644 --- a/apps/web/src/pages/Positions/TopPools.tsx +++ b/apps/web/src/pages/Positions/TopPools.tsx @@ -2,6 +2,7 @@ import { PoolSortFields } from 'appGraphql/data/pools/useTopPools' import { OrderDirection } from 'appGraphql/data/util' import { ExploreStatsResponse } from '@uniswap/client-explore/dist/uniswap/explore/v1/service_pb' import { ALL_NETWORKS_ARG } from '@universe/api' +import { FeatureFlags, useFeatureFlag } from '@universe/gating' import { ExternalArrowLink } from 'components/Liquidity/ExternalArrowLink' import { useAccount } from 'hooks/useAccount' import { TopPoolsSection } from 'pages/Positions/TopPoolsSection' @@ -10,8 +11,6 @@ import { useTopPools } from 'state/explore/topPools' import { Flex, useMedia } from 'ui/src' import { useExploreStatsQuery } from 'uniswap/src/data/rest/exploreStats' import { UniverseChainId } from 'uniswap/src/features/chains/types' -import { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' const MAX_BOOSTED_POOLS = 3 diff --git a/apps/web/src/pages/Positions/V2PositionPage.tsx b/apps/web/src/pages/Positions/V2PositionPage.tsx index 40cf64e6e4e..1b79fdd6bc5 100644 --- a/apps/web/src/pages/Positions/V2PositionPage.tsx +++ b/apps/web/src/pages/Positions/V2PositionPage.tsx @@ -1,4 +1,5 @@ -import { ProtocolVersion } from '@uniswap/client-pools/dist/pools/v1/types_pb' +import { ProtocolVersion } from '@uniswap/client-data-api/dist/data/v1/poolTypes_pb' +import { FeatureFlags, useFeatureFlag } from '@universe/gating' import { BreadcrumbNavContainer, BreadcrumbNavLink } from 'components/BreadcrumbNav' import { useGetPoolTokenPercentage } from 'components/Liquidity/hooks/useGetPoolTokenPercentage' import { LiquidityPositionInfo, LiquidityPositionInfoLoader } from 'components/Liquidity/LiquidityPositionInfo' @@ -25,8 +26,6 @@ import { useGetPositionQuery } from 'uniswap/src/data/rest/getPosition' import { getChainInfo } from 'uniswap/src/features/chains/chainInfo' import { useSupportedChainId } from 'uniswap/src/features/chains/hooks/useSupportedChainId' import { UniverseChainId } from 'uniswap/src/features/chains/types' -import { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' import { useLocalizationContext } from 'uniswap/src/features/language/LocalizationContext' import { isEVMChain } from 'uniswap/src/features/platforms/utils/chains' import { useUSDCValue } from 'uniswap/src/features/transactions/hooks/useUSDCPrice' diff --git a/apps/web/src/pages/Positions/index.tsx b/apps/web/src/pages/Positions/index.tsx index 4fb2229256b..43c75054aeb 100644 --- a/apps/web/src/pages/Positions/index.tsx +++ b/apps/web/src/pages/Positions/index.tsx @@ -1,4 +1,5 @@ -import { PositionStatus, ProtocolVersion } from '@uniswap/client-pools/dist/pools/v1/types_pb' +import { PositionStatus, ProtocolVersion } from '@uniswap/client-data-api/dist/data/v1/poolTypes_pb' +import { FeatureFlags, useFeatureFlag } from '@universe/gating' import PROVIDE_LIQUIDITY from 'assets/images/provideLiquidity.png' import tokenLogo from 'assets/images/token-logo.png' import V4_HOOK from 'assets/images/v4Hooks.png' @@ -34,8 +35,6 @@ import { uniswapUrls } from 'uniswap/src/constants/urls' import { useGetPositionsInfiniteQuery } from 'uniswap/src/data/rest/getPositions' import { useEnabledChains } from 'uniswap/src/features/chains/hooks/useEnabledChains' import { UniverseChainId } from 'uniswap/src/features/chains/types' -import { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' import { Platform } from 'uniswap/src/features/platforms/types/Platform' import { InterfacePageName, UniswapEventName } from 'uniswap/src/features/telemetry/constants' import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' diff --git a/apps/web/src/pages/RemoveLiquidity/RemoveLiquidity.anvil.e2e.test.ts b/apps/web/src/pages/RemoveLiquidity/RemoveLiquidity.anvil.e2e.test.ts index 155481bbbbd..60cdd4784fb 100644 --- a/apps/web/src/pages/RemoveLiquidity/RemoveLiquidity.anvil.e2e.test.ts +++ b/apps/web/src/pages/RemoveLiquidity/RemoveLiquidity.anvil.e2e.test.ts @@ -1,4 +1,4 @@ -import { getPosition } from '@uniswap/client-pools/dist/pools/v1/api-PoolsService_connectquery' +import { getPosition } from '@uniswap/client-data-api/dist/data/v1/api-DataApiService_connectquery' import { ONE_MILLION_USDT } from 'playwright/anvil/utils' import { expect, getTest } from 'playwright/fixtures' import { stubTradingApiEndpoint } from 'playwright/fixtures/tradingApi' diff --git a/apps/web/src/pages/RemoveLiquidity/RemoveLiquidityModalContext.tsx b/apps/web/src/pages/RemoveLiquidity/RemoveLiquidityModalContext.tsx index 7dab6197615..0a558e17485 100644 --- a/apps/web/src/pages/RemoveLiquidity/RemoveLiquidityModalContext.tsx +++ b/apps/web/src/pages/RemoveLiquidity/RemoveLiquidityModalContext.tsx @@ -1,4 +1,4 @@ -import { ProtocolVersion } from '@uniswap/client-pools/dist/pools/v1/types_pb' +import { ProtocolVersion } from '@uniswap/client-data-api/dist/data/v1/poolTypes_pb' import { Currency } from '@uniswap/sdk-core' import { getCurrencyWithOptionalUnwrap } from 'components/Liquidity/utils/currency' import { useModalInitialState } from 'hooks/useModalInitialState' diff --git a/apps/web/src/pages/RemoveLiquidity/RemoveLiquidityReview.tsx b/apps/web/src/pages/RemoveLiquidity/RemoveLiquidityReview.tsx index 4e219497812..5ad7f04a845 100644 --- a/apps/web/src/pages/RemoveLiquidity/RemoveLiquidityReview.tsx +++ b/apps/web/src/pages/RemoveLiquidity/RemoveLiquidityReview.tsx @@ -1,4 +1,4 @@ -import { ProtocolVersion } from '@uniswap/client-pools/dist/pools/v1/types_pb' +import { ProtocolVersion } from '@uniswap/client-data-api/dist/data/v1/poolTypes_pb' import { CurrencyAmount } from '@uniswap/sdk-core' import { getLPBaseAnalyticsProperties } from 'components/Liquidity/analytics' import { useGetPoolTokenPercentage } from 'components/Liquidity/hooks/useGetPoolTokenPercentage' diff --git a/apps/web/src/pages/RemoveLiquidity/hooks/useRemoveLiquidityTxAndGasInfo.ts b/apps/web/src/pages/RemoveLiquidity/hooks/useRemoveLiquidityTxAndGasInfo.ts index c64e786d904..7ddeb195330 100644 --- a/apps/web/src/pages/RemoveLiquidity/hooks/useRemoveLiquidityTxAndGasInfo.ts +++ b/apps/web/src/pages/RemoveLiquidity/hooks/useRemoveLiquidityTxAndGasInfo.ts @@ -1,4 +1,4 @@ -import { ProtocolVersion } from '@uniswap/client-pools/dist/pools/v1/types_pb' +import { ProtocolVersion } from '@uniswap/client-data-api/dist/data/v1/poolTypes_pb' import { TradingApi } from '@universe/api' import { getTokenOrZeroAddress } from 'components/Liquidity/utils/currency' import { getProtocolItems } from 'components/Liquidity/utils/protocolVersion' diff --git a/apps/web/src/pages/RouteDefinitions.tsx b/apps/web/src/pages/RouteDefinitions.tsx index 794e24ecd46..675c2e7fd5f 100644 --- a/apps/web/src/pages/RouteDefinitions.tsx +++ b/apps/web/src/pages/RouteDefinitions.tsx @@ -1,3 +1,4 @@ +import { FeatureFlags, useFeatureFlag } from '@universe/gating' import { getExploreDescription, getExploreTitle } from 'pages/getExploreTitle' import { getAddLiquidityPageTitle, getPositionPageDescription, getPositionPageTitle } from 'pages/getPositionPageTitle' // High-traffic pages (index and /swap) should not be lazy-loaded. @@ -7,8 +8,6 @@ import { lazy, ReactNode, Suspense, useMemo } from 'react' import { matchPath, Navigate, Route, Routes, useLocation } from 'react-router' import { CHROME_EXTENSION_UNINSTALL_URL_PATH } from 'uniswap/src/constants/urls' import { WRAPPED_SOL_ADDRESS_SOLANA } from 'uniswap/src/features/chains/svm/defaults' -import { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' import { EXTENSION_PASSKEY_AUTH_PATH } from 'uniswap/src/features/passkey/constants' import i18n from 'uniswap/src/i18n' import { isBrowserRouterEnabled } from 'utils/env' diff --git a/apps/web/src/pages/Swap/Buy/Buy.e2e.test.ts b/apps/web/src/pages/Swap/Buy/Buy.e2e.test.ts index 5a725eb6dc7..2feb4d89169 100644 --- a/apps/web/src/pages/Swap/Buy/Buy.e2e.test.ts +++ b/apps/web/src/pages/Swap/Buy/Buy.e2e.test.ts @@ -1,5 +1,7 @@ import { expect, getTest } from 'playwright/fixtures' +import { stubTradingApiEndpoint } from 'playwright/fixtures/tradingApi' import { Mocks } from 'playwright/mocks/mocks' +import { uniswapUrls } from 'uniswap/src/constants/urls' import { TestID } from 'uniswap/src/test/fixtures/testIDs' const test = getTest() @@ -20,6 +22,7 @@ test.describe('Buy Crypto Form', () => { }) } + await stubTradingApiEndpoint({ page, endpoint: uniswapUrls.tradingApiPaths.quote }) await page.goto('/buy') // Wait for wallet to be connected diff --git a/apps/web/src/pages/Swap/Buy/hooks.ts b/apps/web/src/pages/Swap/Buy/hooks.ts index 975c5d00a40..41d10539e82 100644 --- a/apps/web/src/pages/Swap/Buy/hooks.ts +++ b/apps/web/src/pages/Swap/Buy/hooks.ts @@ -1,4 +1,5 @@ import { useMeldSupportedCurrencyToCurrencyInfo } from 'appGraphql/data/types' +import { FeatureFlags, useFeatureFlag } from '@universe/gating' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' import { useSearchParams } from 'react-router' @@ -17,8 +18,6 @@ import { FORCountry, OffRampTransferDetailsRequest, } from 'uniswap/src/features/fiatOnRamp/types' -import { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' // biome-ignore lint/style/noRestrictedImports: Buy hooks need direct SDK imports import { getFiatCurrencyComponents } from 'utilities/src/format/localeBased' diff --git a/apps/web/src/pages/Swap/Fees.anvil.e2e.test.ts b/apps/web/src/pages/Swap/Fees.anvil.e2e.test.ts index cda699f73ca..9ba01796841 100644 --- a/apps/web/src/pages/Swap/Fees.anvil.e2e.test.ts +++ b/apps/web/src/pages/Swap/Fees.anvil.e2e.test.ts @@ -10,6 +10,7 @@ const test = getTest({ withAnvil: true }) test.describe('Fees', () => { test('swaps ETH for USDC exact-in with swap fee', async ({ page, anvil }) => { await stubTradingApiEndpoint({ page, endpoint: uniswapUrls.tradingApiPaths.swap }) + await stubTradingApiEndpoint({ page, endpoint: uniswapUrls.tradingApiPaths.quote }) await page.goto(`/swap?inputCurrency=ETH&outputCurrency=${USDC_MAINNET.address}`) diff --git a/apps/web/src/pages/Swap/Fees.e2e.test.ts b/apps/web/src/pages/Swap/Fees.e2e.test.ts index 2c6564b3a16..c7b33190f5d 100644 --- a/apps/web/src/pages/Swap/Fees.e2e.test.ts +++ b/apps/web/src/pages/Swap/Fees.e2e.test.ts @@ -1,15 +1,16 @@ +import { Layers, PriceUxUpdateProperties } from '@universe/gating' import { expect, getTest } from 'playwright/fixtures' import { stubTradingApiEndpoint } from 'playwright/fixtures/tradingApi' import { Mocks } from 'playwright/mocks/mocks' import { DAI, USDC_MAINNET } from 'uniswap/src/constants/tokens' import { uniswapUrls } from 'uniswap/src/constants/urls' -import { Layers, PriceUxUpdateProperties } from 'uniswap/src/features/gating/experiments' import { TestID } from 'uniswap/src/test/fixtures/testIDs' const test = getTest() test.describe('Fees', () => { test('should not display fee on swaps without fees', async ({ page }) => { + await stubTradingApiEndpoint({ page, endpoint: uniswapUrls.tradingApiPaths.quote }) await page.goto(`/swap?inputCurrency=${DAI.address}&outputCurrency=${USDC_MAINNET.address}`) // Enter amount @@ -23,6 +24,7 @@ test.describe('Fees', () => { }) test('displays UniswapX fee in UI', async ({ page }) => { + await stubTradingApiEndpoint({ page, endpoint: uniswapUrls.tradingApiPaths.quote }) await stubTradingApiEndpoint({ page, endpoint: uniswapUrls.tradingApiPaths.swap }) await page.goto( diff --git a/apps/web/src/pages/Swap/Limit/LimitForm.tsx b/apps/web/src/pages/Swap/Limit/LimitForm.tsx index 6ae17550be7..762c656b716 100644 --- a/apps/web/src/pages/Swap/Limit/LimitForm.tsx +++ b/apps/web/src/pages/Swap/Limit/LimitForm.tsx @@ -1,5 +1,6 @@ import { Currency, CurrencyAmount, Token } from '@uniswap/sdk-core' import { UNIVERSAL_ROUTER_ADDRESS, UniversalRouterVersion } from '@uniswap/universal-router-sdk' +import { FeatureFlags, useFeatureFlag } from '@universe/gating' import { useAccountDrawer } from 'components/AccountDrawer/MiniPortfolio/hooks' import { OpenLimitOrdersButton } from 'components/AccountDrawer/MiniPortfolio/Limits/OpenLimitOrdersButton' import { MenuStateVariant, useSetMenu } from 'components/AccountDrawer/menuState' @@ -40,8 +41,6 @@ import { uniswapUrls } from 'uniswap/src/constants/urls' import { LIMIT_SUPPORTED_CHAINS } from 'uniswap/src/features/chains/chainInfo' import { useIsSupportedChainId } from 'uniswap/src/features/chains/hooks/useSupportedChainId' import { getPrimaryStablecoin } from 'uniswap/src/features/chains/utils' -import { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' import { useLocalizationContext } from 'uniswap/src/features/language/LocalizationContext' import { isEVMChain } from 'uniswap/src/features/platforms/utils/chains' import { useIsMismatchAccountQuery } from 'uniswap/src/features/smartWallet/mismatch/hooks' diff --git a/apps/web/src/pages/Swap/Logging.anvil.e2e.test.ts b/apps/web/src/pages/Swap/Logging.anvil.e2e.test.ts index 0c679d6bb32..ff6221750f6 100644 --- a/apps/web/src/pages/Swap/Logging.anvil.e2e.test.ts +++ b/apps/web/src/pages/Swap/Logging.anvil.e2e.test.ts @@ -16,6 +16,7 @@ test.describe('Time-to-swap logging', () => { anvil, }) => { await stubTradingApiEndpoint({ page, endpoint: uniswapUrls.tradingApiPaths.swap }) + await stubTradingApiEndpoint({ page, endpoint: uniswapUrls.tradingApiPaths.quote }) await page.goto(`/swap?inputCurrency=ETH&outputCurrency=${USDC_MAINNET.address}`) const expectMultipleTransactions = createExpectMultipleTransactions({ diff --git a/apps/web/src/pages/Swap/Send/NewAddressSpeedBump.test.tsx b/apps/web/src/pages/Swap/Send/NewAddressSpeedBump.test.tsx index 7c2a01a89ad..35ef25f0b89 100644 --- a/apps/web/src/pages/Swap/Send/NewAddressSpeedBump.test.tsx +++ b/apps/web/src/pages/Swap/Send/NewAddressSpeedBump.test.tsx @@ -22,8 +22,9 @@ const mockSendContext: SendContextType = { setSendState: vi.fn(), } -vi.mock('uniswap/src/features/gating/hooks', () => { +vi.mock('@universe/gating', async (importOriginal) => { return { + ...(await importOriginal()), useFeatureFlag: vi.fn(), getFeatureFlag: vi.fn(), } diff --git a/apps/web/src/pages/Swap/Send/SendForm.tsx b/apps/web/src/pages/Swap/Send/SendForm.tsx index 064fc5844a0..69a680ca15e 100644 --- a/apps/web/src/pages/Swap/Send/SendForm.tsx +++ b/apps/web/src/pages/Swap/Send/SendForm.tsx @@ -13,7 +13,6 @@ import { useTranslation } from 'react-i18next' import { useSendContext } from 'state/send/SendContext' import { CurrencyState } from 'state/swap/types' import { Button, Flex } from 'ui/src' -import { checkIsBridgedAsset } from 'uniswap/src/components/BridgedAsset/utils' import { GetHelpHeader } from 'uniswap/src/components/dialog/GetHelpHeader' import { uniswapUrls } from 'uniswap/src/constants/urls' import { useActiveAddress, useConnectionStatus } from 'uniswap/src/features/accounts/store/hooks' @@ -114,7 +113,7 @@ function SendFormInner({ disableTokenInputs = false, onCurrencyChange }: SendFor const { tokenWarningDismissed: isCompatibleAddressDismissed } = useDismissedCompatibleAddressWarnings( inputCurrencyInfo?.currency, ) - const isUnichainBridgedAsset = checkIsBridgedAsset(inputCurrencyInfo ?? undefined) && !isCompatibleAddressDismissed + const isUnichainBridgedAsset = Boolean(inputCurrencyInfo?.isBridged) && !isCompatibleAddressDismissed const { isSmartContractAddress, loading: loadingSmartContractAddress } = useIsSmartContractAddress( recipientData?.address, diff --git a/apps/web/src/pages/Swap/Swap.anvil.e2e.test.ts b/apps/web/src/pages/Swap/Swap.anvil.e2e.test.ts index cc58e9a32fd..974ec8a1d6c 100644 --- a/apps/web/src/pages/Swap/Swap.anvil.e2e.test.ts +++ b/apps/web/src/pages/Swap/Swap.anvil.e2e.test.ts @@ -48,6 +48,7 @@ test.describe('Swap', () => { test('should swap ETH to USDC', async ({ page, anvil }) => { await stubTradingApiEndpoint({ page, endpoint: uniswapUrls.tradingApiPaths.swap }) + await stubTradingApiEndpoint({ page, endpoint: uniswapUrls.tradingApiPaths.quote }) await page.goto('/swap') await page.getByTestId(TestID.ChooseOutputToken).click() @@ -68,6 +69,8 @@ test.describe('Swap', () => { }) test('should be able to swap token with FOT warning via TDP', async ({ page, anvil }) => { + await stubTradingApiEndpoint({ page, endpoint: uniswapUrls.tradingApiPaths.quote }) + await page.route(`${uniswapUrls.tradingApiUrl}/v1/swap`, async (route) => { const request = route.request() const postData = request.postDataJSON() @@ -111,6 +114,8 @@ test.describe('Swap', () => { }) test('should bridge from ETH to L2', async ({ page, anvil }) => { + await stubTradingApiEndpoint({ page, endpoint: uniswapUrls.tradingApiPaths.swap }) + await stubTradingApiEndpoint({ page, endpoint: uniswapUrls.tradingApiPaths.quote }) await page.goto(`/swap?inputCurrency=ETH`) await page.getByTestId(TestID.ChooseOutputToken).click() await page.getByTestId(`token-option-${UniverseChainId.Base}-ETH`).first().click() @@ -122,6 +127,7 @@ test.describe('Swap', () => { ).toBeVisible() await page.getByTestId(TestID.AmountInputIn).click() await page.getByTestId(TestID.AmountInputIn).fill('1') + await expect(page.getByTestId(TestID.ReviewSwap)).toBeEnabled() await page.getByTestId(TestID.ReviewSwap).click() await page.getByTestId(TestID.Confirm).click() await page.getByTestId(TestID.Swap).click() @@ -198,7 +204,7 @@ test.describe('Swap', () => { await page.getByTestId(TestID.ReviewSwap).click() await page.getByTestId(TestID.Swap).click() - await expect(page.getByText('Sign Message')).not.toBeVisible() + await expect(page.getByText('Sign message')).not.toBeVisible() await expect(page.getByText('Approved')).toBeVisible() await expect(page.getByText('Swapped')).toBeVisible() }) @@ -239,48 +245,48 @@ test.describe('Swap', () => { await page.getByTestId(TestID.Swap).click() await expect(page.getByText('Reset USDT limit')).toBeVisible() - await expect(page.getByText('Sign Message')).toBeVisible() + await expect(page.getByText('Sign message')).toBeVisible() await expect(page.getByText('Approved')).toBeVisible() await expect(page.getByText('Swapped')).toBeVisible() }) test('prompts signature when existing permit approval is expired', async ({ page, anvil }) => { + await stubTradingApiEndpoint({ page, endpoint: uniswapUrls.tradingApiPaths.swap }) + await anvil.setErc20Balance({ address: assume0xAddress(USDT.address), balance: ONE_MILLION_USDT }) await anvil.setPermit2Allowance({ owner: TEST_WALLET_ADDRESS, token: assume0xAddress(USDT.address), spender: assume0xAddress(UNIVERSAL_ROUTER_ADDRESS(UniversalRouterVersion.V2_0, UniverseChainId.Mainnet)), expiration: Math.floor((Date.now() - 1) / 1000), }) - await stubTradingApiEndpoint({ page, endpoint: uniswapUrls.tradingApiPaths.swap }) - await anvil.setErc20Balance({ address: assume0xAddress(USDT.address), balance: ONE_MILLION_USDT }) await page.goto(`/swap?inputCurrency=${USDT.address}&outputCurrency=ETH`) await page.getByTestId(TestID.AmountInputIn).click() await page.getByTestId(TestID.AmountInputIn).fill('10') await page.getByTestId(TestID.ReviewSwap).click() await page.getByTestId(TestID.Swap).click() + await expect(page.getByText('Sign message')).toBeVisible() await expect(page.getByText('Approved')).toBeVisible() - await expect(page.getByText('Sign Message')).toBeVisible() await expect(page.getByText('Swapped')).toBeVisible() }) test('prompts signature when existing permit approval amount is too low', async ({ page, anvil }) => { + await stubTradingApiEndpoint({ page, endpoint: uniswapUrls.tradingApiPaths.swap }) + await anvil.setErc20Balance({ address: assume0xAddress(USDT.address), balance: ONE_MILLION_USDT }) await anvil.setPermit2Allowance({ owner: TEST_WALLET_ADDRESS, token: assume0xAddress(USDT.address), spender: assume0xAddress(UNIVERSAL_ROUTER_ADDRESS(UniversalRouterVersion.V2_0, UniverseChainId.Mainnet)), amount: 1n, }) - await stubTradingApiEndpoint({ page, endpoint: uniswapUrls.tradingApiPaths.swap }) - await anvil.setErc20Balance({ address: assume0xAddress(USDT.address), balance: ONE_MILLION_USDT }) await page.goto(`/swap?inputCurrency=${USDT.address}&outputCurrency=ETH`) await page.getByTestId(TestID.AmountInputIn).click() await page.getByTestId(TestID.AmountInputIn).fill('10') await page.getByTestId(TestID.ReviewSwap).click() await page.getByTestId(TestID.Swap).click() + await expect(page.getByText('Sign message')).toBeVisible() await expect(page.getByText('Approved')).toBeVisible() - await expect(page.getByText('Sign Message')).toBeVisible() await expect(page.getByText('Swapped')).toBeVisible() }) }) diff --git a/apps/web/src/pages/Swap/index.tsx b/apps/web/src/pages/Swap/index.tsx index c17bb5ba55a..a06928cb810 100644 --- a/apps/web/src/pages/Swap/index.tsx +++ b/apps/web/src/pages/Swap/index.tsx @@ -1,5 +1,6 @@ import { PrefetchBalancesWrapper } from 'appGraphql/data/apollo/AdaptiveTokenBalancesProvider' import type { Currency } from '@uniswap/sdk-core' +import { FeatureFlags, useFeatureFlag } from '@universe/gating' import { useAccountDrawer } from 'components/AccountDrawer/MiniPortfolio/hooks' import { SwapBottomCard } from 'components/SwapBottomCard' import { SwitchLocaleLink } from 'components/SwitchLocaleLink' @@ -28,10 +29,8 @@ import type { AppTFunction } from 'ui/src/i18n/types' import { zIndexes } from 'ui/src/theme' import { useUniswapContext } from 'uniswap/src/contexts/UniswapContext' import { useIsModeMismatch } from 'uniswap/src/features/chains/hooks/useEnabledChains' -import type { UniverseChainId } from 'uniswap/src/features/chains/types' +import { UniverseChainId } from 'uniswap/src/features/chains/types' import { RampDirection } from 'uniswap/src/features/fiatOnRamp/types' -import { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' import { useGetPasskeyAuthStatus } from 'uniswap/src/features/passkey/hooks/useGetPasskeyAuthStatus' import { WebFORNudgeProvider } from 'uniswap/src/features/providers/webForNudgeProvider' import { InterfaceEventName, InterfacePageName, ModalName } from 'uniswap/src/features/telemetry/constants' @@ -49,6 +48,7 @@ import { SwapDependenciesStoreContextProvider } from 'uniswap/src/features/trans import { SwapFormStoreContextProvider } from 'uniswap/src/features/transactions/swap/stores/swapFormStore/SwapFormStoreContextProvider' import type { SwapFormState } from 'uniswap/src/features/transactions/swap/stores/swapFormStore/types' import { currencyToAsset } from 'uniswap/src/features/transactions/swap/utils/asset' +import { TransactionState } from 'uniswap/src/features/transactions/types/transactionState' import { CurrencyField } from 'uniswap/src/types/currency' import { SwapTab } from 'uniswap/src/types/screens/interface' import { isMobileWeb } from 'utilities/src/platform' @@ -66,7 +66,8 @@ export default function SwapPage() { const { initialInputCurrency, initialOutputCurrency, - initialChainId, + initialInputChainId, + initialOutputChainId, initialTypedValue, initialField, triggerConnect, @@ -84,9 +85,10 @@ export default function SwapPage() { void initialInputCurrency?: Currency initialOutputCurrency?: Currency + initialOutputChainId?: UniverseChainId initialTypedValue?: string initialIndependentField?: CurrencyField syncTabToUrl: boolean @@ -137,7 +158,7 @@ export function Swap({ const { isSwapTokenSelectorOpen, swapOutputChainId } = useUniswapContext() const isExplorePage = useIsPage(PageType.EXPLORE) - const isModeMismatch = useIsModeMismatch(chainId) + const isModeMismatch = useIsModeMismatch(initialInputChainId) const isSharedSwapDisabled = isModeMismatch && isExplorePage const input = currencyToAsset(initialInputCurrency) @@ -153,11 +174,16 @@ export function Swap({ selectingCurrencyField: isSwapTokenSelectorOpen ? CurrencyField.OUTPUT : undefined, selectingCurrencyChainId: swapOutputChainId, skipFocusOnCurrencyField: isMobileWeb, - filteredChainIdsOverride: usePersistedFilteredChainIds ? persistedFilteredChainIds : undefined, + filteredChainIdsOverride: getFilteredChainIdsOverride({ + initialInputChainId, + initialOutputChainId, + usePersistedFilteredChainIds, + persistedFilteredChainIds, + }), }) return ( - + ): AnvilConfig { port: overrides?.port ?? parseInt(process.env.ANVIL_PORT ?? '8545'), host: overrides?.host ?? '127.0.0.1', forkUrl: overrides?.forkUrl ?? buildForkUrl(), - timeout: overrides?.timeout ?? 5000, - healthCheckInterval: overrides?.healthCheckInterval ?? 10000, + timeout: overrides?.timeout ?? 10_000, + healthCheckInterval: overrides?.healthCheckInterval ?? 10_000, logFile: overrides?.logFile ?? path.join(process.cwd(), `anvil-test-${process.pid}.log`), } } diff --git a/apps/web/src/playwright/fixtures/anvil.ts b/apps/web/src/playwright/fixtures/anvil.ts index d80cd8c8bc4..91fcab8dffb 100644 --- a/apps/web/src/playwright/fixtures/anvil.ts +++ b/apps/web/src/playwright/fixtures/anvil.ts @@ -10,7 +10,9 @@ import { ZERO_ADDRESS } from 'uniswap/src/constants/misc' import { DAI, USDT } from 'uniswap/src/constants/tokens' import { UniverseChainId } from 'uniswap/src/features/chains/types' import { assume0xAddress } from 'utils/wagmi' -import { Address, erc20Abi } from 'viem' +import { type Address, erc20Abi } from 'viem' + +const SNAPSHOTS_ENABLED = process.env.ENABLE_ANVIL_SNAPSHOTS === 'true' class WalletError extends Error { code?: number @@ -162,6 +164,11 @@ const createAnvilClient = () => { await client.mine({ blocks: 1 }) }, + /** + * @deprecated + * Wagmi submits transactions to Anvil via the RPC interface so this function no longer intercepts + * the requests. Use createRejectableMockConnector instead. + */ async setTransactionRejection() { // Override the wallet actions to reject transactions const originalRequest = client.request @@ -219,7 +226,9 @@ export const test = base.extend<{ anvil: AnvilClient; delegateToZeroAddress?: vo // Take snapshot for test isolation let snapshotId: `0x${string}` | undefined try { - snapshotId = await testAnvil.snapshot() + if (SNAPSHOTS_ENABLED) { + snapshotId = await testAnvil.snapshot() + } } catch (error) { if (isTimeoutError(error)) { // Anvil timed out during snapshot, restart and retry diff --git a/apps/web/src/playwright/fixtures/tradingApi.ts b/apps/web/src/playwright/fixtures/tradingApi.ts index b84f79de3b1..0092111e8b4 100644 --- a/apps/web/src/playwright/fixtures/tradingApi.ts +++ b/apps/web/src/playwright/fixtures/tradingApi.ts @@ -1,6 +1,6 @@ // biome-ignore lint/style/noRestrictedImports: Trading API fixtures need direct Playwright imports import { test as base } from '@playwright/test' -import { Page } from 'playwright/test' +import { type Page } from 'playwright/test' import { uniswapUrls } from 'uniswap/src/constants/urls' export const DEFAULT_TEST_GAS_LIMIT = '20000000' @@ -54,7 +54,15 @@ export async function stubTradingApiEndpoint({ }) const responseText = await response.text() - let responseJson = JSON.parse(responseText) + let responseJson + try { + responseJson = JSON.parse(responseText) + } catch (parseError) { + throw new Error(`Failed to parse trading API response for ${endpoint}. Response: ${responseText}`, { + cause: parseError, + }) + } + // Set a high gas limit to avoid OutOfGas if (endpoint === uniswapUrls.tradingApiPaths.swap) { responseJson.swap.gasLimit = DEFAULT_TEST_GAS_LIMIT diff --git a/apps/web/src/setupTests.ts b/apps/web/src/setupTests.ts index 9ede1b601e6..27620e661a8 100644 --- a/apps/web/src/setupTests.ts +++ b/apps/web/src/setupTests.ts @@ -10,6 +10,7 @@ import { WalletName, WalletReadyState, } from '@solana/wallet-adapter-base' +import { useFeatureFlag } from '@universe/gating' import { useWeb3React } from '@web3-react/core' import { config as loadEnv } from 'dotenv' import failOnConsole from 'jest-fail-on-console' @@ -19,7 +20,6 @@ import { Readable } from 'stream' import { toBeVisible } from 'test-utils/matchers' import { mocked } from 'test-utils/mocked' import { UniverseChainId } from 'uniswap/src/features/chains/types' -import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' import { setupi18n } from 'uniswap/src/i18n/i18n-setup-interface' import { mockLocalizationContext } from 'uniswap/src/test/mocks/locale' import { TextDecoder, TextEncoder } from 'util' @@ -390,10 +390,9 @@ failOnConsole({ }, }) -vi.mock('uniswap/src/features/gating/hooks', async () => { - const genMock = await vi.importActual('uniswap/src/features/gating/hooks') +vi.mock('@universe/gating', async (importOriginal) => { return { - ...genMock, + ...(await importOriginal()), useFeatureFlag: vi.fn(), useFeatureFlagWithLoading: vi.fn(), getFeatureFlag: vi.fn(), diff --git a/apps/web/src/state/activity/polling/transactions.ts b/apps/web/src/state/activity/polling/transactions.ts index 4bbbb3fae73..417397b7707 100644 --- a/apps/web/src/state/activity/polling/transactions.ts +++ b/apps/web/src/state/activity/polling/transactions.ts @@ -1,4 +1,5 @@ import { TradingApi } from '@universe/api' +import { FeatureFlags, useFeatureFlag } from '@universe/gating' import { useAccount } from 'hooks/useAccount' import useCurrentBlockTimestamp from 'hooks/useCurrentBlockTimestamp' import useBlockNumber from 'lib/hooks/useBlockNumber' @@ -13,8 +14,6 @@ import { isPendingTx } from 'state/transactions/utils' import { TradingApiClient } from 'uniswap/src/data/apiClients/tradingApi/TradingApiClient' import { getChainInfo } from 'uniswap/src/features/chains/chainInfo' import { RetryOptions, UniverseChainId } from 'uniswap/src/features/chains/types' -import { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' import { InterfaceEventName } from 'uniswap/src/features/telemetry/constants' import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' import { checkedTransaction } from 'uniswap/src/features/transactions/slice' diff --git a/apps/web/src/state/explore/protocolStats.test.tsx b/apps/web/src/state/explore/protocolStats.test.tsx index 0c94c9521fb..55ee77f344a 100644 --- a/apps/web/src/state/explore/protocolStats.test.tsx +++ b/apps/web/src/state/explore/protocolStats.test.tsx @@ -1,14 +1,13 @@ import { ProtocolStatsResponse } from '@uniswap/client-explore/dist/uniswap/explore/v1/service_pb' +import { useFeatureFlagWithLoading } from '@universe/gating' import { ExploreContext } from 'state/explore' import { use24hProtocolVolume, useDailyTVLWithChange } from 'state/explore/protocolStats' import { render, screen } from 'test-utils/render' -import * as GatingHooks from 'uniswap/src/features/gating/hooks' import type { Mock } from 'vitest' -vi.mock('uniswap/src/features/gating/hooks', async () => { - const actual = await vi.importActual('uniswap/src/features/gating/hooks') +vi.mock('@universe/gating', async (importOriginal) => { return { - ...actual, + ...(await importOriginal()), useFeatureFlagWithLoading: vi.fn(() => ({ value: true, isLoading: false })), // Ensure mock returns value immediately } }) @@ -57,7 +56,7 @@ const TestComponent24HrTVL = () => { } beforeEach(() => { - ;(GatingHooks.useFeatureFlagWithLoading as Mock).mockReturnValue({ value: true, isLoading: false }) + ;(useFeatureFlagWithLoading as Mock).mockReturnValue({ value: true, isLoading: false }) }) describe('use24hProtocolVolume', () => { diff --git a/apps/web/src/state/explore/topPools.ts b/apps/web/src/state/explore/topPools.ts index b8f97566868..980f69ad586 100644 --- a/apps/web/src/state/explore/topPools.ts +++ b/apps/web/src/state/explore/topPools.ts @@ -5,8 +5,8 @@ import { PoolTableSortState, } from 'appGraphql/data/pools/useTopPools' import { OrderDirection } from 'appGraphql/data/util' +import { ProtocolVersion } from '@uniswap/client-data-api/dist/data/v1/poolTypes_pb' import { ExploreStatsResponse, PoolStats } from '@uniswap/client-explore/dist/uniswap/explore/v1/service_pb' -import { ProtocolVersion } from '@uniswap/client-pools/dist/pools/v1/types_pb' import { exploreSearchStringAtom } from 'components/Tokens/state' import { useAtomValue } from 'jotai/utils' import { useContext, useMemo } from 'react' diff --git a/apps/web/src/state/limit/hooks.ts b/apps/web/src/state/limit/hooks.ts index d63821e61f4..5e04da2d683 100644 --- a/apps/web/src/state/limit/hooks.ts +++ b/apps/web/src/state/limit/hooks.ts @@ -1,4 +1,5 @@ import { Currency, CurrencyAmount, Price, TradeType } from '@uniswap/sdk-core' +import { FeatureFlags, useFeatureFlag } from '@universe/gating' import { useAccount } from 'hooks/useAccount' import JSBI from 'jsbi' import { useCurrencyBalances } from 'lib/hooks/useCurrencyBalance' @@ -13,8 +14,6 @@ import { getUSDCostPerGas, isClassicTrade } from 'state/routing/utils' import { useSwapAndLimitContext } from 'state/swap/useSwapContext' import { nativeOnChain } from 'uniswap/src/constants/tokens' import { getStablecoinsForChain, isUniverseChainId } from 'uniswap/src/features/chains/utils' -import { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' import { isEVMChain, isSVMChain } from 'uniswap/src/features/platforms/utils/chains' import { CurrencyField } from 'uniswap/src/types/currency' diff --git a/apps/web/src/state/routing/useRoutingAPITrade.test.ts b/apps/web/src/state/routing/useRoutingAPITrade.test.ts index f535380851d..208225f4531 100644 --- a/apps/web/src/state/routing/useRoutingAPITrade.test.ts +++ b/apps/web/src/state/routing/useRoutingAPITrade.test.ts @@ -24,8 +24,9 @@ vi.mock('./slice', () => { } }) vi.mock('state/user/hooks') -vi.mock('uniswap/src/features/gating/hooks', () => { +vi.mock('@universe/gating', async (importOriginal) => { return { + ...(await importOriginal()), useFeatureFlag: vi.fn(), useExperimentValue: vi.fn(), getFeatureFlag: vi.fn(), diff --git a/apps/web/src/state/sagas/liquidity/liquiditySaga.ts b/apps/web/src/state/sagas/liquidity/liquiditySaga.ts index 492091bf334..1f96ff38e72 100644 --- a/apps/web/src/state/sagas/liquidity/liquiditySaga.ts +++ b/apps/web/src/state/sagas/liquidity/liquiditySaga.ts @@ -5,7 +5,6 @@ import { import { getLiquidityEventName } from 'components/Liquidity/analytics' import { popupRegistry } from 'components/Popups/registry' import { PopupType } from 'components/Popups/types' -import type { HandleOnChainStepParams } from 'state/sagas/transactions/utils' import { getDisplayableError, handleApprovalTransactionStep, @@ -34,7 +33,7 @@ import type { } from 'uniswap/src/features/transactions/liquidity/steps/migrate' import type { LiquidityAction, ValidatedLiquidityTxContext } from 'uniswap/src/features/transactions/liquidity/types' import { LiquidityTransactionType } from 'uniswap/src/features/transactions/liquidity/types' -import type { TransactionStep } from 'uniswap/src/features/transactions/steps/types' +import type { HandleOnChainStepParams, TransactionStep } from 'uniswap/src/features/transactions/steps/types' import { TransactionStepType } from 'uniswap/src/features/transactions/steps/types' import type { SetCurrentStepFn } from 'uniswap/src/features/transactions/swap/types/swapCallback' import type { diff --git a/apps/web/src/state/sagas/root.ts b/apps/web/src/state/sagas/root.ts index 7ebf0f49ac1..88b64fae9a5 100644 --- a/apps/web/src/state/sagas/root.ts +++ b/apps/web/src/state/sagas/root.ts @@ -4,10 +4,12 @@ import { swapSaga } from 'state/sagas/transactions/swapSaga' import { watchTransactionsSaga } from 'state/sagas/transactions/watcherSaga' import { wrapSaga } from 'state/sagas/transactions/wrapSaga' import { call, spawn } from 'typed-redux-saga' +import { planSaga } from 'uniswap/src/features/transactions/swap/plan/planSaga' import { waitForRehydration } from 'uniswap/src/utils/saga' const sagas = [ swapSaga.wrappedSaga, + planSaga.wrappedSaga, wrapSaga.wrappedSaga, liquiditySaga.wrappedSaga, watchTransactionsSaga.wrappedSaga, diff --git a/apps/web/src/state/sagas/transactions/5792.ts b/apps/web/src/state/sagas/transactions/5792.ts index 30f39529ce0..f10eed438aa 100644 --- a/apps/web/src/state/sagas/transactions/5792.ts +++ b/apps/web/src/state/sagas/transactions/5792.ts @@ -4,12 +4,12 @@ import { popupRegistry } from 'components/Popups/registry' import { PopupType } from 'components/Popups/types' import { wagmiConfig } from 'components/Web3Provider/wagmiConfig' import { getRoutingForTransaction } from 'state/activity/utils' -import { getSigner, HandleOnChainStepParams, watchForInterruption } from 'state/sagas/transactions/utils' +import { getSigner, watchForInterruption } from 'state/sagas/transactions/utils' import { handleGetCapabilities } from 'state/walletCapabilities/lib/handleGetCapabilities' import { setCapabilitiesByChain } from 'state/walletCapabilities/reducer' import { call, put } from 'typed-redux-saga' import { addTransaction } from 'uniswap/src/features/transactions/slice' -import { OnChainTransactionStepBatched } from 'uniswap/src/features/transactions/steps/types' +import { HandleOnChainStepParams, OnChainTransactionStepBatched } from 'uniswap/src/features/transactions/steps/types' import { InterfaceTransactionDetails, TransactionOriginType, diff --git a/apps/web/src/state/sagas/transactions/solana.ts b/apps/web/src/state/sagas/transactions/solana.ts index 7451e96e51a..2ff23ceb5ac 100644 --- a/apps/web/src/state/sagas/transactions/solana.ts +++ b/apps/web/src/state/sagas/transactions/solana.ts @@ -5,9 +5,10 @@ import { PopupType } from 'components/Popups/types' import { signSolanaTransactionWithCurrentWallet } from 'components/Web3Provider/signSolanaTransaction' import store from 'state' import { getSwapTransactionInfo } from 'state/sagas/transactions/utils' -import { call } from 'typed-redux-saga' +import { call, delay, spawn } from 'typed-redux-saga' import { JupiterApiClient } from 'uniswap/src/data/apiClients/jupiterApi/JupiterFetchClient' import { UniverseChainId } from 'uniswap/src/features/chains/types' +import { refetchRestQueriesViaOnchainOverrideVariant } from 'uniswap/src/features/portfolio/portfolioUpdates/rest/refetchRestQueriesViaOnchainOverrideVariantSaga' import { SwapEventName } from 'uniswap/src/features/telemetry/constants/features' import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' import { JupiterExecuteError } from 'uniswap/src/features/transactions/errors' @@ -16,9 +17,15 @@ import { ExtractedBaseTradeAnalyticsProperties } from 'uniswap/src/features/tran import { SolanaTrade } from 'uniswap/src/features/transactions/swap/types/solana' import { ValidatedSolanaSwapTxAndGasInfo } from 'uniswap/src/features/transactions/swap/types/swapTxAndGasInfo' import { SwapEventType, timestampTracker } from 'uniswap/src/features/transactions/swap/utils/SwapEventTimestampTracker' -import { TransactionOriginType, TransactionStatus } from 'uniswap/src/features/transactions/types/transactionDetails' +import { + InterfaceBaseTransactionDetails, + SolanaTransactionDetails, + TransactionOriginType, + TransactionStatus, +} from 'uniswap/src/features/transactions/types/transactionDetails' import { SignerMnemonicAccountDetails } from 'uniswap/src/features/wallet/types/AccountDetails' import { tryCatch } from 'utilities/src/errors' +import { ONE_SECOND_MS } from 'utilities/src/time/time' type JupiterSwapParams = { account: SignerMnemonicAccountDetails @@ -49,27 +56,52 @@ async function signAndSendJupiterSwap({ return result } -function updateAppState({ hash, trade, from }: { hash: string; trade: SolanaTrade; from: string }) { +function* refetchBalancesWithDelay({ + transaction, + activeAddress, +}: { + transaction: SolanaTransactionDetails + activeAddress: string +}) { + // Wait 3 seconds before refetching. + // This is because at this point the transaction hasn't been fully confirmed yet, + // and it should take 1-2 seconds for the balance to update onchain. + yield* delay(3 * ONE_SECOND_MS) + + yield* call(refetchRestQueriesViaOnchainOverrideVariant, { + transaction, + activeAddress, + apolloClient: null, + }) +} + +function* updateAppState({ hash, trade, from }: { hash: string; trade: SolanaTrade; from: string }) { const typeInfo = getSwapTransactionInfo(trade) - store.dispatch( - addTransaction({ - from, - typeInfo, - hash, - chainId: UniverseChainId.Solana, - routing: TradingApi.Routing.JUPITER, - status: TransactionStatus.Success, - addedTime: Date.now(), - id: hash, - transactionOriginType: TransactionOriginType.Internal, - options: { - request: {}, - }, - }), - ) + const transaction: SolanaTransactionDetails = { + from, + typeInfo, + hash, + chainId: UniverseChainId.Solana, + routing: TradingApi.Routing.JUPITER, + status: TransactionStatus.Success, + addedTime: Date.now(), + id: hash, + transactionOriginType: TransactionOriginType.Internal, + options: { + request: {}, + }, + } + + store.dispatch(addTransaction(transaction)) popupRegistry.addPopup({ type: PopupType.Transaction, hash }, hash) + + // Spawn background task to refetch balances after a delay + yield* spawn(refetchBalancesWithDelay, { + transaction, + activeAddress: from, + }) } function createJupiterSwap(signSolanaTransaction: (tx: VersionedTransaction) => Promise) { @@ -92,7 +124,7 @@ function createJupiterSwap(signSolanaTransaction: (tx: VersionedTransaction) => throw new JupiterExecuteError(errorMessage ?? 'Unknown Jupiter Execution Error', code) } - updateAppState({ hash, trade, from: account.address }) + yield* call(updateAppState, { hash, trade, from: account.address }) return hash } diff --git a/apps/web/src/state/sagas/transactions/swapSaga.ts b/apps/web/src/state/sagas/transactions/swapSaga.ts index 2187773687e..f24c0bcfe3c 100644 --- a/apps/web/src/state/sagas/transactions/swapSaga.ts +++ b/apps/web/src/state/sagas/transactions/swapSaga.ts @@ -1,5 +1,6 @@ import { useTotalBalancesUsdForAnalytics } from 'appGraphql/data/apollo/useTotalBalancesUsdForAnalytics' import { TradingApi } from '@universe/api' +import { Experiments } from '@universe/gating' import { popupRegistry } from 'components/Popups/registry' import { PopupType } from 'components/Popups/types' import { DEFAULT_TXN_DISMISS_MS, L2_TXN_DISMISS_MS, ZERO_PERCENT } from 'constants/misc' @@ -16,7 +17,6 @@ import { handleUniswapXSignatureStep } from 'state/sagas/transactions/uniswapx' import { getDisplayableError, getSwapTransactionInfo, - HandleOnChainStepParams, handleApprovalTransactionStep, handleOnChainStep, handlePermitTransactionStep, @@ -24,24 +24,31 @@ import { } from 'state/sagas/transactions/utils' import { VitalTxFields } from 'state/transactions/types' import invariant from 'tiny-invariant' -import { call } from 'typed-redux-saga' +import { call, SagaGenerator } from 'typed-redux-saga' import { isL2ChainId } from 'uniswap/src/features/chains/utils' import { useLocalizationContext } from 'uniswap/src/features/language/LocalizationContext' import { isSVMChain } from 'uniswap/src/features/platforms/utils/chains' import { SwapEventName } from 'uniswap/src/features/telemetry/constants' import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' import { SwapTradeBaseProperties } from 'uniswap/src/features/telemetry/types' +import { logExperimentQualifyingEvent } from 'uniswap/src/features/telemetry/utils/logExperimentQualifyingEvent' import { selectSwapStartTimestamp } from 'uniswap/src/features/timing/selectors' import { updateSwapStartTimestamp } from 'uniswap/src/features/timing/slice' import { UnexpectedTransactionStateError } from 'uniswap/src/features/transactions/errors' -import { TransactionStep, TransactionStepType } from 'uniswap/src/features/transactions/steps/types' +import { + HandleOnChainStepParams, + HandleSwapStepParams, + TransactionStep, + TransactionStepType, +} from 'uniswap/src/features/transactions/steps/types' import { ExtractedBaseTradeAnalyticsProperties, getBaseTradeAnalyticsProperties, } from 'uniswap/src/features/transactions/swap/analytics' -import { FLASHBLOCKS_UI_SKIP_ROUTES } from 'uniswap/src/features/transactions/swap/components/UnichainInstantBalanceModal/constants' -import { getIsFlashblocksEnabled } from 'uniswap/src/features/transactions/swap/hooks/useIsUnichainFlashblocksEnabled' +import { getFlashblocksExperimentStatus } from 'uniswap/src/features/transactions/swap/hooks/useIsUnichainFlashblocksEnabled' import { useV4SwapEnabled } from 'uniswap/src/features/transactions/swap/hooks/useV4SwapEnabled' +import { planSaga } from 'uniswap/src/features/transactions/swap/plan/planSaga' +import { handleSwitchChains } from 'uniswap/src/features/transactions/swap/plan/utils' import { SwapTransactionStep, SwapTransactionStepAsync, @@ -54,10 +61,15 @@ import { SwapCallbackParams, } from 'uniswap/src/features/transactions/swap/types/swapCallback' import { PermitMethod, ValidatedSwapTxContext } from 'uniswap/src/features/transactions/swap/types/swapTxAndGasInfo' -import { BridgeTrade, ClassicTrade } from 'uniswap/src/features/transactions/swap/types/trade' +import { BridgeTrade, ChainedActionTrade, ClassicTrade } from 'uniswap/src/features/transactions/swap/types/trade' import { slippageToleranceToPercent } from 'uniswap/src/features/transactions/swap/utils/format' import { generateSwapTransactionSteps } from 'uniswap/src/features/transactions/swap/utils/generateSwapTransactionSteps' -import { isClassic, isJupiter, UNISWAPX_ROUTING_VARIANTS } from 'uniswap/src/features/transactions/swap/utils/routing' +import { + isClassic, + isJupiter, + requireRouting, + UNISWAPX_ROUTING_VARIANTS, +} from 'uniswap/src/features/transactions/swap/utils/routing' import { getClassicQuoteFromResponse } from 'uniswap/src/features/transactions/swap/utils/tradingApi' import { useWallet } from 'uniswap/src/features/wallet/hooks/useWallet' import { @@ -68,14 +80,7 @@ import { createSaga } from 'uniswap/src/utils/saga' import { logger } from 'utilities/src/logger/logger' import { useTrace } from 'utilities/src/telemetry/trace/TraceContext' -interface HandleSwapStepParams extends Omit { - step: SwapTransactionStep | SwapTransactionStepAsync - signature?: string - trade: ClassicTrade | BridgeTrade - analytics: ExtractedBaseTradeAnalyticsProperties - onTransactionHash?: (hash: string) => void -} -function* handleSwapTransactionStep(params: HandleSwapStepParams) { +function* handleSwapTransactionStep(params: HandleSwapStepParams): SagaGenerator { const { trade, step, signature, analytics, onTransactionHash } = params const info = getSwapTransactionInfo(trade) @@ -103,14 +108,24 @@ function* handleSwapTransactionStep(params: HandleSwapStepParams) { handleSwapTransactionAnalytics({ ...params, hash }) - if ( - !getIsFlashblocksEnabled(trade.inputAmount.currency.chainId) || - FLASHBLOCKS_UI_SKIP_ROUTES.includes(trade.routing) - ) { + const chainId = trade.inputAmount.currency.chainId + const { shouldLogQualifyingEvent, shouldShowModal } = getFlashblocksExperimentStatus({ + chainId, + routing: trade.routing, + }) + + if (shouldLogQualifyingEvent) { + logExperimentQualifyingEvent({ + experiment: Experiments.UnichainFlashblocksModal, + }) + } + + // Show regular popup for control variant or ineligible swaps + if (!shouldShowModal) { popupRegistry.addPopup( { type: PopupType.Transaction, hash }, hash, - isL2ChainId(trade.inputAmount.currency.chainId) ? L2_TXN_DISMISS_MS : DEFAULT_TXN_DISMISS_MS, + isL2ChainId(chainId) ? L2_TXN_DISMISS_MS : DEFAULT_TXN_DISMISS_MS, ) } @@ -119,7 +134,7 @@ function* handleSwapTransactionStep(params: HandleSwapStepParams) { onTransactionHash(hash) } - return + return hash } interface HandleSwapBatchedStepParams extends Omit { @@ -149,7 +164,7 @@ function* handleSwapTransactionBatchedStep(params: HandleSwapBatchedStepParams) } function handleSwapTransactionAnalytics(params: { - trade: ClassicTrade | BridgeTrade + trade: ClassicTrade | BridgeTrade | ChainedActionTrade analytics: SwapTradeBaseProperties hash?: string batchId?: string @@ -208,31 +223,6 @@ type SwapParams = { v4Enabled: boolean } -/** Asserts that a given object fits a given routing variant. */ -function requireRouting( - val: V, - routing: readonly T[], -): asserts val is V & { routing: T } { - if (!routing.includes(val.routing as T)) { - throw new UnexpectedTransactionStateError(`Expected routing ${routing}, got ${val.routing}`) - } -} - -/** Switches to the proper chain, if needed. If a chain switch is necessary and it fails, returns success=false. */ -async function handleSwitchChains( - params: Pick, -): Promise<{ chainSwitchFailed: boolean }> { - const { selectChain, startChainId, swapTxContext } = params - - const swapChainId = swapTxContext.trade.inputAmount.currency.chainId - if (isJupiter(swapTxContext) || swapChainId === startChainId) { - return { chainSwitchFailed: false } - } - - const chainSwitched = await selectChain(swapChainId) - return { chainSwitchFailed: !chainSwitched } -} - function* swap(params: SwapParams) { const { account, @@ -247,7 +237,11 @@ function* swap(params: SwapParams) { } = params const { trade } = swapTxContext - const { chainSwitchFailed } = yield* call(handleSwitchChains, params) + const { chainSwitchFailed } = yield* call(handleSwitchChains, { + selectChain: params.selectChain, + startChainId: params.startChainId, + swapTxContext, + }) if (chainSwitchFailed) { onFailure() return @@ -408,7 +402,19 @@ export function useSwapCallback(): SwapCallback { updateSwapForm({ txHash: hash, txHashReceivedTime: Date.now() }) }, } - appDispatch(swapSaga.actions.trigger(swapParams)) + if (swapTxContext.trade.routing === TradingApi.Routing.CHAINED) { + appDispatch( + planSaga.actions.trigger({ + ...swapParams, + handleApprovalTransactionStep, + handleSwapTransactionStep, + handleSignatureStep, + getDisplayableError, + }), + ) + } else { + appDispatch(swapSaga.actions.trigger(swapParams)) + } const blockNumber = getClassicQuoteFromResponse(trade.quote)?.blockNumber?.toString() diff --git a/apps/web/src/state/sagas/transactions/uniswapx.ts b/apps/web/src/state/sagas/transactions/uniswapx.ts index fee5167d8bf..fe118260a6a 100644 --- a/apps/web/src/state/sagas/transactions/uniswapx.ts +++ b/apps/web/src/state/sagas/transactions/uniswapx.ts @@ -4,7 +4,6 @@ import { formatSwapSignedAnalyticsEventProperties } from 'lib/utils/analytics' import { addTransactionBreadcrumb, getSwapTransactionInfo, - HandleSignatureStepParams, handleSignatureStep, TransactionBreadcrumbStatus, } from 'state/sagas/transactions/utils' @@ -15,6 +14,7 @@ import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' import { SwapTradeBaseProperties } from 'uniswap/src/features/telemetry/types' import { HandledTransactionInterrupt } from 'uniswap/src/features/transactions/errors' import { addTransaction } from 'uniswap/src/features/transactions/slice' +import { HandleSignatureStepParams } from 'uniswap/src/features/transactions/steps/types' import { UniswapXSignatureStep } from 'uniswap/src/features/transactions/swap/steps/signOrder' import { UniswapXTrade } from 'uniswap/src/features/transactions/swap/types/trade' import { slippageToleranceToPercent } from 'uniswap/src/features/transactions/swap/utils/format' diff --git a/apps/web/src/state/sagas/transactions/utils.ts b/apps/web/src/state/sagas/transactions/utils.ts index bd12ae770d0..1905efecf5a 100644 --- a/apps/web/src/state/sagas/transactions/utils.ts +++ b/apps/web/src/state/sagas/transactions/utils.ts @@ -3,6 +3,7 @@ import type { TransactionResponse } from '@ethersproject/abstract-provider' import type { JsonRpcSigner, Web3Provider } from '@ethersproject/providers' import { TradeType } from '@uniswap/sdk-core' import { FetchError, TradingApi } from '@universe/api' +import { BlockedAsyncSubmissionChainIdsConfigKey, DynamicConfigs, getDynamicConfigValue } from '@universe/gating' import { wagmiConfig } from 'components/Web3Provider/wagmiConfig' import { clientToProvider } from 'hooks/useEthersProvider' import ms from 'ms' @@ -15,8 +16,6 @@ import type { SagaGenerator } from 'typed-redux-saga' import { call, cancel, delay, fork, put, race, select, spawn, take } from 'typed-redux-saga' import { UniverseChainId } from 'uniswap/src/features/chains/types' import { isL2ChainId, isUniverseChainId } from 'uniswap/src/features/chains/utils' -import { BlockedAsyncSubmissionChainIdsConfigKey, DynamicConfigs } from 'uniswap/src/features/gating/configs' -import { getDynamicConfigValue } from 'uniswap/src/features/gating/hooks' import { ApprovalEditedInWalletError, HandledTransactionInterrupt, @@ -33,14 +32,21 @@ import type { TokenApprovalTransactionStep } from 'uniswap/src/features/transact import type { Permit2TransactionStep } from 'uniswap/src/features/transactions/steps/permit2Transaction' import type { TokenRevocationTransactionStep } from 'uniswap/src/features/transactions/steps/revoke' import type { + HandleApprovalStepParams, + HandleOnChainPermit2TransactionStep, + HandleOnChainStepParams, + HandleSignatureStepParams, OnChainTransactionStep, - SignatureTransactionStep, TransactionStep, } from 'uniswap/src/features/transactions/steps/types' import { TransactionStepType } from 'uniswap/src/features/transactions/steps/types' import { SolanaTrade } from 'uniswap/src/features/transactions/swap/types/solana' -import type { SetCurrentStepFn } from 'uniswap/src/features/transactions/swap/types/swapCallback' -import type { BridgeTrade, ClassicTrade, UniswapXTrade } from 'uniswap/src/features/transactions/swap/types/trade' +import type { + BridgeTrade, + ChainedActionTrade, + ClassicTrade, + UniswapXTrade, +} from 'uniswap/src/features/transactions/swap/types/trade' import { isUniswapX } from 'uniswap/src/features/transactions/swap/utils/routing' import type { ApproveTransactionInfo, @@ -75,12 +81,6 @@ export enum TransactionBreadcrumbStatus { Interrupted = 'interrupted', } -export interface HandleSignatureStepParams { - account: AccountDetails - step: T - setCurrentStep: SetCurrentStepFn - ignoreInterrupt?: boolean -} export function* handleSignatureStep({ setCurrentStep, step, ignoreInterrupt, account }: HandleSignatureStepParams) { // Add a watcher to check if the transaction flow is interrupted during this step const { throwIfInterrupted } = yield* watchForInterruption(ignoreInterrupt) @@ -107,20 +107,6 @@ export function* handleSignatureStep({ setCurrentStep, step, ignoreInterrupt, ac return signature } -export interface HandleOnChainStepParams { - account: AccountDetails - info: TransactionInfo - step: T - setCurrentStep: SetCurrentStepFn - /** Controls whether the function allow submitting a duplicate tx (a tx w/ identical `info` to another recent/pending tx). Defaults to false. */ - allowDuplicativeTx?: boolean - /** Controls whether the function should throw an error upon interrupt or not, defaults to `false`. */ - ignoreInterrupt?: boolean - /** Controls whether the function should wait to return until after the transaction has confirmed. Defaults to `true`. */ - shouldWaitForConfirmation?: boolean - /** Called when data returned from a submitted transaction differs from data originally sent to the wallet. */ - onModification?: (response: VitalTxFields) => void | Generator -} export function* handleOnChainStep(params: HandleOnChainStepParams) { const { account, @@ -350,15 +336,12 @@ function transformTransactionResponse(response: TransactionResponse | Transactio return { hash: response.hash, data: response.input, nonce: response.nonce } } -interface HandlePermitStepParams extends Omit, 'info'> {} -export function* handlePermitTransactionStep(params: HandlePermitStepParams) { +export function* handlePermitTransactionStep(params: HandleOnChainPermit2TransactionStep) { const { step } = params const info = getPermitTransactionInfo(step) return yield* call(handleOnChainStep, { ...params, info }) } -interface HandleApprovalStepParams - extends Omit, 'info'> {} export function* handleApprovalTransactionStep(params: HandleApprovalStepParams) { const { step, account } = params const info = getApprovalTransactionInfo(step) @@ -528,11 +511,11 @@ export async function getSigner(account: string): Promise { type SwapInfo = ExactInputSwapTransactionInfo | ExactOutputSwapTransactionInfo export function getSwapTransactionInfo( - trade: ClassicTrade | BridgeTrade | SolanaTrade, + trade: ClassicTrade | BridgeTrade | SolanaTrade | ChainedActionTrade, ): SwapInfo | BridgeTransactionInfo export function getSwapTransactionInfo(trade: UniswapXTrade): SwapInfo & { isUniswapXOrder: true } export function getSwapTransactionInfo( - trade: ClassicTrade | BridgeTrade | UniswapXTrade | SolanaTrade, + trade: ClassicTrade | BridgeTrade | UniswapXTrade | SolanaTrade | ChainedActionTrade, ): SwapInfo | BridgeTransactionInfo { if (trade.routing === TradingApi.Routing.BRIDGE) { return { diff --git a/apps/web/src/state/sagas/transactions/wrapSaga.ts b/apps/web/src/state/sagas/transactions/wrapSaga.ts index d2b767e6076..6dc3d46a298 100644 --- a/apps/web/src/state/sagas/transactions/wrapSaga.ts +++ b/apps/web/src/state/sagas/transactions/wrapSaga.ts @@ -6,10 +6,10 @@ import { useAccount } from 'hooks/useAccount' import useSelectChain from 'hooks/useSelectChain' import { useCallback } from 'react' import { useDispatch } from 'react-redux' -import { HandleOnChainStepParams, handleOnChainStep } from 'state/sagas/transactions/utils' +import { handleOnChainStep } from 'state/sagas/transactions/utils' import { call } from 'typed-redux-saga' import { isTestnetChain } from 'uniswap/src/features/chains/utils' -import { TransactionStepType } from 'uniswap/src/features/transactions/steps/types' +import { HandleOnChainStepParams, TransactionStepType } from 'uniswap/src/features/transactions/steps/types' import { WrapTransactionStep } from 'uniswap/src/features/transactions/steps/wrap' import { WrapCallback, WrapCallbackParams } from 'uniswap/src/features/transactions/swap/types/wrapCallback' import { TransactionType, WrapTransactionInfo } from 'uniswap/src/features/transactions/types/transactionDetails' diff --git a/apps/web/src/state/swap/hooks.test.tsx b/apps/web/src/state/swap/hooks.test.tsx index 2836b4d444d..516eda349ac 100644 --- a/apps/web/src/state/swap/hooks.test.tsx +++ b/apps/web/src/state/swap/hooks.test.tsx @@ -20,8 +20,9 @@ import { useEnabledChains } from 'uniswap/src/features/chains/hooks/useEnabledCh import { UniverseChainId } from 'uniswap/src/features/chains/types' import { CurrencyField } from 'uniswap/src/types/currency' -vi.mock('uniswap/src/features/gating/hooks', () => { +vi.mock('@universe/gating', async (importOriginal) => { return { + ...(await importOriginal()), useFeatureFlag: vi.fn(), getFeatureFlag: vi.fn(), } @@ -128,6 +129,91 @@ describe('hooks', () => { chainId: undefined, }) }) + + test('no query parameters', () => { + expect(queryParametersToCurrencyState(parse('', { parseArrays: false, ignoreQueryPrefix: true }))).toEqual({ + inputCurrencyAddress: undefined, + outputCurrencyAddress: undefined, + value: undefined, + field: undefined, + chainId: undefined, + outputChainId: undefined, + }) + }) + + test('only chain parameter, no currencies', () => { + expect( + queryParametersToCurrencyState(parse('?chain=optimism', { parseArrays: false, ignoreQueryPrefix: true })), + ).toEqual({ + inputCurrencyAddress: undefined, + outputCurrencyAddress: undefined, + value: undefined, + field: undefined, + chainId: UniverseChainId.Optimism, + outputChainId: undefined, + }) + }) + + test('only outputChain parameter, no currencies', () => { + expect( + queryParametersToCurrencyState(parse('?outputChain=base', { parseArrays: false, ignoreQueryPrefix: true })), + ).toEqual({ + inputCurrencyAddress: undefined, + outputCurrencyAddress: undefined, + value: undefined, + field: undefined, + chainId: undefined, + outputChainId: UniverseChainId.Base, + }) + }) + + test('both chain and outputChain parameters, no currencies', () => { + expect( + queryParametersToCurrencyState( + parse('?chain=mainnet&outputChain=optimism', { parseArrays: false, ignoreQueryPrefix: true }), + ), + ).toEqual({ + inputCurrencyAddress: undefined, + outputCurrencyAddress: undefined, + value: undefined, + field: undefined, + chainId: UniverseChainId.Mainnet, + outputChainId: UniverseChainId.Optimism, + }) + }) + + test('outputChain parameter with output currency', () => { + expect( + queryParametersToCurrencyState( + parse(`?outputChain=base&outputCurrency=${DAI.address}`, { parseArrays: false, ignoreQueryPrefix: true }), + ), + ).toEqual({ + inputCurrencyAddress: undefined, + outputCurrencyAddress: DAI.address, + value: undefined, + field: undefined, + chainId: undefined, + outputChainId: UniverseChainId.Base, + }) + }) + + test('both chain and outputChain with input and output currencies', () => { + expect( + queryParametersToCurrencyState( + parse(`?chain=mainnet&outputChain=optimism&inputCurrency=ETH&outputCurrency=${USDC_OPTIMISM.address}`, { + parseArrays: false, + ignoreQueryPrefix: true, + }), + ), + ).toEqual({ + inputCurrencyAddress: 'ETH', + outputCurrencyAddress: USDC_OPTIMISM.address, + value: undefined, + field: undefined, + chainId: UniverseChainId.Mainnet, + outputChainId: UniverseChainId.Optimism, + }) + }) }) describe('URL parameter serialization', () => { @@ -260,7 +346,7 @@ describe('hooks', () => { const { result: { - current: { initialInputCurrency, initialOutputCurrency, initialChainId }, + current: { initialInputCurrency, initialOutputCurrency, initialInputChainId: initialChainId }, }, } = renderHook(() => useInitialCurrencyState()) @@ -282,7 +368,7 @@ describe('hooks', () => { const { result: { - current: { initialInputCurrency, initialOutputCurrency, initialChainId }, + current: { initialInputCurrency, initialOutputCurrency, initialInputChainId: initialChainId }, }, } = renderHook(() => useInitialCurrencyState()) @@ -307,7 +393,13 @@ describe('hooks', () => { const { result: { - current: { initialInputCurrency, initialOutputCurrency, initialTypedValue, initialField, initialChainId }, + current: { + initialInputCurrency, + initialOutputCurrency, + initialTypedValue, + initialField, + initialInputChainId: initialChainId, + }, }, } = renderHook(() => useInitialCurrencyState()) @@ -328,7 +420,7 @@ describe('hooks', () => { const { result: { - current: { initialInputCurrency, initialOutputCurrency, initialChainId }, + current: { initialInputCurrency, initialOutputCurrency, initialInputChainId: initialChainId }, }, } = renderHook(() => useInitialCurrencyState()) @@ -391,7 +483,7 @@ describe('hooks', () => { const { result: { - current: { initialInputCurrency, initialChainId }, + current: { initialInputCurrency, initialInputChainId: initialChainId }, }, } = renderHook(() => useInitialCurrencyState()) @@ -417,7 +509,7 @@ describe('hooks', () => { const { result: { - current: { initialInputCurrency, initialOutputCurrency, initialChainId }, + current: { initialInputCurrency, initialOutputCurrency, initialInputChainId: initialChainId }, }, } = renderHook(() => useInitialCurrencyState()) @@ -444,7 +536,7 @@ describe('hooks', () => { const { result: { - current: { initialInputCurrency, initialChainId }, + current: { initialInputCurrency, initialInputChainId: initialChainId }, }, } = renderHook(() => useInitialCurrencyState()) @@ -463,7 +555,7 @@ describe('hooks', () => { const { result: { - current: { initialInputCurrency, initialOutputCurrency, initialChainId }, + current: { initialInputCurrency, initialOutputCurrency, initialInputChainId: initialChainId }, }, } = renderHook(() => useInitialCurrencyState()) @@ -482,7 +574,7 @@ describe('hooks', () => { const { result: { - current: { initialInputCurrency, initialOutputCurrency, initialChainId }, + current: { initialInputCurrency, initialOutputCurrency, initialInputChainId: initialChainId }, }, } = renderHook(() => useInitialCurrencyState()) @@ -504,7 +596,7 @@ describe('hooks', () => { const { result: { - current: { initialInputCurrency, initialOutputCurrency, initialChainId }, + current: { initialInputCurrency, initialOutputCurrency, initialInputChainId: initialChainId }, }, } = renderHook(() => useInitialCurrencyState()) diff --git a/apps/web/src/state/swap/hooks.tsx b/apps/web/src/state/swap/hooks.tsx index 1f590d11da1..e95c837cd79 100644 --- a/apps/web/src/state/swap/hooks.tsx +++ b/apps/web/src/state/swap/hooks.tsx @@ -225,7 +225,8 @@ export function useInitialCurrencyState(): { initialOutputCurrency?: Currency initialTypedValue?: string initialField?: CurrencyField - initialChainId: UniverseChainId + initialInputChainId?: UniverseChainId + initialOutputChainId?: UniverseChainId triggerConnect: boolean } { const { setIsUserSelectedToken } = useMultichainContext() @@ -243,7 +244,10 @@ export function useInitialCurrencyState(): { const isSupportedChainCompatible = isTestnetModeEnabled === !!supportedChainInfo.testnet const hasCurrencyQueryParams = - parsedCurrencyState.inputCurrencyAddress || parsedCurrencyState.outputCurrencyAddress || parsedCurrencyState.chainId + parsedCurrencyState.inputCurrencyAddress || + parsedCurrencyState.outputCurrencyAddress || + parsedCurrencyState.chainId || + parsedCurrencyState.outputChainId useEffect(() => { if (parsedCurrencyState.inputCurrencyAddress || parsedCurrencyState.outputCurrencyAddress) { @@ -255,9 +259,9 @@ export function useInitialCurrencyState(): { const { initialInputCurrencyAddress, initialChainId } = useMemo(() => { // Default to native if no query params or chain is not compatible with testnet or mainnet mode if (!hasCurrencyQueryParams || !isSupportedChainCompatible) { - const initialChainId = persistedFilteredChainIds?.input ?? defaultChainId + const initialChainId = persistedFilteredChainIds?.input return { - initialInputCurrencyAddress: getNativeAddress(initialChainId), + initialInputCurrencyAddress: getNativeAddress(initialChainId ?? defaultChainId), initialChainId, } } @@ -265,13 +269,13 @@ export function useInitialCurrencyState(): { if (parsedCurrencyState.inputCurrencyAddress) { return { initialInputCurrencyAddress: parsedCurrencyState.inputCurrencyAddress, - initialChainId: supportedChainId, + initialChainId: parsedCurrencyState.chainId ? supportedChainId : undefined, } } // return ETH or parsedCurrencyState return { initialInputCurrencyAddress: parsedCurrencyState.outputCurrencyAddress ? undefined : 'ETH', - initialChainId: supportedChainId, + initialChainId: parsedCurrencyState.chainId ? supportedChainId : undefined, } }, [ hasCurrencyQueryParams, @@ -282,15 +286,15 @@ export function useInitialCurrencyState(): { defaultChainId, ]) - const outputChainIsSupported = useSupportedChainId(parsedCurrencyState.outputChainId) + const supportedOutputChainId = useSupportedChainId(parsedCurrencyState.outputChainId) const initialOutputCurrencyAddress = useMemo( () => // clear output if identical unless there's a supported outputChainId which means we're bridging - initialInputCurrencyAddress === parsedCurrencyState.outputCurrencyAddress && !outputChainIsSupported + initialInputCurrencyAddress === parsedCurrencyState.outputCurrencyAddress && !supportedOutputChainId ? undefined : parsedCurrencyState.outputCurrencyAddress, - [initialInputCurrencyAddress, parsedCurrencyState.outputCurrencyAddress, outputChainIsSupported], + [initialInputCurrencyAddress, parsedCurrencyState.outputCurrencyAddress, supportedOutputChainId], ) const initialInputCurrency = useCurrency({ address: initialInputCurrencyAddress, chainId: initialChainId }) @@ -313,7 +317,8 @@ export function useInitialCurrencyState(): { initialOutputCurrency, initialTypedValue, initialField, - initialChainId, + initialInputChainId: initialChainId, + initialOutputChainId: supportedOutputChainId, triggerConnect: !!parsedQs.connect, } } diff --git a/apps/web/src/state/transactions/types.ts b/apps/web/src/state/transactions/types.ts index 70d93c70194..d5708d22270 100644 --- a/apps/web/src/state/transactions/types.ts +++ b/apps/web/src/state/transactions/types.ts @@ -8,7 +8,11 @@ import type { } from 'uniswap/src/features/transactions/types/transactionDetails' import type { ValidatedTransactionRequest } from 'uniswap/src/features/transactions/types/transactionRequests' -// Re-export for backward compatibility +/** + * Re-export for backward compatibility + * + * @deprecated Use TransactionTypeInfo + */ export type TransactionInfo = TransactionTypeInfo // Web-specific pending transaction details with guaranteed pending status diff --git a/apps/web/src/state/walletCapabilities/hooks/useMismatchAccount.ts b/apps/web/src/state/walletCapabilities/hooks/useMismatchAccount.ts index 86b48a4a479..1b5633726c5 100644 --- a/apps/web/src/state/walletCapabilities/hooks/useMismatchAccount.ts +++ b/apps/web/src/state/walletCapabilities/hooks/useMismatchAccount.ts @@ -1,4 +1,5 @@ import { nanoid } from '@reduxjs/toolkit' +import { FeatureFlags, useFeatureFlag } from '@universe/gating' import { popupRegistry } from 'components/Popups/registry' import { PopupType } from 'components/Popups/types' import { useRef } from 'react' @@ -9,8 +10,6 @@ import { isAtomicBatchingSupportedByChainId } from 'state/walletCapabilities/lib import { useDelegationService } from 'state/wallets/useDelegationService' import { selectHasShownMismatchToast } from 'uniswap/src/features/behaviorHistory/selectors' import { setHasShownMismatchToast } from 'uniswap/src/features/behaviorHistory/slice' -import { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' import { createHasMismatchUtil, type HasMismatchUtil } from 'uniswap/src/features/smartWallet/mismatch/mismatch' import { UniswapEventName } from 'uniswap/src/features/telemetry/constants' import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send.web' diff --git a/apps/web/src/state/walletCapabilities/hooks/useWalletGetCapabilitiesMutation.ts b/apps/web/src/state/walletCapabilities/hooks/useWalletGetCapabilitiesMutation.ts index 889cdf53524..2fca5aae612 100644 --- a/apps/web/src/state/walletCapabilities/hooks/useWalletGetCapabilitiesMutation.ts +++ b/apps/web/src/state/walletCapabilities/hooks/useWalletGetCapabilitiesMutation.ts @@ -1,11 +1,10 @@ import { queryOptions, useMutation, useQueryClient } from '@tanstack/react-query' +import { FeatureFlags, useFeatureFlag } from '@universe/gating' import { useAccount } from 'hooks/useAccount' import ms from 'ms' import { useAppDispatch } from 'state/hooks' import { handleGetCapabilities } from 'state/walletCapabilities/lib/handleGetCapabilities' import { setCapabilitiesByChain, setCapabilitiesNotSupported } from 'state/walletCapabilities/reducer' -import { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' import { getLogger } from 'utilities/src/logger/logger' import { useEvent } from 'utilities/src/react/hooks' import { ReactQueryCacheKey } from 'utilities/src/reactQuery/cache' diff --git a/apps/web/src/utils/computeSurroundingTicks.test.ts b/apps/web/src/utils/computeSurroundingTicks.test.ts index 1be002984db..266ddb293ca 100644 --- a/apps/web/src/utils/computeSurroundingTicks.test.ts +++ b/apps/web/src/utils/computeSurroundingTicks.test.ts @@ -1,5 +1,5 @@ import { TickData } from 'appGraphql/data/AllV3TicksQuery' -import { ProtocolVersion } from '@uniswap/client-pools/dist/pools/v1/types_pb' +import { ProtocolVersion } from '@uniswap/client-data-api/dist/data/v1/poolTypes_pb' import { Price, Token } from '@uniswap/sdk-core' import { FeeAmount, TICK_SPACINGS } from '@uniswap/v3-sdk' import JSBI from 'jsbi' diff --git a/apps/web/src/utils/computeSurroundingTicks.ts b/apps/web/src/utils/computeSurroundingTicks.ts index 03f3f448e93..4f98225ae92 100644 --- a/apps/web/src/utils/computeSurroundingTicks.ts +++ b/apps/web/src/utils/computeSurroundingTicks.ts @@ -1,5 +1,5 @@ import { Ticks } from 'appGraphql/data/AllV3TicksQuery' -import { ProtocolVersion } from '@uniswap/client-pools/dist/pools/v1/types_pb' +import { ProtocolVersion } from '@uniswap/client-data-api/dist/data/v1/poolTypes_pb' import { Currency, Price, Token } from '@uniswap/sdk-core' import { tickToPrice as tickToPriceV3 } from '@uniswap/v3-sdk' import { tickToPrice as tickToPriceV4 } from '@uniswap/v4-sdk' diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 8426335ca78..31f14d0e75b 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -56,6 +56,9 @@ }, { "path": "../../packages/api" + }, + { + "path": "../../packages/gating" } ] } diff --git a/apps/web/vite.config.mts b/apps/web/vite.config.mts index 31586201d5a..2f1476e4429 100644 --- a/apps/web/vite.config.mts +++ b/apps/web/vite.config.mts @@ -287,7 +287,7 @@ export default defineConfig(({ mode }) => { '@visx/responsive', ], // Libraries that shouldn't be pre-bundled - exclude: ['expo-clipboard'], + exclude: ['expo-clipboard', '@connectrpc/connect'], esbuildOptions: { resolveExtensions: ['.web.js', '.web.ts', '.web.tsx', '.js', '.ts', '.tsx'], loader: { diff --git a/bun.lock b/bun.lock index a2d98fc1fbc..d06d2a9e293 100644 --- a/bun.lock +++ b/bun.lock @@ -67,6 +67,7 @@ "@uniswap/v3-sdk": "3.25.2", "@uniswap/v4-sdk": "1.21.2", "@universe/api": "workspace:^", + "@universe/gating": "workspace:^", "@wxt-dev/module-react": "1.1.3", "confusing-browser-globals": "1.0.11", "dotenv-webpack": "8.0.1", @@ -196,6 +197,7 @@ "@uniswap/ethers-rs-mobile": "0.0.5", "@uniswap/sdk-core": "7.7.2", "@universe/api": "workspace:^", + "@universe/gating": "workspace:^", "@walletconnect/core": "2.21.4", "@walletconnect/react-native-compat": "2.21.4", "@walletconnect/types": "2.21.4", @@ -362,8 +364,8 @@ "@types/react-scroll-sync": "0.9.0", "@uniswap/analytics": "1.7.2", "@uniswap/analytics-events": "2.43.0", + "@uniswap/client-data-api": "0.0.18", "@uniswap/client-explore": "0.0.17", - "@uniswap/client-pools": "0.0.17", "@uniswap/merkle-distributor": "1.0.1", "@uniswap/permit2-sdk": "1.3.0", "@uniswap/router-sdk": "2.0.2", @@ -378,6 +380,7 @@ "@uniswap/v3-sdk": "3.25.2", "@uniswap/v4-sdk": "1.21.2", "@universe/api": "workspace:^", + "@universe/gating": "workspace:^", "@visx/group": "2.17.0", "@visx/responsive": "3.12.0", "@visx/shape": "2.18.0", @@ -584,10 +587,9 @@ "@connectrpc/connect": "1.4.0", "@connectrpc/connect-web": "1.4.0", "@tanstack/react-query": "5.77.2", - "@uniswap/client-data-api": "0.0.14", + "@uniswap/client-data-api": "0.0.18", "@uniswap/client-embeddedwallet": "0.0.16", "@uniswap/client-explore": "0.0.17", - "@uniswap/client-pools": "0.0.17", "@uniswap/client-trading": "0.1.0", "@universe/config": "workspace:^", "@universe/sessions": "workspace:^", @@ -682,6 +684,37 @@ "eslint": "8.44.0", }, }, + "packages/gating": { + "name": "@universe/gating", + "version": "0.0.0", + "dependencies": { + "@statsig/client-core": "3.12.2", + "@statsig/js-client": "3.12.2", + "@statsig/js-local-overrides": "3.12.2", + "@statsig/react-bindings": "3.12.2", + "@statsig/react-native-bindings": "3.12.2", + "@universe/api": "workspace:*", + "utilities": "workspace:*", + }, + "devDependencies": { + "@types/node": "22.13.1", + "@uniswap/eslint-config": "workspace:^", + "depcheck": "1.4.7", + "eslint": "8.44.0", + "typescript": "5.3.3", + }, + }, + "packages/notifications": { + "name": "@universe/notifications", + "version": "0.0.0", + "devDependencies": { + "@types/node": "22.13.1", + "@uniswap/eslint-config": "workspace:^", + "depcheck": "1.4.7", + "eslint": "8.44.0", + "typescript": "5.3.3", + }, + }, "packages/sessions": { "name": "@universe/sessions", "version": "0.0.0", @@ -774,6 +807,7 @@ "@connectrpc/connect-query": "1.4.1", "@datadog/browser-logs": "5.20.0", "@datadog/browser-rum": "5.23.3", + "@ethersproject/abstract-provider": "5.8.0", "@ethersproject/abstract-signer": "5.7.0", "@ethersproject/address": "5.7.0", "@ethersproject/bignumber": "5.7.0", @@ -788,21 +822,15 @@ "@shopify/flash-list": "1.7.3", "@simplewebauthn/browser": "13.1.0", "@solana/web3.js": "1.92.0", - "@statsig/client-core": "3.12.2", - "@statsig/js-client": "3.12.2", - "@statsig/js-local-overrides": "3.12.2", - "@statsig/react-bindings": "3.12.2", - "@statsig/react-native-bindings": "3.12.2", "@tanstack/query-async-storage-persister": "5.51.21", "@tanstack/react-query": "5.77.2", "@tanstack/react-query-persist-client": "5.77.2", "@typechain/ethers-v5": "7.2.0", "@types/poisson-disk-sampling": "2.2.4", "@uniswap/analytics-events": "2.43.0", - "@uniswap/client-data-api": "0.0.14", + "@uniswap/client-data-api": "0.0.18", "@uniswap/client-embeddedwallet": "0.0.16", "@uniswap/client-explore": "0.0.17", - "@uniswap/client-pools": "0.0.17", "@uniswap/client-search": "0.0.10", "@uniswap/client-trading": "0.1.0", "@uniswap/permit2-sdk": "1.3.0", @@ -815,6 +843,7 @@ "@uniswap/v4-sdk": "1.21.2", "@universe/api": "workspace:^", "@universe/config": "workspace:^", + "@universe/gating": "workspace:^", "apollo-link-rest": "0.9.0", "date-fns": "2.30.0", "dayjs": "1.11.7", @@ -971,12 +1000,13 @@ "@scure/bip32": "1.3.2", "@tanstack/react-query": "5.77.2", "@uniswap/analytics-events": "2.43.0", - "@uniswap/client-data-api": "0.0.14", + "@uniswap/client-data-api": "0.0.18", "@uniswap/permit2-sdk": "1.3.0", "@uniswap/router-sdk": "2.0.2", "@uniswap/sdk-core": "7.7.2", "@uniswap/universal-router-sdk": "4.19.5", "@universe/api": "workspace:^", + "@universe/gating": "workspace:^", "apollo3-cache-persist": "0.14.1", "dayjs": "1.11.7", "ethers": "5.7.2", @@ -1167,29 +1197,29 @@ "@aws-crypto/util": ["@aws-crypto/util@5.2.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="], - "@aws-sdk/client-cloudwatch-logs": ["@aws-sdk/client-cloudwatch-logs@3.911.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "3.911.0", "@aws-sdk/credential-provider-node": "3.911.0", "@aws-sdk/middleware-host-header": "3.910.0", "@aws-sdk/middleware-logger": "3.910.0", "@aws-sdk/middleware-recursion-detection": "3.910.0", "@aws-sdk/middleware-user-agent": "3.911.0", "@aws-sdk/region-config-resolver": "3.910.0", "@aws-sdk/types": "3.910.0", "@aws-sdk/util-endpoints": "3.910.0", "@aws-sdk/util-user-agent-browser": "3.910.0", "@aws-sdk/util-user-agent-node": "3.911.0", "@smithy/config-resolver": "^4.3.2", "@smithy/core": "^3.16.1", "@smithy/eventstream-serde-browser": "^4.2.2", "@smithy/eventstream-serde-config-resolver": "^4.3.2", "@smithy/eventstream-serde-node": "^4.2.2", "@smithy/fetch-http-handler": "^5.3.3", "@smithy/hash-node": "^4.2.2", "@smithy/invalid-dependency": "^4.2.2", "@smithy/middleware-content-length": "^4.2.2", "@smithy/middleware-endpoint": "^4.3.3", "@smithy/middleware-retry": "^4.4.3", "@smithy/middleware-serde": "^4.2.2", "@smithy/middleware-stack": "^4.2.2", "@smithy/node-config-provider": "^4.3.2", "@smithy/node-http-handler": "^4.4.1", "@smithy/protocol-http": "^5.3.2", "@smithy/smithy-client": "^4.8.1", "@smithy/types": "^4.7.1", "@smithy/url-parser": "^4.2.2", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.2", "@smithy/util-defaults-mode-node": "^4.2.3", "@smithy/util-endpoints": "^3.2.2", "@smithy/util-middleware": "^4.2.2", "@smithy/util-retry": "^4.2.2", "@smithy/util-utf8": "^4.2.0", "@smithy/uuid": "^1.1.0", "tslib": "^2.6.2" } }, "sha512-172pKzqYk/GtIQsdrqmJCg8VRBGR+U70kSWdcOWtTMZOOex7Cv6iYMsCLX/ckmvVCKicgsSdxXE6TWYIEMr6oQ=="], + "@aws-sdk/client-cloudwatch-logs": ["@aws-sdk/client-cloudwatch-logs@3.913.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "3.911.0", "@aws-sdk/credential-provider-node": "3.913.0", "@aws-sdk/middleware-host-header": "3.910.0", "@aws-sdk/middleware-logger": "3.910.0", "@aws-sdk/middleware-recursion-detection": "3.910.0", "@aws-sdk/middleware-user-agent": "3.911.0", "@aws-sdk/region-config-resolver": "3.910.0", "@aws-sdk/types": "3.910.0", "@aws-sdk/util-endpoints": "3.910.0", "@aws-sdk/util-user-agent-browser": "3.910.0", "@aws-sdk/util-user-agent-node": "3.911.0", "@smithy/config-resolver": "^4.3.2", "@smithy/core": "^3.16.1", "@smithy/eventstream-serde-browser": "^4.2.2", "@smithy/eventstream-serde-config-resolver": "^4.3.2", "@smithy/eventstream-serde-node": "^4.2.2", "@smithy/fetch-http-handler": "^5.3.3", "@smithy/hash-node": "^4.2.2", "@smithy/invalid-dependency": "^4.2.2", "@smithy/middleware-content-length": "^4.2.2", "@smithy/middleware-endpoint": "^4.3.3", "@smithy/middleware-retry": "^4.4.3", "@smithy/middleware-serde": "^4.2.2", "@smithy/middleware-stack": "^4.2.2", "@smithy/node-config-provider": "^4.3.2", "@smithy/node-http-handler": "^4.4.1", "@smithy/protocol-http": "^5.3.2", "@smithy/smithy-client": "^4.8.1", "@smithy/types": "^4.7.1", "@smithy/url-parser": "^4.2.2", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.2", "@smithy/util-defaults-mode-node": "^4.2.3", "@smithy/util-endpoints": "^3.2.2", "@smithy/util-middleware": "^4.2.2", "@smithy/util-retry": "^4.2.2", "@smithy/util-utf8": "^4.2.0", "@smithy/uuid": "^1.1.0", "tslib": "^2.6.2" } }, "sha512-ztaUBCI6ps90O5sERy5ZP8aGC2+Ks9kvOJrdpGFMKcTVtyHP1xTB/FDfNvmz2s25S8W7yeCokvs1fvoKcLyniQ=="], - "@aws-sdk/client-cognito-identity": ["@aws-sdk/client-cognito-identity@3.911.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "3.911.0", "@aws-sdk/credential-provider-node": "3.911.0", "@aws-sdk/middleware-host-header": "3.910.0", "@aws-sdk/middleware-logger": "3.910.0", "@aws-sdk/middleware-recursion-detection": "3.910.0", "@aws-sdk/middleware-user-agent": "3.911.0", "@aws-sdk/region-config-resolver": "3.910.0", "@aws-sdk/types": "3.910.0", "@aws-sdk/util-endpoints": "3.910.0", "@aws-sdk/util-user-agent-browser": "3.910.0", "@aws-sdk/util-user-agent-node": "3.911.0", "@smithy/config-resolver": "^4.3.2", "@smithy/core": "^3.16.1", "@smithy/fetch-http-handler": "^5.3.3", "@smithy/hash-node": "^4.2.2", "@smithy/invalid-dependency": "^4.2.2", "@smithy/middleware-content-length": "^4.2.2", "@smithy/middleware-endpoint": "^4.3.3", "@smithy/middleware-retry": "^4.4.3", "@smithy/middleware-serde": "^4.2.2", "@smithy/middleware-stack": "^4.2.2", "@smithy/node-config-provider": "^4.3.2", "@smithy/node-http-handler": "^4.4.1", "@smithy/protocol-http": "^5.3.2", "@smithy/smithy-client": "^4.8.1", "@smithy/types": "^4.7.1", "@smithy/url-parser": "^4.2.2", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.2", "@smithy/util-defaults-mode-node": "^4.2.3", "@smithy/util-endpoints": "^3.2.2", "@smithy/util-middleware": "^4.2.2", "@smithy/util-retry": "^4.2.2", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-Ch/ndkyrh5fAIOqIBS/0IOSsxLQSrzhmBqyZ6Zrahy/haKHOC1UxFFld7crJUbcukvgvmuM9l5DRncy0tIe1tQ=="], + "@aws-sdk/client-cognito-identity": ["@aws-sdk/client-cognito-identity@3.913.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "3.911.0", "@aws-sdk/credential-provider-node": "3.913.0", "@aws-sdk/middleware-host-header": "3.910.0", "@aws-sdk/middleware-logger": "3.910.0", "@aws-sdk/middleware-recursion-detection": "3.910.0", "@aws-sdk/middleware-user-agent": "3.911.0", "@aws-sdk/region-config-resolver": "3.910.0", "@aws-sdk/types": "3.910.0", "@aws-sdk/util-endpoints": "3.910.0", "@aws-sdk/util-user-agent-browser": "3.910.0", "@aws-sdk/util-user-agent-node": "3.911.0", "@smithy/config-resolver": "^4.3.2", "@smithy/core": "^3.16.1", "@smithy/fetch-http-handler": "^5.3.3", "@smithy/hash-node": "^4.2.2", "@smithy/invalid-dependency": "^4.2.2", "@smithy/middleware-content-length": "^4.2.2", "@smithy/middleware-endpoint": "^4.3.3", "@smithy/middleware-retry": "^4.4.3", "@smithy/middleware-serde": "^4.2.2", "@smithy/middleware-stack": "^4.2.2", "@smithy/node-config-provider": "^4.3.2", "@smithy/node-http-handler": "^4.4.1", "@smithy/protocol-http": "^5.3.2", "@smithy/smithy-client": "^4.8.1", "@smithy/types": "^4.7.1", "@smithy/url-parser": "^4.2.2", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.2", "@smithy/util-defaults-mode-node": "^4.2.3", "@smithy/util-endpoints": "^3.2.2", "@smithy/util-middleware": "^4.2.2", "@smithy/util-retry": "^4.2.2", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-TdEwasoXnLIb90z7NL1vLbEprzY0vdRqZH97ubIUDo8EaJ6WrJ35Um5g0rcnWKR6C+P9lKKI4mVv2BI2EwY94Q=="], - "@aws-sdk/client-iam": ["@aws-sdk/client-iam@3.911.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "3.911.0", "@aws-sdk/credential-provider-node": "3.911.0", "@aws-sdk/middleware-host-header": "3.910.0", "@aws-sdk/middleware-logger": "3.910.0", "@aws-sdk/middleware-recursion-detection": "3.910.0", "@aws-sdk/middleware-user-agent": "3.911.0", "@aws-sdk/region-config-resolver": "3.910.0", "@aws-sdk/types": "3.910.0", "@aws-sdk/util-endpoints": "3.910.0", "@aws-sdk/util-user-agent-browser": "3.910.0", "@aws-sdk/util-user-agent-node": "3.911.0", "@smithy/config-resolver": "^4.3.2", "@smithy/core": "^3.16.1", "@smithy/fetch-http-handler": "^5.3.3", "@smithy/hash-node": "^4.2.2", "@smithy/invalid-dependency": "^4.2.2", "@smithy/middleware-content-length": "^4.2.2", "@smithy/middleware-endpoint": "^4.3.3", "@smithy/middleware-retry": "^4.4.3", "@smithy/middleware-serde": "^4.2.2", "@smithy/middleware-stack": "^4.2.2", "@smithy/node-config-provider": "^4.3.2", "@smithy/node-http-handler": "^4.4.1", "@smithy/protocol-http": "^5.3.2", "@smithy/smithy-client": "^4.8.1", "@smithy/types": "^4.7.1", "@smithy/url-parser": "^4.2.2", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.2", "@smithy/util-defaults-mode-node": "^4.2.3", "@smithy/util-endpoints": "^3.2.2", "@smithy/util-middleware": "^4.2.2", "@smithy/util-retry": "^4.2.2", "@smithy/util-utf8": "^4.2.0", "@smithy/util-waiter": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-IksVAEDAsiKR0NsP6b4OLhtvg6GkH/CpopR8Dh1TaBTotS/lE8amF2N94SYq+emG0vJ23CvErmkHQpa2ZBYDUg=="], + "@aws-sdk/client-iam": ["@aws-sdk/client-iam@3.913.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "3.911.0", "@aws-sdk/credential-provider-node": "3.913.0", "@aws-sdk/middleware-host-header": "3.910.0", "@aws-sdk/middleware-logger": "3.910.0", "@aws-sdk/middleware-recursion-detection": "3.910.0", "@aws-sdk/middleware-user-agent": "3.911.0", "@aws-sdk/region-config-resolver": "3.910.0", "@aws-sdk/types": "3.910.0", "@aws-sdk/util-endpoints": "3.910.0", "@aws-sdk/util-user-agent-browser": "3.910.0", "@aws-sdk/util-user-agent-node": "3.911.0", "@smithy/config-resolver": "^4.3.2", "@smithy/core": "^3.16.1", "@smithy/fetch-http-handler": "^5.3.3", "@smithy/hash-node": "^4.2.2", "@smithy/invalid-dependency": "^4.2.2", "@smithy/middleware-content-length": "^4.2.2", "@smithy/middleware-endpoint": "^4.3.3", "@smithy/middleware-retry": "^4.4.3", "@smithy/middleware-serde": "^4.2.2", "@smithy/middleware-stack": "^4.2.2", "@smithy/node-config-provider": "^4.3.2", "@smithy/node-http-handler": "^4.4.1", "@smithy/protocol-http": "^5.3.2", "@smithy/smithy-client": "^4.8.1", "@smithy/types": "^4.7.1", "@smithy/url-parser": "^4.2.2", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.2", "@smithy/util-defaults-mode-node": "^4.2.3", "@smithy/util-endpoints": "^3.2.2", "@smithy/util-middleware": "^4.2.2", "@smithy/util-retry": "^4.2.2", "@smithy/util-utf8": "^4.2.0", "@smithy/util-waiter": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-3UR3EJ/2eutWpcs6LdtLl4JTJ4u/TZZEoLryUConEchrBNNtSVBG2CXrG7In1hS4l0t5TlkW/ruEZyLZJiqFfw=="], - "@aws-sdk/client-lambda": ["@aws-sdk/client-lambda@3.911.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "3.911.0", "@aws-sdk/credential-provider-node": "3.911.0", "@aws-sdk/middleware-host-header": "3.910.0", "@aws-sdk/middleware-logger": "3.910.0", "@aws-sdk/middleware-recursion-detection": "3.910.0", "@aws-sdk/middleware-user-agent": "3.911.0", "@aws-sdk/region-config-resolver": "3.910.0", "@aws-sdk/types": "3.910.0", "@aws-sdk/util-endpoints": "3.910.0", "@aws-sdk/util-user-agent-browser": "3.910.0", "@aws-sdk/util-user-agent-node": "3.911.0", "@smithy/config-resolver": "^4.3.2", "@smithy/core": "^3.16.1", "@smithy/eventstream-serde-browser": "^4.2.2", "@smithy/eventstream-serde-config-resolver": "^4.3.2", "@smithy/eventstream-serde-node": "^4.2.2", "@smithy/fetch-http-handler": "^5.3.3", "@smithy/hash-node": "^4.2.2", "@smithy/invalid-dependency": "^4.2.2", "@smithy/middleware-content-length": "^4.2.2", "@smithy/middleware-endpoint": "^4.3.3", "@smithy/middleware-retry": "^4.4.3", "@smithy/middleware-serde": "^4.2.2", "@smithy/middleware-stack": "^4.2.2", "@smithy/node-config-provider": "^4.3.2", "@smithy/node-http-handler": "^4.4.1", "@smithy/protocol-http": "^5.3.2", "@smithy/smithy-client": "^4.8.1", "@smithy/types": "^4.7.1", "@smithy/url-parser": "^4.2.2", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.2", "@smithy/util-defaults-mode-node": "^4.2.3", "@smithy/util-endpoints": "^3.2.2", "@smithy/util-middleware": "^4.2.2", "@smithy/util-retry": "^4.2.2", "@smithy/util-stream": "^4.5.2", "@smithy/util-utf8": "^4.2.0", "@smithy/util-waiter": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-cKRF10Fks0EEkZiRYlIy3mOUKCwtGET6CwsdYbaQ28qCm/Hh26QcL5YjVbq1fUF4BfdsFi7AQAX9WOWOAA8HQA=="], + "@aws-sdk/client-lambda": ["@aws-sdk/client-lambda@3.913.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "3.911.0", "@aws-sdk/credential-provider-node": "3.913.0", "@aws-sdk/middleware-host-header": "3.910.0", "@aws-sdk/middleware-logger": "3.910.0", "@aws-sdk/middleware-recursion-detection": "3.910.0", "@aws-sdk/middleware-user-agent": "3.911.0", "@aws-sdk/region-config-resolver": "3.910.0", "@aws-sdk/types": "3.910.0", "@aws-sdk/util-endpoints": "3.910.0", "@aws-sdk/util-user-agent-browser": "3.910.0", "@aws-sdk/util-user-agent-node": "3.911.0", "@smithy/config-resolver": "^4.3.2", "@smithy/core": "^3.16.1", "@smithy/eventstream-serde-browser": "^4.2.2", "@smithy/eventstream-serde-config-resolver": "^4.3.2", "@smithy/eventstream-serde-node": "^4.2.2", "@smithy/fetch-http-handler": "^5.3.3", "@smithy/hash-node": "^4.2.2", "@smithy/invalid-dependency": "^4.2.2", "@smithy/middleware-content-length": "^4.2.2", "@smithy/middleware-endpoint": "^4.3.3", "@smithy/middleware-retry": "^4.4.3", "@smithy/middleware-serde": "^4.2.2", "@smithy/middleware-stack": "^4.2.2", "@smithy/node-config-provider": "^4.3.2", "@smithy/node-http-handler": "^4.4.1", "@smithy/protocol-http": "^5.3.2", "@smithy/smithy-client": "^4.8.1", "@smithy/types": "^4.7.1", "@smithy/url-parser": "^4.2.2", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.2", "@smithy/util-defaults-mode-node": "^4.2.3", "@smithy/util-endpoints": "^3.2.2", "@smithy/util-middleware": "^4.2.2", "@smithy/util-retry": "^4.2.2", "@smithy/util-stream": "^4.5.2", "@smithy/util-utf8": "^4.2.0", "@smithy/util-waiter": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-Z1POaV/q+unF0G6PEDO0p6JrepJI6DXkVAl4RQiN1hZRshsfZxPQtsanKqMHLEi/OyhqnoVxy1buSNHjuumTdg=="], - "@aws-sdk/client-sfn": ["@aws-sdk/client-sfn@3.911.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "3.911.0", "@aws-sdk/credential-provider-node": "3.911.0", "@aws-sdk/middleware-host-header": "3.910.0", "@aws-sdk/middleware-logger": "3.910.0", "@aws-sdk/middleware-recursion-detection": "3.910.0", "@aws-sdk/middleware-user-agent": "3.911.0", "@aws-sdk/region-config-resolver": "3.910.0", "@aws-sdk/types": "3.910.0", "@aws-sdk/util-endpoints": "3.910.0", "@aws-sdk/util-user-agent-browser": "3.910.0", "@aws-sdk/util-user-agent-node": "3.911.0", "@smithy/config-resolver": "^4.3.2", "@smithy/core": "^3.16.1", "@smithy/fetch-http-handler": "^5.3.3", "@smithy/hash-node": "^4.2.2", "@smithy/invalid-dependency": "^4.2.2", "@smithy/middleware-content-length": "^4.2.2", "@smithy/middleware-endpoint": "^4.3.3", "@smithy/middleware-retry": "^4.4.3", "@smithy/middleware-serde": "^4.2.2", "@smithy/middleware-stack": "^4.2.2", "@smithy/node-config-provider": "^4.3.2", "@smithy/node-http-handler": "^4.4.1", "@smithy/protocol-http": "^5.3.2", "@smithy/smithy-client": "^4.8.1", "@smithy/types": "^4.7.1", "@smithy/url-parser": "^4.2.2", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.2", "@smithy/util-defaults-mode-node": "^4.2.3", "@smithy/util-endpoints": "^3.2.2", "@smithy/util-middleware": "^4.2.2", "@smithy/util-retry": "^4.2.2", "@smithy/util-utf8": "^4.2.0", "@smithy/uuid": "^1.1.0", "tslib": "^2.6.2" } }, "sha512-JQrrhHXecw607oAqJOmg53pd9f9iYOAXcDcTfIVdHiTnXvhMshqgW8TuRobQ4n8Yg4FtgCg0SB/WQNDfYiU+vg=="], + "@aws-sdk/client-sfn": ["@aws-sdk/client-sfn@3.913.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "3.911.0", "@aws-sdk/credential-provider-node": "3.913.0", "@aws-sdk/middleware-host-header": "3.910.0", "@aws-sdk/middleware-logger": "3.910.0", "@aws-sdk/middleware-recursion-detection": "3.910.0", "@aws-sdk/middleware-user-agent": "3.911.0", "@aws-sdk/region-config-resolver": "3.910.0", "@aws-sdk/types": "3.910.0", "@aws-sdk/util-endpoints": "3.910.0", "@aws-sdk/util-user-agent-browser": "3.910.0", "@aws-sdk/util-user-agent-node": "3.911.0", "@smithy/config-resolver": "^4.3.2", "@smithy/core": "^3.16.1", "@smithy/fetch-http-handler": "^5.3.3", "@smithy/hash-node": "^4.2.2", "@smithy/invalid-dependency": "^4.2.2", "@smithy/middleware-content-length": "^4.2.2", "@smithy/middleware-endpoint": "^4.3.3", "@smithy/middleware-retry": "^4.4.3", "@smithy/middleware-serde": "^4.2.2", "@smithy/middleware-stack": "^4.2.2", "@smithy/node-config-provider": "^4.3.2", "@smithy/node-http-handler": "^4.4.1", "@smithy/protocol-http": "^5.3.2", "@smithy/smithy-client": "^4.8.1", "@smithy/types": "^4.7.1", "@smithy/url-parser": "^4.2.2", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.2", "@smithy/util-defaults-mode-node": "^4.2.3", "@smithy/util-endpoints": "^3.2.2", "@smithy/util-middleware": "^4.2.2", "@smithy/util-retry": "^4.2.2", "@smithy/util-utf8": "^4.2.0", "@smithy/uuid": "^1.1.0", "tslib": "^2.6.2" } }, "sha512-XVonl1v6mqz+FxevEvY+CBEOi+fTG3Ht2BTwWRM3F0XUwOo4ONH4+jo54rEEbq12zVW3M53k+rMfUDyy1pSLiw=="], "@aws-sdk/client-sso": ["@aws-sdk/client-sso@3.911.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "3.911.0", "@aws-sdk/middleware-host-header": "3.910.0", "@aws-sdk/middleware-logger": "3.910.0", "@aws-sdk/middleware-recursion-detection": "3.910.0", "@aws-sdk/middleware-user-agent": "3.911.0", "@aws-sdk/region-config-resolver": "3.910.0", "@aws-sdk/types": "3.910.0", "@aws-sdk/util-endpoints": "3.910.0", "@aws-sdk/util-user-agent-browser": "3.910.0", "@aws-sdk/util-user-agent-node": "3.911.0", "@smithy/config-resolver": "^4.3.2", "@smithy/core": "^3.16.1", "@smithy/fetch-http-handler": "^5.3.3", "@smithy/hash-node": "^4.2.2", "@smithy/invalid-dependency": "^4.2.2", "@smithy/middleware-content-length": "^4.2.2", "@smithy/middleware-endpoint": "^4.3.3", "@smithy/middleware-retry": "^4.4.3", "@smithy/middleware-serde": "^4.2.2", "@smithy/middleware-stack": "^4.2.2", "@smithy/node-config-provider": "^4.3.2", "@smithy/node-http-handler": "^4.4.1", "@smithy/protocol-http": "^5.3.2", "@smithy/smithy-client": "^4.8.1", "@smithy/types": "^4.7.1", "@smithy/url-parser": "^4.2.2", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.2", "@smithy/util-defaults-mode-node": "^4.2.3", "@smithy/util-endpoints": "^3.2.2", "@smithy/util-middleware": "^4.2.2", "@smithy/util-retry": "^4.2.2", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-N9QAeMvN3D1ZyKXkQp4aUgC4wUMuA5E1HuVCkajc0bq1pnH4PIke36YlrDGGREqPlyLFrXCkws2gbL5p23vtlg=="], "@aws-sdk/core": ["@aws-sdk/core@3.911.0", "", { "dependencies": { "@aws-sdk/types": "3.910.0", "@aws-sdk/xml-builder": "3.911.0", "@smithy/core": "^3.16.1", "@smithy/node-config-provider": "^4.3.2", "@smithy/property-provider": "^4.2.2", "@smithy/protocol-http": "^5.3.2", "@smithy/signature-v4": "^5.3.2", "@smithy/smithy-client": "^4.8.1", "@smithy/types": "^4.7.1", "@smithy/util-base64": "^4.3.0", "@smithy/util-middleware": "^4.2.2", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-k4QG9A+UCq/qlDJFmjozo6R0eXXfe++/KnCDMmajehIE9kh+b/5DqlGvAmbl9w4e92LOtrY6/DN3mIX1xs4sXw=="], - "@aws-sdk/credential-provider-cognito-identity": ["@aws-sdk/credential-provider-cognito-identity@3.911.0", "", { "dependencies": { "@aws-sdk/client-cognito-identity": "3.911.0", "@aws-sdk/types": "3.910.0", "@smithy/property-provider": "^4.2.2", "@smithy/types": "^4.7.1", "tslib": "^2.6.2" } }, "sha512-4RF/HQ2C4K+UfNfddw3xHLqk/c1G0/8nhgW10BGU0w/EICkCxtVEzgbflGeUumuXsxJYo8Fyyg/Pd8302brfHA=="], + "@aws-sdk/credential-provider-cognito-identity": ["@aws-sdk/credential-provider-cognito-identity@3.913.0", "", { "dependencies": { "@aws-sdk/client-cognito-identity": "3.913.0", "@aws-sdk/types": "3.910.0", "@smithy/property-provider": "^4.2.2", "@smithy/types": "^4.7.1", "tslib": "^2.6.2" } }, "sha512-AYZNpy3eEFzopzntLcrkEQQ1qyhg0V7BL8U77QdLSYtzoYvI9CqnWOGdWnNSEUp+Mpbk1VJyPzVfkDoDq5kX6g=="], "@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.911.0", "", { "dependencies": { "@aws-sdk/core": "3.911.0", "@aws-sdk/types": "3.910.0", "@smithy/property-provider": "^4.2.2", "@smithy/types": "^4.7.1", "tslib": "^2.6.2" } }, "sha512-6FWRwWn3LUZzLhqBXB+TPMW2ijCWUqGICSw8bVakEdODrvbiv1RT/MVUayzFwz/ek6e6NKZn6DbSWzx07N9Hjw=="], "@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.911.0", "", { "dependencies": { "@aws-sdk/core": "3.911.0", "@aws-sdk/types": "3.910.0", "@smithy/fetch-http-handler": "^5.3.3", "@smithy/node-http-handler": "^4.4.1", "@smithy/property-provider": "^4.2.2", "@smithy/protocol-http": "^5.3.2", "@smithy/smithy-client": "^4.8.1", "@smithy/types": "^4.7.1", "@smithy/util-stream": "^4.5.2", "tslib": "^2.6.2" } }, "sha512-xUlwKmIUW2fWP/eM3nF5u4CyLtOtyohlhGJ5jdsJokr3MrQ7w0tDITO43C9IhCn+28D5UbaiWnKw5ntkw7aVfA=="], - "@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.911.0", "", { "dependencies": { "@aws-sdk/core": "3.911.0", "@aws-sdk/credential-provider-env": "3.911.0", "@aws-sdk/credential-provider-http": "3.911.0", "@aws-sdk/credential-provider-process": "3.911.0", "@aws-sdk/credential-provider-sso": "3.911.0", "@aws-sdk/credential-provider-web-identity": "3.911.0", "@aws-sdk/nested-clients": "3.911.0", "@aws-sdk/types": "3.910.0", "@smithy/credential-provider-imds": "^4.2.2", "@smithy/property-provider": "^4.2.2", "@smithy/shared-ini-file-loader": "^4.3.2", "@smithy/types": "^4.7.1", "tslib": "^2.6.2" } }, "sha512-bQ86kWAZ0Imn7uWl7uqOYZ2aqlkftPmEc8cQh+QyhmUXbia8II4oYKq/tMek6j3M5UOMCiJVxzJoxemJZA6/sw=="], + "@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.913.0", "", { "dependencies": { "@aws-sdk/core": "3.911.0", "@aws-sdk/credential-provider-env": "3.911.0", "@aws-sdk/credential-provider-http": "3.911.0", "@aws-sdk/credential-provider-process": "3.911.0", "@aws-sdk/credential-provider-sso": "3.911.0", "@aws-sdk/credential-provider-web-identity": "3.911.0", "@aws-sdk/nested-clients": "3.911.0", "@aws-sdk/types": "3.910.0", "@smithy/credential-provider-imds": "^4.2.2", "@smithy/property-provider": "^4.2.2", "@smithy/shared-ini-file-loader": "^4.3.2", "@smithy/types": "^4.7.1", "tslib": "^2.6.2" } }, "sha512-iR4c4NQ1OSRKQi0SxzpwD+wP1fCy+QNKtEyCajuVlD0pvmoIHdrm5THK9e+2/7/SsQDRhOXHJfLGxHapD74WJw=="], - "@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.911.0", "", { "dependencies": { "@aws-sdk/credential-provider-env": "3.911.0", "@aws-sdk/credential-provider-http": "3.911.0", "@aws-sdk/credential-provider-ini": "3.911.0", "@aws-sdk/credential-provider-process": "3.911.0", "@aws-sdk/credential-provider-sso": "3.911.0", "@aws-sdk/credential-provider-web-identity": "3.911.0", "@aws-sdk/types": "3.910.0", "@smithy/credential-provider-imds": "^4.2.2", "@smithy/property-provider": "^4.2.2", "@smithy/shared-ini-file-loader": "^4.3.2", "@smithy/types": "^4.7.1", "tslib": "^2.6.2" } }, "sha512-4oGpLwgQCKNtVoJROztJ4v7lZLhCqcUMX6pe/DQ2aU0TktZX7EczMCIEGjVo5b7yHwSNWt2zW0tDdgVUTsMHPw=="], + "@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.913.0", "", { "dependencies": { "@aws-sdk/credential-provider-env": "3.911.0", "@aws-sdk/credential-provider-http": "3.911.0", "@aws-sdk/credential-provider-ini": "3.913.0", "@aws-sdk/credential-provider-process": "3.911.0", "@aws-sdk/credential-provider-sso": "3.911.0", "@aws-sdk/credential-provider-web-identity": "3.911.0", "@aws-sdk/types": "3.910.0", "@smithy/credential-provider-imds": "^4.2.2", "@smithy/property-provider": "^4.2.2", "@smithy/shared-ini-file-loader": "^4.3.2", "@smithy/types": "^4.7.1", "tslib": "^2.6.2" } }, "sha512-HQPLkKDxS83Q/nZKqg9bq4igWzYQeOMqhpx5LYs4u1GwsKeCsYrrfz12Iu4IHNWPp9EnGLcmdfbfYuqZGrsaSQ=="], "@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.911.0", "", { "dependencies": { "@aws-sdk/core": "3.911.0", "@aws-sdk/types": "3.910.0", "@smithy/property-provider": "^4.2.2", "@smithy/shared-ini-file-loader": "^4.3.2", "@smithy/types": "^4.7.1", "tslib": "^2.6.2" } }, "sha512-mKshhV5jRQffZjbK9x7bs+uC2IsYKfpzYaBamFsEov3xtARCpOiKaIlM8gYKFEbHT2M+1R3rYYlhhl9ndVWS2g=="], @@ -1197,7 +1227,7 @@ "@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.911.0", "", { "dependencies": { "@aws-sdk/core": "3.911.0", "@aws-sdk/nested-clients": "3.911.0", "@aws-sdk/types": "3.910.0", "@smithy/property-provider": "^4.2.2", "@smithy/shared-ini-file-loader": "^4.3.2", "@smithy/types": "^4.7.1", "tslib": "^2.6.2" } }, "sha512-urIbXWWG+cm54RwwTFQuRwPH0WPsMFSDF2/H9qO2J2fKoHRURuyblFCyYG3aVKZGvFBhOizJYexf5+5w3CJKBw=="], - "@aws-sdk/credential-providers": ["@aws-sdk/credential-providers@3.911.0", "", { "dependencies": { "@aws-sdk/client-cognito-identity": "3.911.0", "@aws-sdk/core": "3.911.0", "@aws-sdk/credential-provider-cognito-identity": "3.911.0", "@aws-sdk/credential-provider-env": "3.911.0", "@aws-sdk/credential-provider-http": "3.911.0", "@aws-sdk/credential-provider-ini": "3.911.0", "@aws-sdk/credential-provider-node": "3.911.0", "@aws-sdk/credential-provider-process": "3.911.0", "@aws-sdk/credential-provider-sso": "3.911.0", "@aws-sdk/credential-provider-web-identity": "3.911.0", "@aws-sdk/nested-clients": "3.911.0", "@aws-sdk/types": "3.910.0", "@smithy/config-resolver": "^4.3.2", "@smithy/core": "^3.16.1", "@smithy/credential-provider-imds": "^4.2.2", "@smithy/node-config-provider": "^4.3.2", "@smithy/property-provider": "^4.2.2", "@smithy/types": "^4.7.1", "tslib": "^2.6.2" } }, "sha512-BTJyah0hB0w4kP6RKBr4oA1O9cJ5hG3UWVXKIH3YvvSEfZtjbaN1lrnN9DXk1lIEsNZG/yG5m6UjI4e9c7eeKA=="], + "@aws-sdk/credential-providers": ["@aws-sdk/credential-providers@3.913.0", "", { "dependencies": { "@aws-sdk/client-cognito-identity": "3.913.0", "@aws-sdk/core": "3.911.0", "@aws-sdk/credential-provider-cognito-identity": "3.913.0", "@aws-sdk/credential-provider-env": "3.911.0", "@aws-sdk/credential-provider-http": "3.911.0", "@aws-sdk/credential-provider-ini": "3.913.0", "@aws-sdk/credential-provider-node": "3.913.0", "@aws-sdk/credential-provider-process": "3.911.0", "@aws-sdk/credential-provider-sso": "3.911.0", "@aws-sdk/credential-provider-web-identity": "3.911.0", "@aws-sdk/nested-clients": "3.911.0", "@aws-sdk/types": "3.910.0", "@smithy/config-resolver": "^4.3.2", "@smithy/core": "^3.16.1", "@smithy/credential-provider-imds": "^4.2.2", "@smithy/node-config-provider": "^4.3.2", "@smithy/property-provider": "^4.2.2", "@smithy/types": "^4.7.1", "tslib": "^2.6.2" } }, "sha512-KnkvoLXGszXNV7IMLdUH2Smo+tr4MiHUp2zkkrhl+6uXdSWpEAhlARSA8OPIxgVMabUW1AWDumN7Km7z0GvnWg=="], "@aws-sdk/middleware-host-header": ["@aws-sdk/middleware-host-header@3.910.0", "", { "dependencies": { "@aws-sdk/types": "3.910.0", "@smithy/protocol-http": "^5.3.2", "@smithy/types": "^4.7.1", "tslib": "^2.6.2" } }, "sha512-F9Lqeu80/aTM6S/izZ8RtwSmjfhWjIuxX61LX+/9mxJyEkgaECRxv0chsLQsLHJumkGnXRy/eIyMLBhcTPF5vg=="], @@ -2679,7 +2709,7 @@ "@reown/walletkit": ["@reown/walletkit@1.2.8", "", { "dependencies": { "@walletconnect/core": "2.21.4", "@walletconnect/jsonrpc-provider": "1.0.14", "@walletconnect/jsonrpc-utils": "1.0.8", "@walletconnect/logger": "2.1.2", "@walletconnect/sign-client": "2.21.4", "@walletconnect/types": "2.21.4", "@walletconnect/utils": "2.21.4" } }, "sha512-X3EO9P6+Dvc++h8OwpBtBhGmq+890UlG/o0Ilb98l5ByDr3QVcYOURRIPVcV6pkTJ9sE6sVDXW7RIRiYSnQp2g=="], - "@repeaterjs/repeater": ["@repeaterjs/repeater@3.0.6", "", {}, "sha512-Javneu5lsuhwNCryN+pXH93VPQ8g0dBX7wItHFgYiwQmzE1sVdg5tWHiOgHywzL2W21XQopa7IwIEnNbmeUJYA=="], + "@repeaterjs/repeater": ["@repeaterjs/repeater@3.0.4", "", {}, "sha512-AW8PKd6iX3vAZ0vA43nOUOnbq/X5ihgU+mSXXqunMkeQADGiqw/PY0JNeYtD5sr0PAy51YPgAPbDoeapv9r8WA=="], "@resvg/resvg-wasm": ["@resvg/resvg-wasm@2.4.1", "", {}, "sha512-yi6R0HyHtsoWTRA06Col4WoDs7SvlXU3DLMNP2bdAgs7HK18dTEVl1weXgxRzi8gwLteGUbIg29zulxIB3GSdg=="], @@ -2901,16 +2931,24 @@ "@socket.io/component-emitter": ["@socket.io/component-emitter@3.1.2", "", {}, "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA=="], - "@solana-mobile/mobile-wallet-adapter-protocol": ["@solana-mobile/mobile-wallet-adapter-protocol@2.2.4", "", { "dependencies": { "@solana/wallet-standard": "^1.1.2", "@solana/wallet-standard-util": "^1.1.1", "@wallet-standard/core": "^1.0.3", "js-base64": "^3.7.5" }, "peerDependencies": { "react-native": ">0.69" } }, "sha512-0YvA8QAzMQYujYq1fuJ4wNlouvnJpVYJ4XKqBBh+G8IQGEezhWjuP6DryIg9gw3LD6ju/rDX1jfzGOZ38JAzkQ=="], + "@solana-mobile/mobile-wallet-adapter-protocol": ["@solana-mobile/mobile-wallet-adapter-protocol@2.2.5", "", { "dependencies": { "@solana/codecs-strings": "^4.0.0", "@solana/wallet-standard": "^1.1.2", "@solana/wallet-standard-util": "^1.1.1", "@wallet-standard/core": "^1.0.3", "js-base64": "^3.7.5" }, "peerDependencies": { "react-native": ">0.69" } }, "sha512-kCI+0/umWm98M9g12ndpS56U6wBzq4XdhobCkDPF8qRDYX/iTU8CD+QMcalh7VgRT7GWEmySQvQdaugM0Chf0g=="], - "@solana-mobile/mobile-wallet-adapter-protocol-web3js": ["@solana-mobile/mobile-wallet-adapter-protocol-web3js@2.2.4", "", { "dependencies": { "@solana-mobile/mobile-wallet-adapter-protocol": "^2.2.4", "bs58": "^5.0.0", "js-base64": "^3.7.5" }, "peerDependencies": { "@solana/web3.js": "^1.58.0" } }, "sha512-vSsIVGEOs+IJ8+5gzSwl5XBCW1zFIwhF0Qfx+fqH8F0eN5ip+XExFcnt5Of426HVpmVL2H8jocBwGwvdrTNU/A=="], + "@solana-mobile/mobile-wallet-adapter-protocol-web3js": ["@solana-mobile/mobile-wallet-adapter-protocol-web3js@2.2.5", "", { "dependencies": { "@solana-mobile/mobile-wallet-adapter-protocol": "^2.2.5", "bs58": "^5.0.0", "js-base64": "^3.7.5" }, "peerDependencies": { "@solana/web3.js": "^1.58.0" } }, "sha512-xfQl6Kee0ZXagUG5mpy+bMhQTNf2LAzF65m5SSgNJp47y/nP9GdXWi9blVH8IPP+QjF/+DnCtURaXS14bk3WJw=="], - "@solana-mobile/wallet-adapter-mobile": ["@solana-mobile/wallet-adapter-mobile@2.2.4", "", { "dependencies": { "@solana-mobile/mobile-wallet-adapter-protocol-web3js": "^2.2.0", "@solana-mobile/wallet-standard-mobile": "^0.4.1", "@solana/wallet-adapter-base": "^0.9.23", "@solana/wallet-standard-features": "^1.2.0", "js-base64": "^3.7.5" }, "optionalDependencies": { "@react-native-async-storage/async-storage": "^1.17.7" }, "peerDependencies": { "@solana/web3.js": "^1.58.0" } }, "sha512-ZKj8xU1bOtgHMgMfJh8qfUtdp5Ii4JhVJP3jqaRswYpRClmTApkBB++izSD3NBQ6fmiGv2G8F7AILQO0dYOwbg=="], + "@solana-mobile/wallet-adapter-mobile": ["@solana-mobile/wallet-adapter-mobile@2.2.5", "", { "dependencies": { "@solana-mobile/mobile-wallet-adapter-protocol-web3js": "^2.2.5", "@solana-mobile/wallet-standard-mobile": "^0.4.3", "@solana/wallet-adapter-base": "^0.9.23", "@solana/wallet-standard-features": "^1.2.0", "js-base64": "^3.7.5" }, "optionalDependencies": { "@react-native-async-storage/async-storage": "^1.17.7" }, "peerDependencies": { "@solana/web3.js": "^1.58.0" } }, "sha512-Zpzfwm3N4FfI63ZMs2qZChQ1j0z+p2prkZbSU51NyTnE+K9l9sDAl8RmRCOWnE29y+/AN10WuQZQoIAccHVOFg=="], - "@solana-mobile/wallet-standard-mobile": ["@solana-mobile/wallet-standard-mobile@0.4.2", "", { "dependencies": { "@solana-mobile/mobile-wallet-adapter-protocol": "^2.2.4", "@solana/wallet-standard-chains": "^1.1.0", "@solana/wallet-standard-features": "^1.2.0", "@wallet-standard/base": "^1.0.1", "@wallet-standard/features": "^1.0.3", "bs58": "^5.0.0", "js-base64": "^3.7.5", "qrcode": "^1.5.4" } }, "sha512-D/ebTRcpSEdCxfp7OZ0NRg+ScguJHqp208EGWI1R5rMBoGdoeu4ZvIi3VeJdi+Y9qcJFji8p2gf/wdHRL+6RkQ=="], + "@solana-mobile/wallet-standard-mobile": ["@solana-mobile/wallet-standard-mobile@0.4.3", "", { "dependencies": { "@solana-mobile/mobile-wallet-adapter-protocol": "^2.2.5", "@solana/wallet-standard-chains": "^1.1.0", "@solana/wallet-standard-features": "^1.2.0", "@wallet-standard/base": "^1.0.1", "@wallet-standard/features": "^1.0.3", "bs58": "^5.0.0", "js-base64": "^3.7.5", "qrcode": "^1.5.4" } }, "sha512-LLMQs/KgRZpftIhwOLCM2VZLMdA2vIghJjKsYUIiy1FBJS9GEkGDLJdbujb92lfAdmYwbyTuolIRik7JMPH3Kg=="], "@solana/buffer-layout": ["@solana/buffer-layout@4.0.1", "", { "dependencies": { "buffer": "~6.0.3" } }, "sha512-E1ImOIAD1tBZFRdjeM4/pzTiTApC0AOBGwyAMS4fwIodCWArzJ3DWdoh8cKxeFM2fElkxBh2Aqts1BPC373rHA=="], + "@solana/codecs-core": ["@solana/codecs-core@4.0.0", "", { "dependencies": { "@solana/errors": "4.0.0" }, "peerDependencies": { "typescript": ">=5.3.3" } }, "sha512-28kNUsyIlhU3MO3/7ZLDqeJf2YAm32B4tnTjl5A9HrbBqsTZ+upT/RzxZGP1MMm7jnPuIKCMwmTpsyqyR6IUpw=="], + + "@solana/codecs-numbers": ["@solana/codecs-numbers@4.0.0", "", { "dependencies": { "@solana/codecs-core": "4.0.0", "@solana/errors": "4.0.0" }, "peerDependencies": { "typescript": ">=5.3.3" } }, "sha512-z9zpjtcwzqT9rbkKVZpkWB5/0V7+6YRKs6BccHkGJlaDx8Pe/+XOvPi2rEdXPqrPd9QWb5Xp1iBfcgaDMyiOiA=="], + + "@solana/codecs-strings": ["@solana/codecs-strings@4.0.0", "", { "dependencies": { "@solana/codecs-core": "4.0.0", "@solana/codecs-numbers": "4.0.0", "@solana/errors": "4.0.0" }, "peerDependencies": { "fastestsmallesttextencoderdecoder": "^1.0.22", "typescript": ">=5.3.3" } }, "sha512-XvyD+sQ1zyA0amfxbpoFZsucLoe+yASQtDiLUGMDg5TZ82IHE3B7n82jE8d8cTAqi0HgqQiwU13snPhvg1O0Ow=="], + + "@solana/errors": ["@solana/errors@4.0.0", "", { "dependencies": { "chalk": "5.6.2", "commander": "14.0.1" }, "peerDependencies": { "typescript": ">=5.3.3" }, "bin": { "errors": "bin/cli.mjs" } }, "sha512-3YEtvcMvtcnTl4HahqLt0VnaGVf7vVWOnt6/uPky5e0qV6BlxDSbGkbBzttNjxLXHognV0AQi3pjvrtfUnZmbg=="], + "@solana/wallet-adapter-base": ["@solana/wallet-adapter-base@0.9.27", "", { "dependencies": { "@solana/wallet-standard-features": "^1.3.0", "@wallet-standard/base": "^1.1.0", "@wallet-standard/features": "^1.1.0", "eventemitter3": "^5.0.1" }, "peerDependencies": { "@solana/web3.js": "^1.98.0" } }, "sha512-kXjeNfNFVs/NE9GPmysBRKQ/nf+foSaq3kfVSeMcO/iVgigyRmB551OjU3WyAolLG/1jeEfKLqF9fKwMCRkUqg=="], "@solana/wallet-adapter-coinbase": ["@solana/wallet-adapter-coinbase@0.1.23", "", { "dependencies": { "@solana/wallet-adapter-base": "^0.9.27" }, "peerDependencies": { "@solana/web3.js": "^1.98.0" } }, "sha512-vCJi/clbq1VVgydPFnHGAc2jdEhDAClYmhEAR4RJp9UHBg+MEQUl1WW8PVIREY5uOzJHma0qEiyummIfyt0b4A=="], @@ -3495,7 +3533,7 @@ "@types/express": ["@types/express@4.17.23", "", { "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^4.17.33", "@types/qs": "*", "@types/serve-static": "*" } }, "sha512-Crp6WY9aTYP3qPi2wGDo9iUe/rceX01UMhnF1jmwDcKCFM6cx7YhGP/Mpr3y9AASpfHixIG0E6azCcL5OcDHsQ=="], - "@types/express-serve-static-core": ["@types/express-serve-static-core@5.1.0", "", { "dependencies": { "@types/node": "*", "@types/qs": "*", "@types/range-parser": "*", "@types/send": "*" } }, "sha512-jnHMsrd0Mwa9Cf4IdOzbz543y4XJepXrbia2T4b6+spXC2We3t1y6K44D3mR8XMFSXMCf3/l7rCgddfx7UNVBA=="], + "@types/express-serve-static-core": ["@types/express-serve-static-core@4.19.7", "", { "dependencies": { "@types/node": "*", "@types/qs": "*", "@types/range-parser": "*", "@types/send": "*" } }, "sha512-FvPtiIf1LfhzsaIXhv/PHan/2FeQBbtBDtfX2QfvPxdUelMDEckK08SM6nqo1MIZY3RUlfA+HV8+hFUSio78qg=="], "@types/filesystem": ["@types/filesystem@0.0.36", "", { "dependencies": { "@types/filewriter": "*" } }, "sha512-vPDXOZuannb9FZdxgHnqSwAG/jvdGM8Wq+6N4D/d80z+D4HWH+bItqsZaVRQykAn6WEVeEkLm2oQigyHtgb0RA=="], @@ -3689,7 +3727,7 @@ "@uniswap/biome-config": ["@uniswap/biome-config@workspace:packages/biome-config"], - "@uniswap/client-data-api": ["@uniswap/client-data-api@0.0.14", "", {}, "sha512-OpZmRP2YbeIfjZ9fDsnVWk7rFa0xwhByfjt1Ma+VJgObUGyi8ecCuczZ4ntrpNqFpij/kYUrm7i36gR2yGow/Q=="], + "@uniswap/client-data-api": ["@uniswap/client-data-api@0.0.18", "", {}, "sha512-gM7Y4EJNdDfMoMQ9F7Il/NqeUus4zEIDdE8kf2OZTYoGmoiGp2WLrG8D/sToprCpYu0klXbdet2lXGDydkTyew=="], "@uniswap/client-embeddedwallet": ["@uniswap/client-embeddedwallet@0.0.16", "", {}, "sha512-zxlx3E2X0kKAw10FKOGlbFpX4yq3KJv9SEipBJxZae5OZH8Ki8UO+FBX/Ke9JPQmU1WUoiJ7NOknjIIDOjVmpw=="], @@ -3697,8 +3735,6 @@ "@uniswap/client-platform-service": ["@uniswap/client-platform-service@0.0.5", "", {}, "sha512-vqxYuCRpddynuaF9+umgIEdo6EYFb+8VJvamjT6E1p1fx0MC0YATWfQvASzKkPS6oKjKzUBQz8nxCLT+v7aNNg=="], - "@uniswap/client-pools": ["@uniswap/client-pools@0.0.17", "", {}, "sha512-qOmKD3r2R9WjK3nHMmNvDDUPKGA/8SFxUNNZupnIK2oUdkGfKY1xadkbcA4iqN3F00511LQ21c5OZDgxxjelSw=="], - "@uniswap/client-search": ["@uniswap/client-search@0.0.10", "", {}, "sha512-ykHIxTR0dRtI3dK1ubHu2jNe+hfhDjyUfqA/dlNVrjM67GLYjF1Ls4kWi/cfR131XeRWj9gVsZv30CgzkOicAQ=="], "@uniswap/client-trading": ["@uniswap/client-trading@0.1.0", "", {}, "sha512-LWjbAUk3TFvWOlfbXweyME12EUmO6jEQPiCM9jaHA6pWtlWOWPbQcCXuDWy3iwHklupoqhnyiLGj8CFgvpl0lA=="], @@ -3751,6 +3787,10 @@ "@universe/config": ["@universe/config@workspace:packages/config"], + "@universe/gating": ["@universe/gating@workspace:packages/gating"], + + "@universe/notifications": ["@universe/notifications@workspace:packages/notifications"], + "@universe/sessions": ["@universe/sessions@workspace:packages/sessions"], "@universe/uniswap-nx": ["@universe/uniswap-nx@workspace:tools/uniswap-nx"], @@ -4147,7 +4187,7 @@ "ast-types-flow": ["ast-types-flow@0.0.8", "", {}, "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ=="], - "ast-v8-to-istanbul": ["ast-v8-to-istanbul@0.3.6", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", "estree-walker": "^3.0.3", "js-tokens": "^9.0.1" } }, "sha512-9tx1z/7OF/a8EdYL3FKoBhxLf3h3D8fXvuSj0HknsVeli2HE40qbNZxyFhMtnydaRiamwFu9zhb+BsJ5tVPehQ=="], + "ast-v8-to-istanbul": ["ast-v8-to-istanbul@0.3.7", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", "estree-walker": "^3.0.3", "js-tokens": "^9.0.1" } }, "sha512-kr1Hy6YRZBkGQSb6puP+D6FQ59Cx4m0siYhAxygMCAgadiWQ6oxAxQXHOMvJx67SJ63jRoVIIg5eXzUbbct1ww=="], "astral-regex": ["astral-regex@2.0.0", "", {}, "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ=="], @@ -4265,7 +4305,7 @@ "base64-sol": ["base64-sol@1.0.1", "", {}, "sha512-ld3cCNMeXt4uJXmLZBHFGMvVpK9KsLVEhPpFRXnvSVAqABKbuNZg/+dsq3NuM+wxFLb/UrVkz7m1ciWmkMfTbg=="], - "baseline-browser-mapping": ["baseline-browser-mapping@2.8.16", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-OMu3BGQ4E7P1ErFsIPpbJh0qvDudM/UuJeHgkAvfWe+0HFJCXh+t/l8L6fVLR55RI/UbKrVLnAXZSVwd9ysWYw=="], + "baseline-browser-mapping": ["baseline-browser-mapping@2.8.17", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-j5zJcx6golJYTG6c05LUZ3Z8Gi+M62zRT/ycz4Xq4iCOdpcxwg7ngEYD4KA0eWZC7U17qh/Smq8bYbACJ0ipBA=="], "basic-auth": ["basic-auth@2.0.1", "", { "dependencies": { "safe-buffer": "5.1.2" } }, "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg=="], @@ -4401,7 +4441,7 @@ "bytes": ["bytes@3.0.0", "", {}, "sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw=="], - "c12": ["c12@3.3.0", "", { "dependencies": { "chokidar": "^4.0.3", "confbox": "^0.2.2", "defu": "^6.1.4", "dotenv": "^17.2.2", "exsolve": "^1.0.7", "giget": "^2.0.0", "jiti": "^2.5.1", "ohash": "^2.0.11", "pathe": "^2.0.3", "perfect-debounce": "^2.0.0", "pkg-types": "^2.3.0", "rc9": "^2.1.2" }, "peerDependencies": { "magicast": "^0.3.5" }, "optionalPeers": ["magicast"] }, "sha512-K9ZkuyeJQeqLEyqldbYLG3wjqwpw4BVaAqvmxq3GYKK0b1A/yYQdIcJxkzAOWcNVWhJpRXAPfZFueekiY/L8Dw=="], + "c12": ["c12@3.3.1", "", { "dependencies": { "chokidar": "^4.0.3", "confbox": "^0.2.2", "defu": "^6.1.4", "dotenv": "^17.2.3", "exsolve": "^1.0.7", "giget": "^2.0.0", "jiti": "^2.6.1", "ohash": "^2.0.11", "pathe": "^2.0.3", "perfect-debounce": "^2.0.0", "pkg-types": "^2.3.0", "rc9": "^2.1.2" }, "peerDependencies": { "magicast": "^0.3.5" }, "optionalPeers": ["magicast"] }, "sha512-LcWQ01LT9tkoUINHgpIOv3mMs+Abv7oVCrtpMRi1PaapVEpWoMga5WuT7/DqFTu7URP9ftbOmimNw1KNIGh9DQ=="], "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], @@ -4439,7 +4479,7 @@ "caniuse-api": ["caniuse-api@3.0.0", "", { "dependencies": { "browserslist": "^4.0.0", "caniuse-lite": "^1.0.0", "lodash.memoize": "^4.1.2", "lodash.uniq": "^4.5.0" } }, "sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw=="], - "caniuse-lite": ["caniuse-lite@1.0.30001750", "", {}, "sha512-cuom0g5sdX6rw00qOoLNSFCJ9/mYIsuSOA+yzpDw8eopiFqcVwQvZHqov0vmEighRxX++cfC0Vg1G+1Iy/mSpQ=="], + "caniuse-lite": ["caniuse-lite@1.0.30001751", "", {}, "sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw=="], "canvaskit-wasm": ["canvaskit-wasm@0.40.0", "", { "dependencies": { "@webgpu/types": "0.1.21" } }, "sha512-Od2o+ZmoEw9PBdN/yCGvzfu0WVqlufBPEWNG452wY7E9aT8RBE+ChpZF526doOlg7zumO4iCS+RAeht4P0Gbpw=="], @@ -5079,7 +5119,7 @@ "env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], - "envinfo": ["envinfo@7.18.0", "", { "bin": { "envinfo": "dist/cli.js" } }, "sha512-02QGCLRW+Jb8PC270ic02lat+N57iBaWsvHjcJViqp6UVupRB+Vsg7brYPTqEFXvsdTql3KnSczv5ModZFpl8Q=="], + "envinfo": ["envinfo@7.19.0", "", { "bin": { "envinfo": "dist/cli.js" } }, "sha512-DoSM9VyG6O3vqBf+p3Gjgr/Q52HYBBtO3v+4koAxt1MnWr+zEnxE+nke/yXS4lt2P4SYCHQ4V3f1i88LQVOpAw=="], "environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="], @@ -5381,6 +5421,8 @@ "fastest-levenshtein": ["fastest-levenshtein@1.0.16", "", {}, "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg=="], + "fastestsmallesttextencoderdecoder": ["fastestsmallesttextencoderdecoder@1.0.22", "", {}, "sha512-Pb8d48e+oIuY4MaM64Cd7OW1gt4nxCHs7/ddPPZ/Ic3sg8yVGM7O9wDvZ7us6ScaUupzM+pfBolwtYhN1IxBIw=="], + "fastq": ["fastq@1.19.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ=="], "faye-websocket": ["faye-websocket@0.11.4", "", { "dependencies": { "websocket-driver": ">=0.5.1" } }, "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g=="], @@ -6683,7 +6725,7 @@ "node-preload": ["node-preload@0.2.1", "", { "dependencies": { "process-on-spawn": "^1.0.0" } }, "sha512-RM5oyBy45cLEoHqCeh+MNuFAxO0vTFBLskvQbOKnEE7YTTSN4tbN8QWDIPQ6L+WvKsB/qLEGpYe2ZZ9d4W9OIQ=="], - "node-releases": ["node-releases@2.0.23", "", {}, "sha512-cCmFDMSm26S6tQSDpBCg/NR8NENrVPhAJSf+XbxBG4rPFaaonlEoE9wHQmun+cls499TQGSb7ZyPBRlzgKfpeg=="], + "node-releases": ["node-releases@2.0.25", "", {}, "sha512-4auku8B/vw5psvTiiN9j1dAOsXvMoGqJuKJcR+dTdqiXEK20mMTk1UEo3HS16LeGQsVG6+qKTPM9u/qQ2LqATA=="], "node-source-walk": ["node-source-walk@5.0.2", "", { "dependencies": { "@babel/parser": "^7.21.4" } }, "sha512-Y4jr/8SRS5hzEdZ7SGuvZGwfORvNsSsNRwDXx5WisiqzsVfeftDvRgfeqWNgZvWSJbgubTRVRYBzK6UO+ErqjA=="], @@ -8849,8 +8891,6 @@ "@babel/runtime/regenerator-runtime": ["regenerator-runtime@0.14.1", "", {}, "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw=="], - "@binance/w3w-qrcode-modal/qrcode": ["qrcode@1.5.4", "", { "dependencies": { "dijkstrajs": "^1.0.1", "pngjs": "^5.0.0", "yargs": "^15.3.1" }, "bin": { "qrcode": "bin/qrcode" } }, "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg=="], - "@chromatic-com/storybook/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], "@cloudflare/kv-asset-handler/mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="], @@ -8865,7 +8905,7 @@ "@commitlint/load/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], - "@commitlint/load/cosmiconfig": ["cosmiconfig@8.0.0", "", { "dependencies": { "import-fresh": "^3.2.1", "js-yaml": "^4.1.0", "parse-json": "^5.0.0", "path-type": "^4.0.0" } }, "sha512-da1EafcpH6b/TD8vDRaWV7xFINlHlF6zKsGwS1TsuVJTZRkquaS5HTMq7uq6h31619QjbsYl21gVDOm32KM1vQ=="], + "@commitlint/load/cosmiconfig": ["cosmiconfig@8.3.6", "", { "dependencies": { "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0", "path-type": "^4.0.0" }, "peerDependencies": { "typescript": ">=4.9.5" }, "optionalPeers": ["typescript"] }, "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA=="], "@commitlint/read/fs-extra": ["fs-extra@11.3.2", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-Xr9F6z6up6Ws+NjzMCZc6WXg2YFRlrLP9NQDO3VQrWrfiojdhS56TzueT88ze0uBdCTwEIhQ3ptnmKeWGFAe0A=="], @@ -8949,26 +8989,8 @@ "@ethersproject/basex/@ethersproject/properties": ["@ethersproject/properties@5.8.0", "", { "dependencies": { "@ethersproject/logger": "^5.8.0" } }, "sha512-PYuiEoQ+FMaZZNGrStmN7+lWjlsoufGIHdww7454FIaGdbe/p5rnaCXTr5MtBYl3NkeoVhHZuyzChPeGeKIpQw=="], - "@ethersproject/hdnode/@ethersproject/bignumber": ["@ethersproject/bignumber@5.8.0", "", { "dependencies": { "@ethersproject/bytes": "^5.8.0", "@ethersproject/logger": "^5.8.0", "bn.js": "^5.2.1" } }, "sha512-ZyaT24bHaSeJon2tGPKIiHszWjD/54Sz8t57Toch475lCLljC6MgPmxk7Gtzz+ddNN5LuHea9qhAe0x3D+uYPA=="], - - "@ethersproject/hdnode/@ethersproject/properties": ["@ethersproject/properties@5.8.0", "", { "dependencies": { "@ethersproject/logger": "^5.8.0" } }, "sha512-PYuiEoQ+FMaZZNGrStmN7+lWjlsoufGIHdww7454FIaGdbe/p5rnaCXTr5MtBYl3NkeoVhHZuyzChPeGeKIpQw=="], - - "@ethersproject/hdnode/@ethersproject/signing-key": ["@ethersproject/signing-key@5.8.0", "", { "dependencies": { "@ethersproject/bytes": "^5.8.0", "@ethersproject/logger": "^5.8.0", "@ethersproject/properties": "^5.8.0", "bn.js": "^5.2.1", "elliptic": "6.6.1", "hash.js": "1.1.7" } }, "sha512-LrPW2ZxoigFi6U6aVkFN/fa9Yx/+4AtIUe4/HACTvKJdhm0eeb107EVCIQcrLZkxaSIgc/eCrX8Q1GtbH+9n3w=="], - - "@ethersproject/hdnode/@ethersproject/strings": ["@ethersproject/strings@5.8.0", "", { "dependencies": { "@ethersproject/bytes": "^5.8.0", "@ethersproject/constants": "^5.8.0", "@ethersproject/logger": "^5.8.0" } }, "sha512-qWEAk0MAvl0LszjdfnZ2uC8xbR2wdv4cDabyHiBh3Cldq/T8dPH3V4BbBsAYJUeonwD+8afVXld274Ls+Y1xXg=="], - - "@ethersproject/json-wallets/@ethersproject/address": ["@ethersproject/address@5.8.0", "", { "dependencies": { "@ethersproject/bignumber": "^5.8.0", "@ethersproject/bytes": "^5.8.0", "@ethersproject/keccak256": "^5.8.0", "@ethersproject/logger": "^5.8.0", "@ethersproject/rlp": "^5.8.0" } }, "sha512-GhH/abcC46LJwshoN+uBNoKVFPxUuZm6dA257z0vZkKmU1+t8xTn8oK7B9qrj8W2rFRMch4gbJl6PmVxjxBEBA=="], - - "@ethersproject/json-wallets/@ethersproject/keccak256": ["@ethersproject/keccak256@5.8.0", "", { "dependencies": { "@ethersproject/bytes": "^5.8.0", "js-sha3": "0.8.0" } }, "sha512-A1pkKLZSz8pDaQ1ftutZoaN46I6+jvuqugx5KYNeQOPqq+JZ0Txm7dlWesCHB5cndJSu5vP2VKptKf7cksERng=="], - - "@ethersproject/json-wallets/@ethersproject/properties": ["@ethersproject/properties@5.8.0", "", { "dependencies": { "@ethersproject/logger": "^5.8.0" } }, "sha512-PYuiEoQ+FMaZZNGrStmN7+lWjlsoufGIHdww7454FIaGdbe/p5rnaCXTr5MtBYl3NkeoVhHZuyzChPeGeKIpQw=="], - - "@ethersproject/json-wallets/@ethersproject/strings": ["@ethersproject/strings@5.8.0", "", { "dependencies": { "@ethersproject/bytes": "^5.8.0", "@ethersproject/constants": "^5.8.0", "@ethersproject/logger": "^5.8.0" } }, "sha512-qWEAk0MAvl0LszjdfnZ2uC8xbR2wdv4cDabyHiBh3Cldq/T8dPH3V4BbBsAYJUeonwD+8afVXld274Ls+Y1xXg=="], - "@ethersproject/providers/ws": ["ws@7.4.6", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-YmhHDO4MzaDLB+M9ym/mDA5z0naX8j7SIlT8f8z+I0VtzsRbekxEutHSme7NPS2qE8StCYQNUnfWdXta/Yu85A=="], - "@ethersproject/signing-key/@ethersproject/properties": ["@ethersproject/properties@5.8.0", "", { "dependencies": { "@ethersproject/logger": "^5.8.0" } }, "sha512-PYuiEoQ+FMaZZNGrStmN7+lWjlsoufGIHdww7454FIaGdbe/p5rnaCXTr5MtBYl3NkeoVhHZuyzChPeGeKIpQw=="], - "@ethersproject/solidity/@ethersproject/bignumber": ["@ethersproject/bignumber@5.8.0", "", { "dependencies": { "@ethersproject/bytes": "^5.8.0", "@ethersproject/logger": "^5.8.0", "bn.js": "^5.2.1" } }, "sha512-ZyaT24bHaSeJon2tGPKIiHszWjD/54Sz8t57Toch475lCLljC6MgPmxk7Gtzz+ddNN5LuHea9qhAe0x3D+uYPA=="], "@ethersproject/solidity/@ethersproject/keccak256": ["@ethersproject/keccak256@5.8.0", "", { "dependencies": { "@ethersproject/bytes": "^5.8.0", "js-sha3": "0.8.0" } }, "sha512-A1pkKLZSz8pDaQ1ftutZoaN46I6+jvuqugx5KYNeQOPqq+JZ0Txm7dlWesCHB5cndJSu5vP2VKptKf7cksERng=="], @@ -8987,20 +9009,8 @@ "@ethersproject/transactions/@ethersproject/signing-key": ["@ethersproject/signing-key@5.8.0", "", { "dependencies": { "@ethersproject/bytes": "^5.8.0", "@ethersproject/logger": "^5.8.0", "@ethersproject/properties": "^5.8.0", "bn.js": "^5.2.1", "elliptic": "6.6.1", "hash.js": "1.1.7" } }, "sha512-LrPW2ZxoigFi6U6aVkFN/fa9Yx/+4AtIUe4/HACTvKJdhm0eeb107EVCIQcrLZkxaSIgc/eCrX8Q1GtbH+9n3w=="], - "@ethersproject/wallet/@ethersproject/address": ["@ethersproject/address@5.8.0", "", { "dependencies": { "@ethersproject/bignumber": "^5.8.0", "@ethersproject/bytes": "^5.8.0", "@ethersproject/keccak256": "^5.8.0", "@ethersproject/logger": "^5.8.0", "@ethersproject/rlp": "^5.8.0" } }, "sha512-GhH/abcC46LJwshoN+uBNoKVFPxUuZm6dA257z0vZkKmU1+t8xTn8oK7B9qrj8W2rFRMch4gbJl6PmVxjxBEBA=="], - - "@ethersproject/wallet/@ethersproject/bignumber": ["@ethersproject/bignumber@5.8.0", "", { "dependencies": { "@ethersproject/bytes": "^5.8.0", "@ethersproject/logger": "^5.8.0", "bn.js": "^5.2.1" } }, "sha512-ZyaT24bHaSeJon2tGPKIiHszWjD/54Sz8t57Toch475lCLljC6MgPmxk7Gtzz+ddNN5LuHea9qhAe0x3D+uYPA=="], - - "@ethersproject/wallet/@ethersproject/keccak256": ["@ethersproject/keccak256@5.8.0", "", { "dependencies": { "@ethersproject/bytes": "^5.8.0", "js-sha3": "0.8.0" } }, "sha512-A1pkKLZSz8pDaQ1ftutZoaN46I6+jvuqugx5KYNeQOPqq+JZ0Txm7dlWesCHB5cndJSu5vP2VKptKf7cksERng=="], - - "@ethersproject/wallet/@ethersproject/properties": ["@ethersproject/properties@5.8.0", "", { "dependencies": { "@ethersproject/logger": "^5.8.0" } }, "sha512-PYuiEoQ+FMaZZNGrStmN7+lWjlsoufGIHdww7454FIaGdbe/p5rnaCXTr5MtBYl3NkeoVhHZuyzChPeGeKIpQw=="], - - "@ethersproject/wallet/@ethersproject/signing-key": ["@ethersproject/signing-key@5.8.0", "", { "dependencies": { "@ethersproject/bytes": "^5.8.0", "@ethersproject/logger": "^5.8.0", "@ethersproject/properties": "^5.8.0", "bn.js": "^5.2.1", "elliptic": "6.6.1", "hash.js": "1.1.7" } }, "sha512-LrPW2ZxoigFi6U6aVkFN/fa9Yx/+4AtIUe4/HACTvKJdhm0eeb107EVCIQcrLZkxaSIgc/eCrX8Q1GtbH+9n3w=="], - "@expo/bunyan/uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="], - "@expo/cli/@babel/runtime": ["@babel/runtime@7.28.2", "", {}, "sha512-KHp2IflsnGywDjBWDkR9iEqiWSpc8GIi0lgTT3mOElT0PP1tG26P4tmFI2YvAdzgq9RGyoHZQEIEdZy6Ec5xCA=="], - "@expo/cli/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "@expo/cli/ci-info": ["ci-info@3.9.0", "", {}, "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ=="], @@ -9019,6 +9029,8 @@ "@expo/cli/picomatch": ["picomatch@3.0.1", "", {}, "sha512-I3EurrIQMlRc9IaAZnqRR044Phh2DXY+55o7uJ0V+hYZAcQYSuFWsc9q5PvyDHUSCe1Qxn/iBz+78s86zWnGag=="], + "@expo/cli/send": ["send@0.19.1", "", { "dependencies": { "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "fresh": "0.5.2", "http-errors": "2.0.0", "mime": "1.6.0", "ms": "2.1.3", "on-finished": "2.4.1", "range-parser": "~1.2.1", "statuses": "2.0.1" } }, "sha512-p4rRk4f23ynFEfcD9LA0xRYngj+IyGiEYyqqOak8kaN0TvNmuxC2dcVeBn62GpCeR2CpWqyHCNScTP91QbAVFg=="], + "@expo/cli/source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="], "@expo/cli/tar": ["tar@6.2.1", "", { "dependencies": { "chownr": "^2.0.0", "fs-minipass": "^2.0.0", "minipass": "^5.0.0", "minizlib": "^2.1.1", "mkdirp": "^1.0.3", "yallist": "^4.0.0" } }, "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A=="], @@ -9267,8 +9279,6 @@ "@graphql-tools/executor/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "@graphql-tools/executor-graphql-ws/@repeaterjs/repeater": ["@repeaterjs/repeater@3.0.4", "", {}, "sha512-AW8PKd6iX3vAZ0vA43nOUOnbq/X5ihgU+mSXXqunMkeQADGiqw/PY0JNeYtD5sr0PAy51YPgAPbDoeapv9r8WA=="], - "@graphql-tools/executor-graphql-ws/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "@graphql-tools/executor-graphql-ws/ws": ["ws@8.13.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA=="], @@ -9473,8 +9483,6 @@ "@metamask/providers/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], - "@metamask/sdk/@babel/runtime": ["@babel/runtime@7.28.2", "", {}, "sha512-KHp2IflsnGywDjBWDkR9iEqiWSpc8GIi0lgTT3mOElT0PP1tG26P4tmFI2YvAdzgq9RGyoHZQEIEdZy6Ec5xCA=="], - "@metamask/sdk/cross-fetch": ["cross-fetch@4.1.0", "", { "dependencies": { "node-fetch": "^2.7.0" } }, "sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw=="], "@metamask/sdk/pump": ["pump@3.0.3", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA=="], @@ -9505,26 +9513,12 @@ "@nicolo-ribaudo/eslint-scope-5-internals/eslint-scope": ["eslint-scope@5.1.1", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" } }, "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw=="], - "@nx/devkit/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "@nx/eslint/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "@nx/jest/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "@nx/js/@babel/core": ["@babel/core@7.28.4", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.28.3", "@babel/helpers": "^7.28.4", "@babel/parser": "^7.28.4", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.4", "@babel/types": "^7.28.4", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA=="], - - "@nx/js/@babel/runtime": ["@babel/runtime@7.28.2", "", {}, "sha512-KHp2IflsnGywDjBWDkR9iEqiWSpc8GIi0lgTT3mOElT0PP1tG26P4tmFI2YvAdzgq9RGyoHZQEIEdZy6Ec5xCA=="], - "@nx/js/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], - "@nx/js/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "@nx/plugin/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "@nx/workspace/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], - "@nx/workspace/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "@octokit/endpoint/is-plain-object": ["is-plain-object@5.0.0", "", {}, "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q=="], "@octokit/request/is-plain-object": ["is-plain-object@5.0.0", "", {}, "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q=="], @@ -9807,6 +9801,12 @@ "@solana-mobile/wallet-standard-mobile/qrcode": ["qrcode@1.5.4", "", { "dependencies": { "dijkstrajs": "^1.0.1", "pngjs": "^5.0.0", "yargs": "^15.3.1" }, "bin": { "qrcode": "bin/qrcode" } }, "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg=="], + "@solana/errors/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + + "@solana/errors/commander": ["commander@14.0.1", "", {}, "sha512-2JkV3gUZUVrbNA+1sjBOYLsMZ5cEEl8GTFP2a4AVz5hvasAMCQ1D2l2le/cX+pV4N6ZU17zjUahLpIXRrnWL8A=="], + + "@solana/wallet-standard-util/@noble/curves": ["@noble/curves@1.9.7", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw=="], + "@solana/web3.js/bs58": ["bs58@4.0.1", "", { "dependencies": { "base-x": "^3.0.2" } }, "sha512-Ok3Wdf5vOIlBrgCvTq96gBkJw+JUEzdBgyaza5HLtPm7yTHkjRy8+JzNyHF7BHa0bNWOQIp3m5YF0nnFcOIKLw=="], "@solana/web3.js/node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], @@ -9815,8 +9815,6 @@ "@storybook/addon-actions/polished": ["polished@4.3.1", "", { "dependencies": { "@babel/runtime": "^7.17.8" } }, "sha512-OBatVyC/N7SCW/FaDHrSd+vn0o5cS855TOmYi4OkdWUMSJCET/xip//ch8xGUvtr3i44X9LVyWwQlRMTN3pwSA=="], - "@storybook/addon-actions/uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="], - "@storybook/addon-essentials/@storybook/addon-controls": ["@storybook/addon-controls@8.5.2", "", { "dependencies": { "@storybook/global": "^5.0.0", "dequal": "^2.0.2", "ts-dedent": "^2.0.0" }, "peerDependencies": { "storybook": "^8.5.2" } }, "sha512-wkzw2vRff4zkzdvC/GOlB2PlV0i973u8igSLeg34TWNEAa4bipwVHnFfIojRuP9eN1bZL/0tjuU5pKnbTqH7aQ=="], "@storybook/addon-interactions/polished": ["polished@4.3.1", "", { "dependencies": { "@babel/runtime": "^7.17.8" } }, "sha512-OBatVyC/N7SCW/FaDHrSd+vn0o5cS855TOmYi4OkdWUMSJCET/xip//ch8xGUvtr3i44X9LVyWwQlRMTN3pwSA=="], @@ -9865,8 +9863,6 @@ "@storybook/test/@vitest/spy": ["@vitest/spy@2.0.5", "", { "dependencies": { "tinyspy": "^3.0.0" } }, "sha512-c/jdthAhvJdpfVuaexSrnawxZz6pywlTPe84LUB2m/4t3rl2fTo9NFGBG4oWgaD+FTgDDV8hJ/nibT7IfH3JfA=="], - "@storybook/test-runner/@babel/core": ["@babel/core@7.28.4", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.28.3", "@babel/helpers": "^7.28.4", "@babel/parser": "^7.28.4", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.4", "@babel/types": "^7.28.4", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA=="], - "@storybook/test-runner/@storybook/csf": ["@storybook/csf@0.1.13", "", { "dependencies": { "type-fest": "^2.19.0" } }, "sha512-7xOOwCLGB3ebM87eemep89MYRFTko+D8qE7EdAAq74lgdqRR5cOUtYWJLjO2dLtP94nqoOdHJo6MdLLKzg412Q=="], "@surma/rollup-plugin-off-main-thread/magic-string": ["magic-string@0.25.9", "", { "dependencies": { "sourcemap-codec": "^1.4.8" } }, "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ=="], @@ -9903,8 +9899,6 @@ "@tamagui/static/@babel/core": ["@babel/core@7.28.4", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.28.3", "@babel/helpers": "^7.28.4", "@babel/parser": "^7.28.4", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.4", "@babel/types": "^7.28.4", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA=="], - "@tamagui/static/@babel/runtime": ["@babel/runtime@7.28.2", "", {}, "sha512-KHp2IflsnGywDjBWDkR9iEqiWSpc8GIi0lgTT3mOElT0PP1tG26P4tmFI2YvAdzgq9RGyoHZQEIEdZy6Ec5xCA=="], - "@tamagui/static/esbuild": ["esbuild@0.24.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.24.2", "@esbuild/android-arm": "0.24.2", "@esbuild/android-arm64": "0.24.2", "@esbuild/android-x64": "0.24.2", "@esbuild/darwin-arm64": "0.24.2", "@esbuild/darwin-x64": "0.24.2", "@esbuild/freebsd-arm64": "0.24.2", "@esbuild/freebsd-x64": "0.24.2", "@esbuild/linux-arm": "0.24.2", "@esbuild/linux-arm64": "0.24.2", "@esbuild/linux-ia32": "0.24.2", "@esbuild/linux-loong64": "0.24.2", "@esbuild/linux-mips64el": "0.24.2", "@esbuild/linux-ppc64": "0.24.2", "@esbuild/linux-riscv64": "0.24.2", "@esbuild/linux-s390x": "0.24.2", "@esbuild/linux-x64": "0.24.2", "@esbuild/netbsd-arm64": "0.24.2", "@esbuild/netbsd-x64": "0.24.2", "@esbuild/openbsd-arm64": "0.24.2", "@esbuild/openbsd-x64": "0.24.2", "@esbuild/sunos-x64": "0.24.2", "@esbuild/win32-arm64": "0.24.2", "@esbuild/win32-ia32": "0.24.2", "@esbuild/win32-x64": "0.24.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA=="], "@tamagui/static/fs-extra": ["fs-extra@11.3.2", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-Xr9F6z6up6Ws+NjzMCZc6WXg2YFRlrLP9NQDO3VQrWrfiojdhS56TzueT88ze0uBdCTwEIhQ3ptnmKeWGFAe0A=="], @@ -9937,8 +9931,6 @@ "@tybys/wasm-util/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "@types/express/@types/express-serve-static-core": ["@types/express-serve-static-core@4.19.7", "", { "dependencies": { "@types/node": "*", "@types/qs": "*", "@types/range-parser": "*", "@types/send": "*" } }, "sha512-FvPtiIf1LfhzsaIXhv/PHan/2FeQBbtBDtfX2QfvPxdUelMDEckK08SM6nqo1MIZY3RUlfA+HV8+hFUSio78qg=="], - "@types/request/form-data": ["form-data@2.5.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.35", "safe-buffer": "^5.2.1" } }, "sha512-jqdObeR2rxZZbPSGL+3VckHMYtu+f9//KXBsVny6JSX/pa38Fy+bGjuG8eW/H6USNQWhLi8Num++cU2yOCNz4A=="], "@typescript-eslint/typescript-estree/globby": ["globby@11.1.0", "", { "dependencies": { "array-union": "^2.1.0", "dir-glob": "^3.0.1", "fast-glob": "^3.2.9", "ignore": "^5.2.0", "merge2": "^1.4.1", "slash": "^3.0.0" } }, "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g=="], @@ -10833,8 +10825,6 @@ "jest-util/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], - "jest-util/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], - "jest-validate/@jest/types": ["@jest/types@30.2.0", "", { "dependencies": { "@jest/pattern": "30.0.1", "@jest/schemas": "30.0.5", "@types/istanbul-lib-coverage": "^2.0.6", "@types/istanbul-reports": "^3.0.4", "@types/node": "*", "@types/yargs": "^17.0.33", "chalk": "^4.1.2" } }, "sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg=="], "jest-validate/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], @@ -10881,10 +10871,6 @@ "keccak/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], - "knip/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], - - "knip/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - "latest-version/package-json": ["package-json@10.0.1", "", { "dependencies": { "ky": "^1.2.0", "registry-auth-token": "^5.0.2", "registry-url": "^6.0.1", "semver": "^7.6.0" } }, "sha512-ua1L4OgXSBdsu1FPb7F3tYH0F48a6kxvod4pLUlGY9COeJAJQNX/sNH2IiEmsxw7lqYiAwrdHMjz1FctOsyDQg=="], "lazystream/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], @@ -10933,8 +10919,6 @@ "matcher-collection/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], - "md5.js/hash-base": ["hash-base@3.1.2", "", { "dependencies": { "inherits": "^2.0.4", "readable-stream": "^2.3.8", "safe-buffer": "^5.2.1", "to-buffer": "^1.2.1" } }, "sha512-Bb33KbowVTIj5s7Ked1OsqHUeCpz//tPwR+E2zJgJKo9Z5XolZ9b6bdUgjmYlwnWhoOQKoTd1TYToZGn5mAYOg=="], - "meow/type-fest": ["type-fest@0.18.1", "", {}, "sha512-OIAYXk8+ISY+qTOwkHtKqzAuxchoMiD9Udx+FSGQDuiRR+PJKJHc2NJAXlbhkGwTt/4/nKZxELY1w3ReWOL8mw=="], "meow/yargs-parser": ["yargs-parser@20.2.9", "", {}, "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w=="], @@ -10973,10 +10957,6 @@ "metro-file-map/jest-worker": ["jest-worker@29.7.0", "", { "dependencies": { "@types/node": "*", "jest-util": "^29.7.0", "merge-stream": "^2.0.0", "supports-color": "^8.0.0" } }, "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw=="], - "metro-minify-terser/terser": ["terser@5.44.0", "", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-nIVck8DK+GM/0Frwd+nIhZ84pR/BX7rmXMfYwyg+Sri5oGVE99/E3KvXqpC2xHFxyqXyGHTKBSioxxplrO4I4w=="], - - "metro-runtime/@babel/runtime": ["@babel/runtime@7.28.2", "", {}, "sha512-KHp2IflsnGywDjBWDkR9iEqiWSpc8GIi0lgTT3mOElT0PP1tG26P4tmFI2YvAdzgq9RGyoHZQEIEdZy6Ec5xCA=="], - "metro-source-map/source-map": ["source-map@0.5.7", "", {}, "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ=="], "metro-source-map/vlq": ["vlq@1.0.1", "", {}, "sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w=="], @@ -11577,8 +11557,6 @@ "tamagui-loader/esbuild-loader": ["esbuild-loader@4.4.0", "", { "dependencies": { "esbuild": "^0.25.0", "get-tsconfig": "^4.10.1", "loader-utils": "^2.0.4", "webpack-sources": "^1.4.3" }, "peerDependencies": { "webpack": "^4.40.0 || ^5.0.0" } }, "sha512-4J+hXTpTtEdzUNLoY8ReqDNJx2NoldfiljRCiKbeYUuZmVaiJeDqFgyAzz8uOopaekwRoCcqBFyEroGQLFVZ1g=="], - "tamagui-loader/fs-extra": ["fs-extra@11.3.2", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-Xr9F6z6up6Ws+NjzMCZc6WXg2YFRlrLP9NQDO3VQrWrfiojdhS56TzueT88ze0uBdCTwEIhQ3ptnmKeWGFAe0A=="], - "tamagui-loader/loader-utils": ["loader-utils@3.3.1", "", {}, "sha512-FMJTLMXfCLMLfJxcX9PFqX5qD88Z5MRGaZCVzfuqeZSPsyiBzs+pahDQjbIWz2QIzPZz0NX9Zy4FX3lmK6YHIg=="], "tar/mkdirp": ["mkdirp@0.5.6", "", { "dependencies": { "minimist": "^1.2.6" }, "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw=="], @@ -11695,18 +11673,12 @@ "vinyl-sourcemap/vinyl": ["vinyl@2.2.1", "", { "dependencies": { "clone": "^2.1.1", "clone-buffer": "^1.0.0", "clone-stats": "^1.0.0", "cloneable-readable": "^1.0.0", "remove-trailing-separator": "^1.0.1", "replace-ext": "^1.0.0" } }, "sha512-LII3bXRFBZLlezoG5FfZVcXflZgWP/4dCwKtxd5ky9+LOtM4CS3bIRQsmR1KMnMW07jpE8fqR2lcxPZ+8sJIcw=="], - "vite/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], - - "vite-plugin-bundlesize/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], - "vite-plugin-bundlesize/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], "vite-plugin-svgr/@svgr/core": ["@svgr/core@8.1.0", "", { "dependencies": { "@babel/core": "^7.21.3", "@svgr/babel-preset": "8.1.0", "camelcase": "^6.2.0", "cosmiconfig": "^8.1.3", "snake-case": "^3.0.4" } }, "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA=="], "vite-plugin-svgr/@svgr/plugin-jsx": ["@svgr/plugin-jsx@8.1.0", "", { "dependencies": { "@babel/core": "^7.21.3", "@svgr/babel-preset": "8.1.0", "@svgr/hast-util-to-babel-ast": "8.0.0", "svg-parser": "^2.0.4" }, "peerDependencies": { "@svgr/core": "*" } }, "sha512-0xiIyBsLlr8quN+WyuxooNW9RJ0Dpr8uOnH/xrCVO8GLUcwHISwj1AG0k+LFzteTkAA0GbX0kj9q6Dk70PTiPA=="], - "vitest/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], - "vitest/vite-node": ["vite-node@3.2.1", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.4.1", "es-module-lexer": "^1.7.0", "pathe": "^2.0.3", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-V4EyKQPxquurNJPtQJRZo8hKOoKNBRIhxcDbQFPFig0JdoWcUhwRgK8yoCXXrfYVPKS6XwirGHPszLnR8FbjCA=="], "wagmi/use-sync-external-store": ["use-sync-external-store@1.4.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-9WXSPC5fMv61vaupRkCKCxsPxBocVnwakBEkMIHHpkTTg6icbJtg6jzgtLDm4bl3cSHAca52rYWih0k4K3PfHw=="], @@ -11839,8 +11811,6 @@ "@babel/register/source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], - "@binance/w3w-qrcode-modal/qrcode/yargs": ["yargs@15.4.1", "", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="], - "@chromatic-com/storybook/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], "@commitlint/format/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], @@ -11877,12 +11847,6 @@ "@ethersproject/abstract-provider/@ethersproject/web/@ethersproject/strings": ["@ethersproject/strings@5.8.0", "", { "dependencies": { "@ethersproject/bytes": "^5.8.0", "@ethersproject/constants": "^5.8.0", "@ethersproject/logger": "^5.8.0" } }, "sha512-qWEAk0MAvl0LszjdfnZ2uC8xbR2wdv4cDabyHiBh3Cldq/T8dPH3V4BbBsAYJUeonwD+8afVXld274Ls+Y1xXg=="], - "@ethersproject/hdnode/@ethersproject/strings/@ethersproject/constants": ["@ethersproject/constants@5.8.0", "", { "dependencies": { "@ethersproject/bignumber": "^5.8.0" } }, "sha512-wigX4lrf5Vu+axVTIvNsuL6YrV4O5AXl5ubcURKMEME5TnWBouUh0CDTWxZ2GpnRn1kcCgE7l8O5+VbV9QTTcg=="], - - "@ethersproject/json-wallets/@ethersproject/address/@ethersproject/bignumber": ["@ethersproject/bignumber@5.8.0", "", { "dependencies": { "@ethersproject/bytes": "^5.8.0", "@ethersproject/logger": "^5.8.0", "bn.js": "^5.2.1" } }, "sha512-ZyaT24bHaSeJon2tGPKIiHszWjD/54Sz8t57Toch475lCLljC6MgPmxk7Gtzz+ddNN5LuHea9qhAe0x3D+uYPA=="], - - "@ethersproject/json-wallets/@ethersproject/strings/@ethersproject/constants": ["@ethersproject/constants@5.8.0", "", { "dependencies": { "@ethersproject/bignumber": "^5.8.0" } }, "sha512-wigX4lrf5Vu+axVTIvNsuL6YrV4O5AXl5ubcURKMEME5TnWBouUh0CDTWxZ2GpnRn1kcCgE7l8O5+VbV9QTTcg=="], - "@ethersproject/solidity/@ethersproject/strings/@ethersproject/constants": ["@ethersproject/constants@5.8.0", "", { "dependencies": { "@ethersproject/bignumber": "^5.8.0" } }, "sha512-wigX4lrf5Vu+axVTIvNsuL6YrV4O5AXl5ubcURKMEME5TnWBouUh0CDTWxZ2GpnRn1kcCgE7l8O5+VbV9QTTcg=="], "@expo/cli/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], @@ -11911,6 +11875,10 @@ "@expo/cli/ora/strip-ansi": ["strip-ansi@5.2.0", "", { "dependencies": { "ansi-regex": "^4.1.0" } }, "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA=="], + "@expo/cli/send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], + + "@expo/cli/send/range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], + "@expo/cli/source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], "@expo/cli/tar/chownr": ["chownr@2.0.0", "", {}, "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ=="], @@ -12361,6 +12329,10 @@ "@solana/web3.js/node-fetch/whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], + "@storybook/addon-actions/polished/@babel/runtime": ["@babel/runtime@7.28.2", "", {}, "sha512-KHp2IflsnGywDjBWDkR9iEqiWSpc8GIi0lgTT3mOElT0PP1tG26P4tmFI2YvAdzgq9RGyoHZQEIEdZy6Ec5xCA=="], + + "@storybook/addon-interactions/polished/@babel/runtime": ["@babel/runtime@7.28.2", "", {}, "sha512-KHp2IflsnGywDjBWDkR9iEqiWSpc8GIi0lgTT3mOElT0PP1tG26P4tmFI2YvAdzgq9RGyoHZQEIEdZy6Ec5xCA=="], + "@storybook/builder-webpack5/terser-webpack-plugin/schema-utils": ["schema-utils@4.3.3", "", { "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", "ajv-formats": "^2.1.1", "ajv-keywords": "^5.1.0" } }, "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA=="], "@storybook/builder-webpack5/terser-webpack-plugin/terser": ["terser@5.44.0", "", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-nIVck8DK+GM/0Frwd+nIhZ84pR/BX7rmXMfYwyg+Sri5oGVE99/E3KvXqpC2xHFxyqXyGHTKBSioxxplrO4I4w=="], @@ -12375,6 +12347,8 @@ "@storybook/preset-react-webpack/find-up/locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], + "@storybook/react-native-theming/polished/@babel/runtime": ["@babel/runtime@7.28.2", "", {}, "sha512-KHp2IflsnGywDjBWDkR9iEqiWSpc8GIi0lgTT3mOElT0PP1tG26P4tmFI2YvAdzgq9RGyoHZQEIEdZy6Ec5xCA=="], + "@storybook/react-native-ui/@storybook/react/@storybook/components": ["@storybook/components@8.6.14", "", { "peerDependencies": { "storybook": "^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0" } }, "sha512-HNR2mC5I4Z5ek8kTrVZlIY/B8gJGs5b3XdZPBPBopTIN6U/YHXiDyOjY3JlaS4fSG1fVhp/Qp1TpMn1w/9m1pw=="], "@storybook/react-native-ui/@storybook/react/@storybook/manager-api": ["@storybook/manager-api@8.6.14", "", { "peerDependencies": { "storybook": "^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0" } }, "sha512-ez0Zihuy17udLbfHZQXkGqwtep0mSGgHcNzGN7iZrMP1m+VmNo+7aGCJJdvXi7+iU3yq8weXSQFWg5DqWgLS7g=="], @@ -12385,6 +12359,8 @@ "@storybook/react-native-ui/@storybook/react/@storybook/theming": ["@storybook/theming@8.6.14", "", { "peerDependencies": { "storybook": "^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0" } }, "sha512-r4y+LsiB37V5hzpQo+BM10PaCsp7YlZ0YcZzQP1OCkPlYXmUAFy2VvDKaFRpD8IeNPKug2u4iFm/laDEbs03dg=="], + "@storybook/react-native-ui/polished/@babel/runtime": ["@babel/runtime@7.28.2", "", {}, "sha512-KHp2IflsnGywDjBWDkR9iEqiWSpc8GIi0lgTT3mOElT0PP1tG26P4tmFI2YvAdzgq9RGyoHZQEIEdZy6Ec5xCA=="], + "@storybook/react-native/glob/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], "@storybook/test/@testing-library/jest-dom/chalk": ["chalk@3.0.0", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg=="], @@ -13461,8 +13437,6 @@ "matcher-collection/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], - "md5.js/hash-base/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], - "metro-config/cosmiconfig/import-fresh": ["import-fresh@2.0.0", "", { "dependencies": { "caller-path": "^2.0.0", "resolve-from": "^3.0.0" } }, "sha512-eZ5H8rcgYazHbKC3PG4ClHNykCSxtAhxSSEM+2mb+7evD2CKF5V7c0dNum7AdpDh0ZdICwZY9sRSn8f+KH96sg=="], "metro-config/cosmiconfig/js-yaml": ["js-yaml@3.14.1", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g=="], @@ -13471,14 +13445,10 @@ "metro-config/jest-validate/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], - "metro-config/metro-runtime/@babel/runtime": ["@babel/runtime@7.28.2", "", {}, "sha512-KHp2IflsnGywDjBWDkR9iEqiWSpc8GIi0lgTT3mOElT0PP1tG26P4tmFI2YvAdzgq9RGyoHZQEIEdZy6Ec5xCA=="], - "metro-file-map/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], "metro-file-map/jest-worker/jest-util": ["jest-util@29.7.0", "", { "dependencies": { "@jest/types": "^29.6.3", "@types/node": "*", "chalk": "^4.0.0", "ci-info": "^3.2.0", "graceful-fs": "^4.2.9", "picomatch": "^2.2.3" } }, "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA=="], - "metro-minify-terser/terser/source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="], - "metro-transform-worker/metro-source-map/metro-symbolicate": ["metro-symbolicate@0.81.1", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", "metro-source-map": "0.81.1", "nullthrows": "^1.1.1", "source-map": "^0.5.6", "vlq": "^1.0.0" }, "bin": { "metro-symbolicate": "src/index.js" } }, "sha512-Lgk0qjEigtFtsM7C0miXITbcV47E1ZYIfB+m/hCraihiwRWkNUQEPCWvqZmwXKSwVE5mXA0EzQtghAvQSjZDxw=="], "metro-transform-worker/metro-source-map/ob1": ["ob1@0.81.1", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-1PEbvI+AFvOcgdNcO79FtDI1TUO8S3lhiKOyAiyWQF3sFDDKS+aw2/BZvGlArFnSmqckwOOB9chQuIX0/OahoQ=="], @@ -13497,8 +13467,6 @@ "metro/jest-worker/jest-util": ["jest-util@29.7.0", "", { "dependencies": { "@jest/types": "^29.6.3", "@types/node": "*", "chalk": "^4.0.0", "ci-info": "^3.2.0", "graceful-fs": "^4.2.9", "picomatch": "^2.2.3" } }, "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA=="], - "metro/metro-runtime/@babel/runtime": ["@babel/runtime@7.28.2", "", {}, "sha512-KHp2IflsnGywDjBWDkR9iEqiWSpc8GIi0lgTT3mOElT0PP1tG26P4tmFI2YvAdzgq9RGyoHZQEIEdZy6Ec5xCA=="], - "metro/metro-source-map/ob1": ["ob1@0.81.1", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-1PEbvI+AFvOcgdNcO79FtDI1TUO8S3lhiKOyAiyWQF3sFDDKS+aw2/BZvGlArFnSmqckwOOB9chQuIX0/OahoQ=="], "metro/metro-source-map/vlq": ["vlq@1.0.1", "", {}, "sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w=="], @@ -13827,7 +13795,7 @@ "storybook/@storybook/core/@storybook/csf": ["@storybook/csf@0.1.12", "", { "dependencies": { "type-fest": "^2.19.0" } }, "sha512-9/exVhabisyIVL0VxTCxo01Tdm8wefIXKXfltAPTSr8cbLn5JAxGQ6QV3mjdecLGEOucfoVhAKtJfVHxEK1iqw=="], - "storybook/@storybook/core/esbuild": ["esbuild@0.19.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.19.12", "@esbuild/android-arm": "0.19.12", "@esbuild/android-arm64": "0.19.12", "@esbuild/android-x64": "0.19.12", "@esbuild/darwin-arm64": "0.19.12", "@esbuild/darwin-x64": "0.19.12", "@esbuild/freebsd-arm64": "0.19.12", "@esbuild/freebsd-x64": "0.19.12", "@esbuild/linux-arm": "0.19.12", "@esbuild/linux-arm64": "0.19.12", "@esbuild/linux-ia32": "0.19.12", "@esbuild/linux-loong64": "0.19.12", "@esbuild/linux-mips64el": "0.19.12", "@esbuild/linux-ppc64": "0.19.12", "@esbuild/linux-riscv64": "0.19.12", "@esbuild/linux-s390x": "0.19.12", "@esbuild/linux-x64": "0.19.12", "@esbuild/netbsd-x64": "0.19.12", "@esbuild/openbsd-x64": "0.19.12", "@esbuild/sunos-x64": "0.19.12", "@esbuild/win32-arm64": "0.19.12", "@esbuild/win32-ia32": "0.19.12", "@esbuild/win32-x64": "0.19.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg=="], + "storybook/@storybook/core/esbuild": ["esbuild@0.24.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.24.2", "@esbuild/android-arm": "0.24.2", "@esbuild/android-arm64": "0.24.2", "@esbuild/android-x64": "0.24.2", "@esbuild/darwin-arm64": "0.24.2", "@esbuild/darwin-x64": "0.24.2", "@esbuild/freebsd-arm64": "0.24.2", "@esbuild/freebsd-x64": "0.24.2", "@esbuild/linux-arm": "0.24.2", "@esbuild/linux-arm64": "0.24.2", "@esbuild/linux-ia32": "0.24.2", "@esbuild/linux-loong64": "0.24.2", "@esbuild/linux-mips64el": "0.24.2", "@esbuild/linux-ppc64": "0.24.2", "@esbuild/linux-riscv64": "0.24.2", "@esbuild/linux-s390x": "0.24.2", "@esbuild/linux-x64": "0.24.2", "@esbuild/netbsd-arm64": "0.24.2", "@esbuild/netbsd-x64": "0.24.2", "@esbuild/openbsd-arm64": "0.24.2", "@esbuild/openbsd-x64": "0.24.2", "@esbuild/sunos-x64": "0.24.2", "@esbuild/win32-arm64": "0.24.2", "@esbuild/win32-ia32": "0.24.2", "@esbuild/win32-x64": "0.24.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA=="], "styled-components/@emotion/is-prop-valid/@emotion/memoize": ["@emotion/memoize@0.9.0", "", {}, "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ=="], @@ -13893,6 +13861,8 @@ "unstorage/chokidar/readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], + "update-check/registry-auth-token/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], + "update-notifier/boxen/camelcase": ["camelcase@8.0.0", "", {}, "sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA=="], "update-notifier/boxen/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], @@ -14055,12 +14025,6 @@ "@babel/register/find-cache-dir/pkg-dir/find-up": ["find-up@3.0.0", "", { "dependencies": { "locate-path": "^3.0.0" } }, "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg=="], - "@binance/w3w-qrcode-modal/qrcode/yargs/cliui": ["cliui@6.0.0", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="], - - "@binance/w3w-qrcode-modal/qrcode/yargs/y18n": ["y18n@4.0.3", "", {}, "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="], - - "@binance/w3w-qrcode-modal/qrcode/yargs/yargs-parser": ["yargs-parser@18.1.3", "", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="], - "@commitlint/format/chalk/supports-color/has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], "@commitlint/load/chalk/supports-color/has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], @@ -14079,8 +14043,6 @@ "@ethersproject/abstract-provider/@ethersproject/web/@ethersproject/strings/@ethersproject/constants": ["@ethersproject/constants@5.8.0", "", { "dependencies": { "@ethersproject/bignumber": "^5.8.0" } }, "sha512-wigX4lrf5Vu+axVTIvNsuL6YrV4O5AXl5ubcURKMEME5TnWBouUh0CDTWxZ2GpnRn1kcCgE7l8O5+VbV9QTTcg=="], - "@ethersproject/json-wallets/@ethersproject/strings/@ethersproject/constants/@ethersproject/bignumber": ["@ethersproject/bignumber@5.8.0", "", { "dependencies": { "@ethersproject/bytes": "^5.8.0", "@ethersproject/logger": "^5.8.0", "bn.js": "^5.2.1" } }, "sha512-ZyaT24bHaSeJon2tGPKIiHszWjD/54Sz8t57Toch475lCLljC6MgPmxk7Gtzz+ddNN5LuHea9qhAe0x3D+uYPA=="], - "@expo/cli/chalk/supports-color/has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], "@expo/cli/glob/foreground-child/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], @@ -14093,6 +14055,8 @@ "@expo/cli/ora/strip-ansi/ansi-regex": ["ansi-regex@4.1.1", "", {}, "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g=="], + "@expo/cli/send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], + "@expo/cli/tar/fs-minipass/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], "@expo/cli/tar/minizlib/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], @@ -14711,10 +14675,6 @@ "madge/chalk/supports-color/has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], - "md5.js/hash-base/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], - - "md5.js/hash-base/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], - "metro-config/cosmiconfig/import-fresh/resolve-from": ["resolve-from@3.0.0", "", {}, "sha512-GnlH6vxLymXJNMBo7XP1fJIzBFbdYt49CuTwmB/6N53t+kMPRMFKz783LlQ4tv28XoQfMWinAJX6WCGf2IlaIw=="], "metro-config/cosmiconfig/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], @@ -14729,8 +14689,6 @@ "metro-file-map/jest-worker/jest-util/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], - "metro-minify-terser/terser/source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], - "metro/chalk/supports-color/has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], "metro/jest-worker/jest-util/ci-info": ["ci-info@3.9.0", "", {}, "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ=="], @@ -14947,51 +14905,55 @@ "static-eval/escodegen/optionator/type-check": ["type-check@0.3.2", "", { "dependencies": { "prelude-ls": "~1.1.2" } }, "sha512-ZCmOJdvOWDBYJlzAoFkC+Q0+bUyEOS1ltgp1MGU03fqHG+dbi9tBFU2Rd9QKiDZFAYrhPh2JUf7rZRIuHRKtOg=="], - "storybook/@storybook/core/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.19.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA=="], + "storybook/@storybook/core/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.24.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA=="], - "storybook/@storybook/core/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.19.12", "", { "os": "android", "cpu": "arm" }, "sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w=="], + "storybook/@storybook/core/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.24.2", "", { "os": "android", "cpu": "arm" }, "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q=="], - "storybook/@storybook/core/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.19.12", "", { "os": "android", "cpu": "arm64" }, "sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA=="], + "storybook/@storybook/core/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.24.2", "", { "os": "android", "cpu": "arm64" }, "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg=="], - "storybook/@storybook/core/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.19.12", "", { "os": "android", "cpu": "x64" }, "sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew=="], + "storybook/@storybook/core/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.24.2", "", { "os": "android", "cpu": "x64" }, "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw=="], - "storybook/@storybook/core/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.19.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g=="], + "storybook/@storybook/core/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.24.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA=="], - "storybook/@storybook/core/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.19.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A=="], + "storybook/@storybook/core/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.24.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA=="], - "storybook/@storybook/core/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.19.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA=="], + "storybook/@storybook/core/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.24.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg=="], - "storybook/@storybook/core/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.19.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg=="], + "storybook/@storybook/core/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.24.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q=="], - "storybook/@storybook/core/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.19.12", "", { "os": "linux", "cpu": "arm" }, "sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w=="], + "storybook/@storybook/core/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.24.2", "", { "os": "linux", "cpu": "arm" }, "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA=="], - "storybook/@storybook/core/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.19.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA=="], + "storybook/@storybook/core/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.24.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg=="], - "storybook/@storybook/core/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.19.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA=="], + "storybook/@storybook/core/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.24.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw=="], - "storybook/@storybook/core/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.19.12", "", { "os": "linux", "cpu": "none" }, "sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA=="], + "storybook/@storybook/core/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.24.2", "", { "os": "linux", "cpu": "none" }, "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ=="], - "storybook/@storybook/core/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.19.12", "", { "os": "linux", "cpu": "none" }, "sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w=="], + "storybook/@storybook/core/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.24.2", "", { "os": "linux", "cpu": "none" }, "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw=="], - "storybook/@storybook/core/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.19.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg=="], + "storybook/@storybook/core/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.24.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw=="], - "storybook/@storybook/core/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.19.12", "", { "os": "linux", "cpu": "none" }, "sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg=="], + "storybook/@storybook/core/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.24.2", "", { "os": "linux", "cpu": "none" }, "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q=="], - "storybook/@storybook/core/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.19.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg=="], + "storybook/@storybook/core/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.24.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw=="], - "storybook/@storybook/core/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.19.12", "", { "os": "linux", "cpu": "x64" }, "sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg=="], + "storybook/@storybook/core/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.24.2", "", { "os": "linux", "cpu": "x64" }, "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q=="], - "storybook/@storybook/core/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.19.12", "", { "os": "none", "cpu": "x64" }, "sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA=="], + "storybook/@storybook/core/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.24.2", "", { "os": "none", "cpu": "arm64" }, "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw=="], - "storybook/@storybook/core/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.19.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw=="], + "storybook/@storybook/core/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.24.2", "", { "os": "none", "cpu": "x64" }, "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw=="], - "storybook/@storybook/core/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.19.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA=="], + "storybook/@storybook/core/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.24.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A=="], - "storybook/@storybook/core/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.19.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A=="], + "storybook/@storybook/core/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.24.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA=="], - "storybook/@storybook/core/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.19.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ=="], + "storybook/@storybook/core/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.24.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig=="], - "storybook/@storybook/core/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.19.12", "", { "os": "win32", "cpu": "x64" }, "sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA=="], + "storybook/@storybook/core/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.24.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ=="], + + "storybook/@storybook/core/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.24.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA=="], + + "storybook/@storybook/core/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.24.2", "", { "os": "win32", "cpu": "x64" }, "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg=="], "sucrase/glob/foreground-child/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], @@ -15059,8 +15021,6 @@ "@babel/register/find-cache-dir/pkg-dir/find-up/locate-path": ["locate-path@3.0.0", "", { "dependencies": { "p-locate": "^3.0.0", "path-exists": "^3.0.0" } }, "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A=="], - "@binance/w3w-qrcode-modal/qrcode/yargs/yargs-parser/camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="], - "@commitlint/top-level/find-up/locate-path/p-locate/p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], "@datadog/datadog-ci/ora/chalk/supports-color/has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], diff --git a/config/jest-presets/jest/jest-preset.js b/config/jest-presets/jest/jest-preset.js index c43d4e5925c..c151e795496 100644 --- a/config/jest-presets/jest/jest-preset.js +++ b/config/jest-presets/jest/jest-preset.js @@ -34,7 +34,7 @@ module.exports = { // changedSince: 'master', // https://github.com/facebook/jest/issues/2663#issuecomment-341384494 transformIgnorePatterns: [ - 'node_modules/(?!(react-native|react-native-web|react-native-modal-selector|react-native-modal-datetime-picker|react-native-keyboard-controller|@react-navigation|@storybook/react-native|@react-native-community/datetimepicker|react-native-image-colors|uuid|react-native-reanimated|react-native-safe-area-context|react-native-localize|@react-native-masked-view|@statsig-js/js-client|@statsig/react-native-bindings|@statsig/react-bindings|@statsig/js-local-overrides|@react-native|@react-native-firebase|@uniswap/client-embeddedwallet|@uniswap/client-data-api|@uniswap/client-pools|@uniswap/client-platform-service|@connectrpc|@bufbuild|react-native-webview|@gorhom|expo.*|d3-(array|color|format|interpolate|path|scale|shape|time-format|time)|internmap|react-native-qrcode-svg|react-native-modal|react-native-animatable|react-native-masked-view|redux-persist|react-native-url-polyfill|react-native-context-menu-view|react-native-wagmi-charts|react-native-markdown-display|react-native-redash|@walletconnect|moti|react-native-image-picker|wagmi|viem|rn-qr-generator|@solana|jayson)/)', + 'node_modules/(?!(react-native|@universe|react-native-web|react-native-modal-selector|react-native-modal-datetime-picker|react-native-keyboard-controller|@react-navigation|@storybook/react-native|@react-native-community/datetimepicker|react-native-image-colors|uuid|react-native-reanimated|react-native-safe-area-context|react-native-localize|@react-native-masked-view|@statsig-js/js-client|@statsig/react-native-bindings|@statsig/react-bindings|@statsig/js-local-overrides|@react-native|@react-native-firebase|@uniswap/client-embeddedwallet|@uniswap/client-data-api|@uniswap/client-platform-service|@connectrpc|@bufbuild|react-native-webview|@gorhom|expo.*|d3-(array|color|format|interpolate|path|scale|shape|time-format|time)|internmap|react-native-qrcode-svg|react-native-modal|react-native-animatable|react-native-masked-view|redux-persist|react-native-url-polyfill|react-native-context-menu-view|react-native-wagmi-charts|react-native-markdown-display|react-native-redash|@walletconnect|moti|react-native-image-picker|wagmi|viem|rn-qr-generator|@solana|jayson|@uniswap\/client-search)/)', ], collectCoverage: false, // only collect in CI clearMocks: true, diff --git a/config/jest-presets/jest/setup.js b/config/jest-presets/jest/setup.js index acfd62262e2..53cf1b368e0 100644 --- a/config/jest-presets/jest/setup.js +++ b/config/jest-presets/jest/setup.js @@ -109,39 +109,33 @@ const NetInfoStateType = { jest.mock('@react-native-community/netinfo', () => ({ ...mockRNCNetInfo, NetInfoStateType })) -jest.mock('uniswap/src/features/gating/sdk/statsig', () => { - const real = jest.requireActual('uniswap/src/features/gating/sdk/statsig') - const StatsigMock = { - ...real, - useGate: () => { - return { - isLoading: false, - value: false, - } - }, - useConfig: () => { - return {} - }, - +jest.mock('@universe/gating', () => { + const actual = jest.requireActual('@universe/gating') + return { + ...actual, + // Mock functions + useDynamicConfigValue: jest.fn((args) => args.defaultValue), + useFeatureFlag: jest.fn(() => false), + useGate: jest.fn(() => ({ isLoading: false, value: false })), + useConfig: jest.fn(() => ({})), + getStatsigClient: jest.fn(() => ({ + checkGate: jest.fn(() => false), + getConfig: jest.fn(() => ({ + get: (_name, fallback) => fallback, + getValue: (_name, fallback) => fallback, + })), + getLayer: jest.fn(() => ({ + get: jest.fn(() => false), + })), + })), Statsig: { - checkGate: () => false, - getConfig: () => { - return { - get: (_name, fallback) => fallback, - getValue: (_name, fallback) => fallback, - } - }, + checkGate: jest.fn(() => false), + getConfig: jest.fn(() => ({ + get: (_name, fallback) => fallback, + getValue: (_name, fallback) => fallback, + })), }, } - return StatsigMock -}) - -jest.mock('uniswap/src/features/gating/hooks', () => { - const real = jest.requireActual('uniswap/src/features/gating/hooks') - return { - ...real, - useDynamicConfigValue: (args) => args.defaultValue, - } }) // TODO: Remove this mock after mocks in jest-expo are fixed diff --git a/config/vitest-presets/vitest/vitest-preset.js b/config/vitest-presets/vitest/vitest-preset.js index 5b44b8f092d..e08010644ed 100644 --- a/config/vitest-presets/vitest/vitest-preset.js +++ b/config/vitest-presets/vitest/vitest-preset.js @@ -61,7 +61,6 @@ module.exports = { '@react-native-firebase/**', '@uniswap/client-embeddedwallet', '@uniswap/client-data-api', - '@uniswap/client-pools', 'react-native-webview', '@gorhom/**', 'expo*', diff --git a/dangerfile.ts b/dangerfile.ts index a0cdafb2a27..7785ee045d5 100644 --- a/dangerfile.ts +++ b/dangerfile.ts @@ -74,7 +74,7 @@ function checkGeneralizedHookFiles() { } // Put any files here that we explicitly want to ignore! -const IGNORED_SPLIT_RULE_FILES: string[] = ['packages/uniswap/src/features/gating/sdk/statsig.native.ts'] +const IGNORED_SPLIT_RULE_FILES: string[] = ['packages/gating/src/sdk/statsig.native.ts'] function checkSplitFiles() { const touchedFiles = danger.git.modified_files.concat(danger.git.created_files) diff --git a/nx.json b/nx.json index 5f69d8974a1..e24f6a33130 100644 --- a/nx.json +++ b/nx.json @@ -10,7 +10,13 @@ "build": { "dependsOn": ["^prepare", "prepare", "^build"], "inputs": ["dependencies", "sourceFiles", "tsConfig"], - "outputs": ["{projectRoot}/dist", "{projectRoot}/build", "{projectRoot}/.next", "{projectRoot}/types"], + "outputs": [ + "{workspaceRoot}/dist/out-tsc/{projectRoot}", + "{projectRoot}/dist", + "{projectRoot}/build", + "{projectRoot}/.next", + "{projectRoot}/types" + ], "cache": true }, "build:production": { @@ -49,18 +55,14 @@ "options": { "cwd": "{projectRoot}" }, - "dependsOn": [ - "@uniswap/biome-config:prepare" - ] + "dependsOn": ["@uniswap/biome-config:prepare"] }, "lint:biome:fix": { "command": "biome check . --write", "options": { "cwd": "{projectRoot}" }, - "dependsOn": [ - "@uniswap/biome-config:prepare" - ] + "dependsOn": ["@uniswap/biome-config:prepare"] }, "lint:eslint": { "command": "eslint . --ext ts,tsx --max-warnings=0", @@ -82,10 +84,7 @@ }, "lint:fix": { "executor": "nx:noop", - "dependsOn": [ - "lint:biome:fix", - "lint:eslint:fix" - ], + "dependsOn": ["lint:biome:fix", "lint:eslint:fix"], "cache": false }, "test": { @@ -171,13 +170,8 @@ }, "@nx/js:tsc": { "cache": true, - "dependsOn": [ - "^build" - ], - "inputs": [ - "production", - "^production" - ] + "dependsOn": ["^build"], + "inputs": ["production", "^production"] } }, "namedInputs": { diff --git a/package.json b/package.json index 1af9250245b..f42f4722421 100644 --- a/package.json +++ b/package.json @@ -132,11 +132,22 @@ }, "scripts": { "api": "bun run --cwd packages/api", - "clean": "bash ./scripts/clean.sh", - "config": "bun run --cwd packages/config", - "extension": "bun run --cwd apps/extension", "biome-config": "bun run --cwd packages/biome-config", + "config": "bun run --cwd packages/config", "eslint-config": "bun run --cwd packages/eslint-config", + "extension": "bun run --cwd apps/extension", + "mobile": "bun run --cwd apps/mobile", + "sessions": "bun run --cwd packages/sessions", + "ui": "bun run --cwd packages/ui", + "uniswap": "bun run --cwd packages/uniswap", + "utilities": "bun run --cwd packages/utilities", + "wallet": "bun run --cwd packages/wallet", + "web": "bun run --cwd apps/web", + "clean": "bash ./scripts/clean.sh", + "lfg": "bun run g:prepare && bun run mobile env:local:download && bun run extension env:local:download && bun run g:build && bun run mobile pod && bun run mobile ios", + "local:check": "./scripts/local-version-check.sh", + "preinstall": "./scripts/check-bun-version.sh", + "postinstall": "lefthook install && bun g:prepare", "g:build": "nx run-many -t build --parallel", "g:build:storybook": "nx run-many -t storybook:build --skip-nx-cache --parallel", "g:check:deps:usage": "./scripts/check-deps-with-vue-fix.sh && nx run-many -t check:deps:usage", @@ -148,12 +159,13 @@ "g:lint:changed": "nx affected -t lint --base=${NX_BASE:-main} --head=${NX_HEAD:-HEAD}", "g:lint": "nx run-many -t lint", "g:lint:fix": "nx run-many -t lint:fix", + "g:pre-commit-checks": "nx affected -t typecheck,lint:biome --uncommitted --output-style=stream", "g:prepare": "nx run-many -t prepare --output-style=stream", - "g:rm:local-packages": "rm -rf ./node_modules/utilities ./node_modules/wallet ./node_modules/ui ./node_modules/uniswap", + "g:rm:local-packages": "bash ./scripts/remove-local-packages.sh", "g:rm:nodemodules": "rm -rf node_modules **/node_modules", - "g:pre-commit-checks": "nx affected -t typecheck,lint:biome --uncommitted --output-style=stream", - "g:run-fast-checks": "nx affected -t typecheck,lint,build --base=${NX_BASE:-HEAD~1} --head=${NX_HEAD:-HEAD}", "g:run-all-checks": "nx run-many -t typecheck,lint,test,build,check:circular", + "g:run-fast-checks": "nx affected -t typecheck,lint,build --base=${NX_BASE:-HEAD~1} --head=${NX_HEAD:-HEAD}", + "g:snapshots": "nx run-many -t snapshots", "g:test:storybook:standalone": "nx run-many -t storybook:test:standalone --skip-nx-cache --parallel", "g:test": "nx run-many -t test", "g:test:coverage": "nx run-many -t test -- --collectCoverage=true", @@ -167,7 +179,6 @@ "g:test:coverage:extension": "nx test @uniswap/extension -- --collectCoverage=true", "g:test:coverage:mobile": "nx test @uniswap/mobile -- --collectCoverage=true", "g:test:coverage:wallet": "nx test wallet -- --collectCoverage=true", - "g:snapshots": "nx run-many -t snapshots", "g:typecheck": "nx run-many -t typecheck", "g:typecheck:changed": "nx affected -t typecheck --base=${NX_BASE:-main} --head=${NX_HEAD:-HEAD}", "i18n:extract": "i18next", @@ -175,22 +186,12 @@ "i18n:upload": "dotenv -e .env.defaults -c -- bun run i18n:_upload", "i18n:_download": "crowdin download", "i18n:download": "dotenv -e .env.defaults -c -- bun run i18n:_download", - "lfg": "bun run g:prepare && bun run mobile env:local:download && bun run extension env:local:download && bun run g:build && bun run mobile pod && bun run mobile ios", - "mobile": "bun run --cwd apps/mobile", - "local:check": "./scripts/local-version-check.sh", - "preinstall": "./scripts/check-bun-version.sh", - "postinstall": "lefthook install && bun g:prepare", - "sessions": "bun run --cwd packages/sessions", - "ui": "bun run --cwd packages/ui", "upgrade:tamagui": "bun update '*tamagui*' '@tamagui/*'", "upgrade:tamagui:canary": "bun update '*tamagui*'@canary '@tamagui/*'@canary", - "wallet": "bun run --cwd packages/wallet", - "web": "bun run --cwd apps/web", - "uniswap": "bun run --cwd packages/uniswap", - "utilities": "bun run --cwd packages/utilities", - "knip": "knip", "wallet:release:setup-cherry-pick-branches": "bunx tsx ./scripts/wallet-releases/generate-cherry-pick-branches-for-release.ts", - "wallet:release:generate-cherry-pick-commit-command": "bunx tsx ./scripts/wallet-releases/generate-cherry-pick-commit-command.ts" + "wallet:release:generate-cherry-pick-commit-command": "bunx tsx ./scripts/wallet-releases/generate-cherry-pick-commit-command.ts", + "gating": "bun run --cwd packages/gating", + "notifications": "bun run --cwd packages/notifications" }, "workspaces": ["apps/*", "packages/*", "config/*", "tools/uniswap-nx"], "patchedDependencies": { diff --git a/packages/api/package.json b/packages/api/package.json index de6f0dc50d8..2569dd0d52d 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -25,10 +25,9 @@ "@connectrpc/connect": "1.4.0", "@connectrpc/connect-web": "1.4.0", "@tanstack/react-query": "5.77.2", - "@uniswap/client-data-api": "0.0.14", + "@uniswap/client-data-api": "0.0.18", "@uniswap/client-embeddedwallet": "0.0.16", "@uniswap/client-explore": "0.0.17", - "@uniswap/client-pools": "0.0.17", "@uniswap/client-trading": "0.1.0", "@universe/config": "workspace:^", "@universe/sessions": "workspace:^", diff --git a/packages/api/src/clients/graphql/queries.graphql b/packages/api/src/clients/graphql/queries.graphql index 53aca66dce4..a6a92de8984 100644 --- a/packages/api/src/clients/graphql/queries.graphql +++ b/packages/api/src/clients/graphql/queries.graphql @@ -836,6 +836,12 @@ fragment TokenBasicInfoParts on Token { name standard symbol + isBridged + bridgedWithdrawalInfo { + chain + provider + url + } } fragment TokenBasicProjectParts on Token { diff --git a/packages/api/src/clients/graphql/schema.graphql b/packages/api/src/clients/graphql/schema.graphql index 287be283601..37e138268fb 100644 --- a/packages/api/src/clients/graphql/schema.graphql +++ b/packages/api/src/clients/graphql/schema.graphql @@ -1,19 +1,6 @@ """This directive allows results to be deferred during execution""" directive @defer on FIELD -"""Tells the service which mutation triggers this subscription.""" -directive @aws_subscribe( - """ - List of mutations which will trigger this subscription when they are called. - """ - mutations: [String] -) on FIELD_DEFINITION - -""" -Tells the service this field/object has access authorized by an OIDC token. -""" -directive @aws_oidc on OBJECT | FIELD_DEFINITION - """Directs the schema to enforce authorization on a field""" directive @aws_auth( """List of cognito user pool groups which have access on this field""" @@ -21,15 +8,26 @@ directive @aws_auth( ) on FIELD_DEFINITION """ -Tells the service this field/object has access authorized by sigv4 signing. +Tells the service which subscriptions will be published to when this mutation is +called. This directive is deprecated use @aws_susbscribe directive instead. """ -directive @aws_iam on OBJECT | FIELD_DEFINITION +directive @aws_publish( + """ + List of subscriptions which will be published to when this mutation is called. + """ + subscriptions: [String] +) on FIELD_DEFINITION """ Tells the service this field/object has access authorized by an API key. """ directive @aws_api_key on OBJECT | FIELD_DEFINITION +""" +Tells the service this field/object has access authorized by sigv4 signing. +""" +directive @aws_iam on OBJECT | FIELD_DEFINITION + """ Tells the service this field/object has access authorized by a Cognito User Pools token. """ @@ -38,15 +36,12 @@ directive @aws_cognito_user_pools( cognito_groups: [String] ) on OBJECT | FIELD_DEFINITION -""" -Tells the service which subscriptions will be published to when this mutation is -called. This directive is deprecated use @aws_susbscribe directive instead. -""" -directive @aws_publish( +"""Tells the service which mutation triggers this subscription.""" +directive @aws_subscribe( """ - List of subscriptions which will be published to when this mutation is called. + List of mutations which will trigger this subscription when they are called. """ - subscriptions: [String] + mutations: [String] ) on FIELD_DEFINITION """ @@ -54,6 +49,11 @@ Tells the service this field/object has access authorized by a Lambda Authorizer """ directive @aws_lambda on OBJECT | FIELD_DEFINITION +""" +Tells the service this field/object has access authorized by an OIDC token. +""" +directive @aws_oidc on OBJECT | FIELD_DEFINITION + """ Types, unions, and inputs (alphabetized): These are colocated to highlight the relationship between some types and their inputs. @@ -182,6 +182,12 @@ type BlockaidFees { sell: Float } +type BridgedWithdrawalInfo { + chain: String! + provider: String! + url: String! +} + enum Chain { ARBITRUM AVALANCHE @@ -1027,6 +1033,7 @@ enum PriceSource { SUBGRAPH_V2 SUBGRAPH_V3 SUBGRAPH_V4 + EXTERNAL } enum ProtectionAttackType { @@ -1078,8 +1085,8 @@ type Query { dailyProtocolTvl(chain: Chain!, version: ProtocolVersion!): [TimestampedAmount!] """ returns top v3 pools sorted by total value locked in desc order""" - topV3Pools(chain: Chain!, first: Int!, tvlCursor: Float, tokenFilter: String): [V3Pool!] - topV4Pools(chain: Chain!, first: Int!, tvlCursor: Float, tokenFilter: String): [V4Pool!] + topV3Pools(chain: Chain!, first: Int!, tvlCursor: Float, tokenFilter: String, debugMode: Boolean): [V3Pool!] + topV4Pools(chain: Chain!, first: Int!, tvlCursor: Float, tokenFilter: String, debugMode: Boolean): [V4Pool!] v3Pool(chain: Chain!, address: String!): V3Pool v4Pool(chain: Chain!, poolId: String!): V4Pool v3PoolsForTokenPair(chain: Chain!, token0: String!, token1: String!): [V3Pool!] @@ -1088,7 +1095,7 @@ type Query { v4Transactions(chain: Chain!, first: Int!, timestampCursor: Int): [PoolTransaction!] """ returns top v2 pairs sorted by total value locked in desc order""" - topV2Pairs(chain: Chain!, first: Int!, tvlCursor: Float, tokenFilter: String): [V2Pair!] + topV2Pairs(chain: Chain!, first: Int!, tvlCursor: Float, tokenFilter: String, debugMode: Boolean): [V2Pair!] v2Pair(chain: Chain!, address: String!): V2Pair v2Transactions(chain: Chain!, first: Int!, timestampCursor: Int): [PoolTransaction] convert(fromAmount: CurrencyAmountInput!, toCurrency: Currency!): Amount @@ -1226,6 +1233,8 @@ type Token implements IContract { v3Transactions(first: Int!, timestampCursor: Int): [PoolTransaction] v2Transactions(first: Int!, timestampCursor: Int): [PoolTransaction] source: TokenSource + isBridged: Boolean + bridgedWithdrawalInfo: BridgedWithdrawalInfo } type TokenAmount { diff --git a/packages/api/src/clients/notifications/createNotificationsApiClient.ts b/packages/api/src/clients/notifications/createNotificationsApiClient.ts new file mode 100644 index 00000000000..59546503bac --- /dev/null +++ b/packages/api/src/clients/notifications/createNotificationsApiClient.ts @@ -0,0 +1,49 @@ +import type { + GetNotificationsRequest, + InAppNotification, + NotificationsApiClient, + NotificationsClientContext, +} from '@universe/api/src/clients/notifications/types' + +/** + * Factory function to create a NotificationsApiClient + * + * Example usage: + * ```typescript + * const notificationsClient = createNotificationsApiClient({ + * fetchClient: myFetchClient, + * getApiPathPrefix: () => '/notifications/v1' + * }) + * + * const notifications = await notificationsClient.getNotifications() + * ``` + * + * @param ctx - Context containing injected dependencies + * @returns NotificationsApiClient instance + */ +export function createNotificationsApiClient(ctx: NotificationsClientContext): NotificationsApiClient { + const { fetchClient, getApiPathPrefix = (): string => '' } = ctx + + const getNotifications = async (params?: GetNotificationsRequest): Promise => { + const pathPrefix = getApiPathPrefix() + const path = `${pathPrefix}/uniswap.notificationservice.v1.NotificationService/GetNotifications` + + try { + const response = await fetchClient.post<{ notifications: InAppNotification[] }>(path, { + body: JSON.stringify(params ?? {}), + }) + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + return response?.notifications ?? [] + } catch (error) { + // Re-throw with context about which API call failed + throw new Error(`Failed to fetch notifications: ${error instanceof Error ? error.message : String(error)}`, { + cause: error, + }) + } + } + + return { + getNotifications, + } +} diff --git a/packages/api/src/clients/notifications/types.ts b/packages/api/src/clients/notifications/types.ts new file mode 100644 index 00000000000..d0b31810112 --- /dev/null +++ b/packages/api/src/clients/notifications/types.ts @@ -0,0 +1,38 @@ +import { FetchClient } from '@universe/api/src/clients/base/types' + +export interface NotificationsClientContext { + fetchClient: FetchClient + getApiPathPrefix?: () => string +} + +/** + * In-app notification returned by the notifications API + * TODO: This will be replaced with OpenAPI-generated types once the spec is integrated + */ +export interface InAppNotification { + notification_id: string + notification_name: string + meta_data: Record + content: Record + criteria: Record +} + +/** + * Request parameters for fetching notifications + */ +export type GetNotificationsRequest = Record + +/** + * Response from the GetNotifications API endpoint + */ +export interface GetNotificationsResponse { + notifications: InAppNotification[] +} + +export interface NotificationsApiClient { + /** + * Fetch notifications for the current user + * Uses session-based authentication (x-session-id header) via FetchClient + */ + getNotifications: (params?: GetNotificationsRequest) => Promise +} diff --git a/packages/api/src/clients/trading/tradeTypes.ts b/packages/api/src/clients/trading/tradeTypes.ts index 9b0a0cc2a17..7d415342ea1 100644 --- a/packages/api/src/clients/trading/tradeTypes.ts +++ b/packages/api/src/clients/trading/tradeTypes.ts @@ -42,7 +42,7 @@ interface StepProof { orderId?: string } -export interface TradeStep { +export interface PlanStep { stepId: string method: Method payloadType: PayloadType @@ -62,7 +62,7 @@ export interface TradeStep { export interface TradeResponse { tradeId: string - steps: TradeStep[] + steps: PlanStep[] expectedOutput: number timeEstimateMs: number //ms gasFee: string diff --git a/packages/api/src/connectRpc/utils.ts b/packages/api/src/connectRpc/utils.ts index 293daed4462..b26a9acd449 100644 --- a/packages/api/src/connectRpc/utils.ts +++ b/packages/api/src/connectRpc/utils.ts @@ -1,7 +1,7 @@ import { type PlainMessage } from '@bufbuild/protobuf' import { Platform, type PlatformAddress, type WalletAccount } from '@uniswap/client-data-api/dist/data/v1/api_pb' +import { ProtocolVersion } from '@uniswap/client-data-api/dist/data/v1/poolTypes_pb' import { type ProtectionInfo as ProtectionInfoProtobuf } from '@uniswap/client-explore/dist/uniswap/explore/v1/service_pb' -import { ProtocolVersion } from '@uniswap/client-pools/dist/pools/v1/types_pb' import { ProtectionAttackType, type ProtectionInfo, diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 10b3fdd723e..f9a95b99786 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -73,11 +73,11 @@ export { type ExistingTradeRequest, Method, type NewTradeRequest, + type PlanStep, PlanStepStatus, type PriorityQuoteResponse, type SwappableTokensParams, type TradeResponse, - type TradeStep, type UnwrapQuoteResponse, type UpdateExistingTradeRequest, type WrapQuoteResponse, @@ -124,6 +124,16 @@ export { TokenReportEventType, } from '@universe/api/src/clients/data/createDataServiceApiClient' +// Notifications API +export { createNotificationsApiClient } from '@universe/api/src/clients/notifications/createNotificationsApiClient' +export type { + GetNotificationsRequest, + GetNotificationsResponse, + InAppNotification, + NotificationsApiClient, + NotificationsClientContext, +} from '@universe/api/src/clients/notifications/types' + // ConnectRPC API export { ALL_NETWORKS_ARG, diff --git a/packages/biome-config/base.jsonc b/packages/biome-config/base.jsonc index 71b95904adb..2e99ba3aada 100644 --- a/packages/biome-config/base.jsonc +++ b/packages/biome-config/base.jsonc @@ -177,7 +177,17 @@ }, "noUnusedLabels": "error", "noUnusedVariables": "error", - "useExhaustiveDependencies": "error", + "useExhaustiveDependencies": { + "level": "error", + "options": { + "hooks": [ + // React Native Reanimated hooks with stable results + { "name": "useSharedValue", "stableResult": true }, + { "name": "useDerivedValue", "stableResult": true }, + { "name": "useAnimatedRef", "stableResult": true } + ] + } + }, "useHookAtTopLevel": "error", "useIsNan": "warn", "useJsxKeyInIterable": "error", diff --git a/packages/gating/.eslintrc.js b/packages/gating/.eslintrc.js new file mode 100644 index 00000000000..05c00dcf591 --- /dev/null +++ b/packages/gating/.eslintrc.js @@ -0,0 +1,46 @@ +module.exports = { + root: true, + extends: ['@uniswap/eslint-config/native', '@uniswap/eslint-config/webPlatform'], + ignorePatterns: [ + 'node_modules', + '.turbo', + '.eslintrc.js', + 'vitest.config.ts', + 'codegen.ts', + '.nx', + 'scripts', + 'dist', + 'src/**/__generated__', + ], + parserOptions: { + project: 'tsconfig.lint.json', + tsconfigRootDir: __dirname, + ecmaFeatures: { + jsx: true, + }, + ecmaVersion: 2018, + sourceType: 'module', + }, + overrides: [ + { + files: ['src/index.ts'], + rules: { + 'check-file/no-index': 'off', + }, + }, + { + files: ['*.ts', '*.tsx'], + rules: { + 'no-relative-import-paths/no-relative-import-paths': [ + 'error', + { + allowSameFolder: false, + prefix: '@universe/gating', + }, + ], + '@typescript-eslint/prefer-enum-initializers': 'off', + }, + }, + ], + rules: {}, +} diff --git a/packages/gating/README.md b/packages/gating/README.md new file mode 100644 index 00000000000..8b26f751149 --- /dev/null +++ b/packages/gating/README.md @@ -0,0 +1,3 @@ +# @universe/gating + +// TODO diff --git a/packages/gating/package.json b/packages/gating/package.json new file mode 100644 index 00000000000..e8457067531 --- /dev/null +++ b/packages/gating/package.json @@ -0,0 +1,31 @@ +{ + "name": "@universe/gating", + "version": "0.0.0", + "dependencies": { + "@statsig/client-core": "3.12.2", + "@statsig/js-client": "3.12.2", + "@statsig/js-local-overrides": "3.12.2", + "@statsig/react-bindings": "3.12.2", + "@statsig/react-native-bindings": "3.12.2", + "@universe/api": "workspace:*", + "utilities": "workspace:*" + }, + "devDependencies": { + "@types/node": "22.13.1", + "@uniswap/eslint-config": "workspace:^", + "depcheck": "1.4.7", + "eslint": "8.44.0", + "typescript": "5.3.3" + }, + "scripts": { + "typecheck": "nx typecheck gating", + "lint": "nx lint gating", + "lint:fix": "nx lint:fix gating" + }, + "nx": { + "includedScripts": [] + }, + "main": "src/index.ts", + "private": true, + "sideEffects": false +} diff --git a/packages/gating/project.json b/packages/gating/project.json new file mode 100644 index 00000000000..e5b65be81e9 --- /dev/null +++ b/packages/gating/project.json @@ -0,0 +1,16 @@ +{ + "name": "@universe/gating", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "packages/gating/src", + "projectType": "library", + "tags": [], + "targets": { + "typecheck": {}, + "lint:biome": {}, + "lint:biome:fix": {}, + "lint:eslint": {}, + "lint:eslint:fix": {}, + "lint": {}, + "lint:fix": {} + } +} diff --git a/packages/uniswap/src/features/gating/LocalOverrideAdapterWrapper.ts b/packages/gating/src/LocalOverrideAdapterWrapper.ts similarity index 95% rename from packages/uniswap/src/features/gating/LocalOverrideAdapterWrapper.ts rename to packages/gating/src/LocalOverrideAdapterWrapper.ts index 4c4794d7cb0..1c68bee6685 100644 --- a/packages/uniswap/src/features/gating/LocalOverrideAdapterWrapper.ts +++ b/packages/gating/src/LocalOverrideAdapterWrapper.ts @@ -1,5 +1,5 @@ import { LocalOverrideAdapter } from '@statsig/js-local-overrides' -import { getStatsigClient } from 'uniswap/src/features/gating/sdk/statsig' +import { getStatsigClient } from '@universe/gating/src/sdk/statsig' // Workaround for @statsig 3.x.x refreshing client after applying overrides to get the result without reloading // Should be removed after statsig add real time override apply functionality diff --git a/packages/uniswap/src/features/gating/configs.ts b/packages/gating/src/configs.ts similarity index 81% rename from packages/uniswap/src/features/gating/configs.ts rename to packages/gating/src/configs.ts index 6c9ac865d3a..9752b9af739 100644 --- a/packages/uniswap/src/features/gating/configs.ts +++ b/packages/gating/src/configs.ts @@ -1,5 +1,64 @@ import { GasStrategy } from '@universe/api' -import type { Locale } from 'uniswap/src/features/language/constants' + +// TODO: move to own package +export enum Locale { + Afrikaans = 'af-ZA', + ArabicSaudi = 'ar-SA', + Catalan = 'ca-ES', + ChineseSimplified = 'zh-Hans', + ChineseTraditional = 'zh-Hant', + CzechCzechia = 'cs-CZ', + DanishDenmark = 'da-DK', + DutchNetherlands = 'nl-NL', + EnglishUnitedStates = 'en-US', + FinnishFinland = 'fi-FI', + FrenchFrance = 'fr-FR', + GreekGreece = 'el-GR', + HebrewIsrael = 'he-IL', + HindiIndia = 'hi-IN', + HungarianHungarian = 'hu-HU', + IndonesianIndonesia = 'id-ID', + ItalianItaly = 'it-IT', + JapaneseJapan = 'ja-JP', + KoreanKorea = 'ko-KR', + MalayMalaysia = 'ms-MY', + NorwegianNorway = 'no-NO', + PolishPoland = 'pl-PL', + PortugueseBrazil = 'pt-BR', + PortuguesePortugal = 'pt-PT', + RomanianRomania = 'ro-RO', + RussianRussia = 'ru-RU', + Serbian = 'sr-SP', + SpanishLatam = 'es-419', + SpanishBelize = 'es-BZ', + SpanishCuba = 'es-CU', + SpanishDominicanRepublic = 'es-DO', + SpanishGuatemala = 'es-GT', + SpanishHonduras = 'es-HN', + SpanishMexico = 'es-MX', + SpanishNicaragua = 'es-NI', + SpanishPanama = 'es-PA', + SpanishPeru = 'es-PE', + SpanishPuertoRico = 'es-PR', + SpanishElSalvador = 'es-SV', + SpanishUnitedStates = 'es-US', + SpanishArgentina = 'es-AR', + SpanishBolivia = 'es-BO', + SpanishChile = 'es-CL', + SpanishColombia = 'es-CO', + SpanishCostaRica = 'es-CR', + SpanishEcuador = 'es-EC', + SpanishSpain = 'es-ES', + SpanishParaguay = 'es-PY', + SpanishUruguay = 'es-UY', + SpanishVenezuela = 'es-VE', + SwahiliTanzania = 'sw-TZ', + SwedishSweden = 'sv-SE', + TurkishTurkey = 'tr-TR', + UkrainianUkraine = 'uk-UA', + UrduPakistan = 'ur-PK', + VietnameseVietnam = 'vi-VN', +} /** * Dynamic Configs diff --git a/packages/uniswap/src/features/gating/constants.ts b/packages/gating/src/constants.ts similarity index 100% rename from packages/uniswap/src/features/gating/constants.ts rename to packages/gating/src/constants.ts diff --git a/packages/uniswap/src/features/gating/experiments.ts b/packages/gating/src/experiments.ts similarity index 83% rename from packages/uniswap/src/features/gating/experiments.ts rename to packages/gating/src/experiments.ts index abee273dafe..1798d898243 100644 --- a/packages/uniswap/src/features/gating/experiments.ts +++ b/packages/gating/src/experiments.ts @@ -11,10 +11,12 @@ export enum Experiments { UnichainFlashblocksModal = 'unichain_flashblocks_modal', WebFORNudges = 'web_for_nudge', ForFilters = 'for_filters', + PortfolioDisconnectedDemoView = 'portfolio_disconnected_demo_view', } export enum Layers { SwapPage = 'swap-page', + PortfolioPage = 'portfolio-page', } // experiment groups @@ -59,6 +61,10 @@ export enum WebFORNudgesProperties { NudgeEnabled = 'nudgeEnabled', } +export enum PortfolioDisconnectedDemoViewProperties { + DemoViewEnabled = 'demoViewEnabled', +} + export type ExperimentProperties = { [Experiments.PriceUxUpdate]: PriceUxUpdateProperties [Experiments.PrivateRpc]: PrivateRpcProperties @@ -67,6 +73,7 @@ export type ExperimentProperties = { [Experiments.UnichainFlashblocksModal]: UnichainFlashblocksProperties [Experiments.ForFilters]: ForFiltersProperties [Experiments.WebFORNudges]: WebFORNudgesProperties + [Experiments.PortfolioDisconnectedDemoView]: PortfolioDisconnectedDemoViewProperties } // will be a spread of all experiment properties in that layer @@ -75,4 +82,7 @@ export const LayerProperties: Record = { ...PriceUxUpdateProperties, ...UnichainFlashblocksProperties, }), + [Layers.PortfolioPage]: Object.values({ + ...PortfolioDisconnectedDemoViewProperties, + }), } diff --git a/packages/uniswap/src/features/gating/flags.ts b/packages/gating/src/flags.ts similarity index 98% rename from packages/uniswap/src/features/gating/flags.ts rename to packages/gating/src/flags.ts index ae194d53c14..6e0f3b10d5b 100644 --- a/packages/uniswap/src/features/gating/flags.ts +++ b/packages/gating/src/flags.ts @@ -10,6 +10,7 @@ export enum FeatureFlags { // Shared ArbitrumDutchV3, BlockaidFotLogging, + BridgedAssetsBannerV2, ChainedActions, DisableSwap7702, EmbeddedWallet, @@ -87,6 +88,7 @@ export enum FeatureFlags { export const SHARED_FEATURE_FLAG_NAMES = new Map([ [FeatureFlags.ArbitrumDutchV3, 'uniswapx_dutchv3_orders_arbitrum'], [FeatureFlags.BlockaidFotLogging, 'blockaid_fot_logging'], + [FeatureFlags.BridgedAssetsBannerV2, 'bridged_assets_banner_v2'], [FeatureFlags.ChainedActions, 'enable_chained_actions'], [FeatureFlags.DisableSwap7702, 'disable-swap-7702'], [FeatureFlags.EmbeddedWallet, 'embedded_wallet'], diff --git a/packages/uniswap/src/features/gating/getStatsigEnvName.ts b/packages/gating/src/getStatsigEnvName.ts similarity index 100% rename from packages/uniswap/src/features/gating/getStatsigEnvName.ts rename to packages/gating/src/getStatsigEnvName.ts diff --git a/packages/uniswap/src/features/gating/hooks.ts b/packages/gating/src/hooks.ts similarity index 95% rename from packages/uniswap/src/features/gating/hooks.ts rename to packages/gating/src/hooks.ts index d369a0a6e3c..460af0075ec 100644 --- a/packages/uniswap/src/features/gating/hooks.ts +++ b/packages/gating/src/hooks.ts @@ -1,8 +1,7 @@ import { StatsigClientEventCallback, StatsigLoadingStatus } from '@statsig/client-core' -import { useEffect, useMemo, useState } from 'react' -import { DynamicConfigKeys } from 'uniswap/src/features/gating/configs' -import { ExperimentProperties, Experiments } from 'uniswap/src/features/gating/experiments' -import { FeatureFlags, getFeatureFlagName } from 'uniswap/src/features/gating/flags' +import { DynamicConfigKeys } from '@universe/gating/src/configs' +import { ExperimentProperties, Experiments } from '@universe/gating/src/experiments' +import { FeatureFlags, getFeatureFlagName } from '@universe/gating/src/flags' import { getStatsigClient, TypedReturn, @@ -12,7 +11,8 @@ import { useGateValue, useLayer, useStatsigClient, -} from 'uniswap/src/features/gating/sdk/statsig' +} from '@universe/gating/src/sdk/statsig' +import { useEffect, useMemo, useState } from 'react' import { logger } from 'utilities/src/logger/logger' export function useFeatureFlag(flag: FeatureFlags): boolean { diff --git a/packages/gating/src/index.ts b/packages/gating/src/index.ts new file mode 100644 index 00000000000..efe852ff1f6 --- /dev/null +++ b/packages/gating/src/index.ts @@ -0,0 +1,86 @@ +export type { + DatadogIgnoredErrorsValType, + DatadogSessionSampleRateValType, + DeepLinkUrlAllowlist, + DynamicConfigKeys, + ForceUpgradeStatus, + ForceUpgradeTranslations, + GasStrategies, + GasStrategyType, + GasStrategyWithConditions, + UwULinkAllowlist, + UwULinkAllowlistItem, +} from '@universe/gating/src/configs' +export { + AllowedV4WethHookAddressesConfigKey, + BlockedAsyncSubmissionChainIdsConfigKey, + ChainsConfigKey, + DatadogIgnoredErrorsConfigKey, + DatadogSessionSampleRateKey, + DeepLinkUrlAllowlistConfigKey, + DynamicConfigs, + EmbeddedWalletConfigKey, + ExtensionBiometricUnlockConfigKey, + ExternallyConnectableExtensionConfigKey, + ForceUpgradeConfigKey, + HomeScreenExploreTokensConfigKey, + LPConfigKey, + NetworkRequestsConfigKey, + OnDeviceRecoveryConfigKey, + OutageBannerChainIdConfigKey, + SwapConfigKey, + SyncTransactionSubmissionChainIdsConfigKey, + UwuLinkConfigKey, +} from '@universe/gating/src/configs' +export { StatsigCustomAppValue } from '@universe/gating/src/constants' +export type { ExperimentProperties } from '@universe/gating/src/experiments' +export { + Experiments, + ForFiltersProperties, + LayerProperties, + Layers, + NativeTokenPercentageBufferProperties, + PortfolioDisconnectedDemoViewProperties, + PriceUxUpdateProperties, + PrivateRpcProperties, + UnichainFlashblocksProperties, + WebFORNudgesProperties, +} from '@universe/gating/src/experiments' +export { + FeatureFlags, + getFeatureFlagName, + WALLET_FEATURE_FLAG_NAMES, + WEB_FEATURE_FLAG_NAMES, +} from '@universe/gating/src/flags' +export { getStatsigEnvName } from '@universe/gating/src/getStatsigEnvName' +export { + getDynamicConfigValue, + getExperimentValue, + getExperimentValueFromLayer, + getFeatureFlag, + useDynamicConfigValue, + useExperimentValue, + useExperimentValueFromLayer, + useFeatureFlag, + useFeatureFlagWithExposureLoggingDisabled, + useFeatureFlagWithLoading, + useStatsigClientStatus, +} from '@universe/gating/src/hooks' +export { LocalOverrideAdapterWrapper } from '@universe/gating/src/LocalOverrideAdapterWrapper' +export type { + StatsigOptions, + StatsigUser, + StorageProvider, +} from '@universe/gating/src/sdk/statsig' +export { + getOverrideAdapter, + getStatsigClient, + StatsigClient, + StatsigContext, + StatsigProvider, + Storage, + useClientAsyncInit, + useExperiment, + useLayer, +} from '@universe/gating/src/sdk/statsig' +export { getOverrides } from '@universe/gating/src/utils' diff --git a/packages/uniswap/src/features/gating/sdk/statsig.native.ts b/packages/gating/src/sdk/statsig.native.ts similarity index 83% rename from packages/uniswap/src/features/gating/sdk/statsig.native.ts rename to packages/gating/src/sdk/statsig.native.ts index f946807b8dd..8e0c14b1022 100644 --- a/packages/uniswap/src/features/gating/sdk/statsig.native.ts +++ b/packages/gating/src/sdk/statsig.native.ts @@ -1,7 +1,9 @@ import { StatsigClient } from '@statsig/react-bindings' import { StatsigClientRN } from '@statsig/react-native-bindings' -import { config } from 'uniswap/src/config' -import { LocalOverrideAdapterWrapper } from 'uniswap/src/features/gating/LocalOverrideAdapterWrapper' +import { getConfig } from '@universe/config' +import { LocalOverrideAdapterWrapper } from '@universe/gating/src/LocalOverrideAdapterWrapper' + +const config = getConfig() export { StatsigClient, diff --git a/packages/uniswap/src/features/gating/sdk/statsig.ts b/packages/gating/src/sdk/statsig.ts similarity index 92% rename from packages/uniswap/src/features/gating/sdk/statsig.ts rename to packages/gating/src/sdk/statsig.ts index 231452cfe31..df1964153b0 100644 --- a/packages/uniswap/src/features/gating/sdk/statsig.ts +++ b/packages/gating/src/sdk/statsig.ts @@ -1,5 +1,5 @@ import { StatsigClient } from '@statsig/react-bindings' -import { LocalOverrideAdapterWrapper } from 'uniswap/src/features/gating/LocalOverrideAdapterWrapper' +import { LocalOverrideAdapterWrapper } from '@universe/gating/src/LocalOverrideAdapterWrapper' export { StatsigClient, diff --git a/packages/uniswap/src/features/gating/utils.ts b/packages/gating/src/utils.ts similarity index 92% rename from packages/uniswap/src/features/gating/utils.ts rename to packages/gating/src/utils.ts index d06e7398daa..491fc37ff2a 100644 --- a/packages/uniswap/src/features/gating/utils.ts +++ b/packages/gating/src/utils.ts @@ -1,5 +1,5 @@ import { PrecomputedEvaluationsInterface } from '@statsig/js-client' -import { getOverrideAdapter } from 'uniswap/src/features/gating/sdk/statsig' +import { getOverrideAdapter } from '@universe/gating/src/sdk/statsig' export function isStatsigReady(client: PrecomputedEvaluationsInterface): boolean { return client.loadingStatus === 'Ready' diff --git a/packages/gating/tsconfig.json b/packages/gating/tsconfig.json new file mode 100644 index 00000000000..1d8d402436b --- /dev/null +++ b/packages/gating/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../../config/tsconfig/app.json", + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.json", "src/global.d.ts"], + "exclude": ["src/**/*.spec.ts", "src/**/*.spec.tsx", "src/**/*.test.ts", "src/**/*.test.tsx"], + "compilerOptions": { + "noEmit": false, + "emitDeclarationOnly": true, + "types": ["node"], + "paths": {} + }, + "references": [ + { + "path": "../utilities" + }, + { + "path": "../api" + } + ] +} diff --git a/packages/gating/tsconfig.lint.json b/packages/gating/tsconfig.lint.json new file mode 100644 index 00000000000..79659c26038 --- /dev/null +++ b/packages/gating/tsconfig.lint.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "preserveSymlinks": true + }, + "include": ["**/*.ts", "**/*.tsx", "**/*.json"], + "exclude": ["node_modules"] +} diff --git a/packages/notifications/.eslintrc.js b/packages/notifications/.eslintrc.js new file mode 100644 index 00000000000..a6dbbad9fee --- /dev/null +++ b/packages/notifications/.eslintrc.js @@ -0,0 +1,45 @@ +module.exports = { + root: true, + extends: ['@uniswap/eslint-config/native', '@uniswap/eslint-config/webPlatform'], + ignorePatterns: [ + 'node_modules', + '.turbo', + '.eslintrc.js', + 'vitest.config.ts', + 'codegen.ts', + '.nx', + 'scripts', + 'dist', + 'src/**/__generated__', + ], + parserOptions: { + project: 'tsconfig.lint.json', + tsconfigRootDir: __dirname, + ecmaFeatures: { + jsx: true, + }, + ecmaVersion: 2018, + sourceType: 'module', + }, + overrides: [ + { + files: ['src/index.ts'], + rules: { + 'check-file/no-index': 'off', + }, + }, + { + files: ['*.ts', '*.tsx'], + rules: { + 'no-relative-import-paths/no-relative-import-paths': [ + 'error', + { + allowSameFolder: false, + prefix: '@universe/notifications', + }, + ], + }, + }, + ], + rules: {}, +} diff --git a/packages/notifications/README.md b/packages/notifications/README.md new file mode 100644 index 00000000000..afbab0c0cfb --- /dev/null +++ b/packages/notifications/README.md @@ -0,0 +1,73 @@ +# @universe/notifications + +Client-side notification system for fetching, processing, storing, and displaying notifications from a backend service. + +## Architecture + +``` +NotificationSystem (orchestrator) +├── NotificationDataSource → Fetch/websocket notification data +├── NotificationTracker → Track shown/dismissed state +├── NotificationProcessor → Filter & prioritize notifications +├── NotificationChainCoordinator → Handle multi-step notification flows +└── NotificationRenderer → Platform-specific UI rendering +``` + +## Core Concepts + +### Notification Chains +Notifications can trigger follow-up notifications based on user actions: +```json +{ + "notificationName": "welcome_step_1", + "content": { + "buttons": [{ + "text": "Next", + "onClickType": "ON_CLICK_TYPE_DISMISS_AND_POPUP", + "onClickLink": "welcome_step_2" // ← triggers next notification + }] + } +} +``` + +## Usage + +### Initialize the System + +```typescript +import { createNotificationSystem } from '@universe/notifications' + +const notificationSystem = createNotificationSystem({ + dataSources: [getFetchNotificationDataSource({ apiClient })], + tracker: createLocalNotificationTracker({ storageDriver }), + processor: createNotificationProcessor(), + renderer: createNotificationRenderer(), + chainCoordinator: createNotificationChainCoordinator() +}) + +await notificationSystem.initialize() +``` + +### Handle User Actions + +```typescript +// When user clicks a button +notificationSystem.onButtonClick(notificationName, button) + +// When user dismisses +notificationSystem.onDismiss(notificationName) + +// When user clicks background +notificationSystem.onBackgroundClick(notificationName) +``` + +### React Integration + +```tsx +// Mount container at app root + + +// Container reads from Zustand store +const activeNotifications = useNotificationStore(state => state.activeNotifications) +const notificationSystem = useNotificationStore(state => state.notificationSystem) +``` diff --git a/packages/notifications/package.json b/packages/notifications/package.json new file mode 100644 index 00000000000..fdcf387b687 --- /dev/null +++ b/packages/notifications/package.json @@ -0,0 +1,18 @@ +{ + "name": "@universe/notifications", + "version": "0.0.0", + "devDependencies": { + "@types/node": "22.13.1", + "@uniswap/eslint-config": "workspace:^", + "depcheck": "1.4.7", + "eslint": "8.44.0", + "typescript": "5.3.3" + }, + "scripts": {}, + "nx": { + "includedScripts": [] + }, + "main": "src/index.ts", + "private": true, + "sideEffects": false +} diff --git a/packages/notifications/project.json b/packages/notifications/project.json new file mode 100644 index 00000000000..83f11f630fe --- /dev/null +++ b/packages/notifications/project.json @@ -0,0 +1,16 @@ +{ + "name": "@universe/notifications", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "packages/notifications/src", + "projectType": "library", + "tags": [], + "targets": { + "typecheck": {}, + "lint:biome": {}, + "lint:biome:fix": {}, + "lint:eslint": {}, + "lint:eslint:fix": {}, + "lint": {}, + "lint:fix": {} + } +} diff --git a/packages/notifications/src/index.ts b/packages/notifications/src/index.ts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/notifications/tsconfig.json b/packages/notifications/tsconfig.json new file mode 100644 index 00000000000..6fbf4aa3e1b --- /dev/null +++ b/packages/notifications/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../config/tsconfig/app.json", + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.json", "src/global.d.ts"], + "exclude": ["src/**/*.spec.ts", "src/**/*.spec.tsx", "src/**/*.test.ts", "src/**/*.test.tsx"], + "compilerOptions": { + "noEmit": false, + "emitDeclarationOnly": true, + "types": ["node"], + "paths": {} + }, + "references": [] +} diff --git a/packages/notifications/tsconfig.lint.json b/packages/notifications/tsconfig.lint.json new file mode 100644 index 00000000000..79659c26038 --- /dev/null +++ b/packages/notifications/tsconfig.lint.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "preserveSymlinks": true + }, + "include": ["**/*.ts", "**/*.tsx", "**/*.json"], + "exclude": ["node_modules"] +} diff --git a/packages/ui/src/assets/graphics/bridged-assets-v2-web-banner.png b/packages/ui/src/assets/graphics/bridged-assets-v2-web-banner.png new file mode 100644 index 0000000000000000000000000000000000000000..c7e87c3aa9006d2308279bfa82b69c8be89ef245 GIT binary patch literal 128449 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

2^rd?|>O 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 { return { diff --git a/packages/ui/src/components/switch/Switch.native.tsx b/packages/ui/src/components/switch/Switch.native.tsx index e5023ae2892..6103f25683a 100644 --- a/packages/ui/src/components/switch/Switch.native.tsx +++ b/packages/ui/src/components/switch/Switch.native.tsx @@ -40,7 +40,7 @@ export const Switch = memo(function Switch({ if (checked !== undefined && checked !== (progress.value === 1)) { progress.value = withTiming(checked ? 1 : 0, ANIMATION_CONFIG) } - }, [checked, progress]) + }, [checked]) const trackStyle = useAnimatedStyle(() => { const isOn = progress.value diff --git a/packages/ui/src/loading/Shine.native.tsx b/packages/ui/src/loading/Shine.native.tsx index 34c98d0ba77..c43c1db9cb1 100644 --- a/packages/ui/src/loading/Shine.native.tsx +++ b/packages/ui/src/loading/Shine.native.tsx @@ -30,7 +30,7 @@ export function Shine({ shimmerDurationSeconds = 2, children, disabled }: ShineP useEffect(() => { xPosition.value = withRepeat(withTiming(1, { duration: shimmerDuration }), Infinity, false) - }, [xPosition, shimmerDuration]) + }, [shimmerDuration]) const animatedStyle = useAnimatedStyle(() => ({ ...StyleSheet.absoluteFillObject, diff --git a/packages/ui/src/loading/Skeleton.native.tsx b/packages/ui/src/loading/Skeleton.native.tsx index 996ab35d086..37629c16a11 100644 --- a/packages/ui/src/loading/Skeleton.native.tsx +++ b/packages/ui/src/loading/Skeleton.native.tsx @@ -19,7 +19,6 @@ export function Skeleton({ children, contrast, disabled }: SkeletonProps): JSX.E const [layout, setLayout] = useState() const xPosition = useSharedValue(0) - // biome-ignore lint/correctness/useExhaustiveDependencies: only want to do this once on mount useLayoutEffect(() => { // TODO: [MOB-210] tweak animation to be smoother, right now sometimes looks kind of stuttery xPosition.value = withRepeat(withTiming(1, { duration: SHIMMER_DURATION }), Infinity, true) diff --git a/packages/ui/src/loading/SpinningLoader.native.tsx b/packages/ui/src/loading/SpinningLoader.native.tsx index bfaf4dc085d..44a3cfbf01d 100644 --- a/packages/ui/src/loading/SpinningLoader.native.tsx +++ b/packages/ui/src/loading/SpinningLoader.native.tsx @@ -33,7 +33,7 @@ export function SpinningLoader({ size = 20, disabled, color }: SpinningLoaderPro -1, ) return () => cancelAnimation(rotation) - }, [rotation]) + }, []) if (disabled) { return diff --git a/packages/uniswap/jest-package-mocks.js b/packages/uniswap/jest-package-mocks.js index 4141f641fdd..970956be0fd 100644 --- a/packages/uniswap/jest-package-mocks.js +++ b/packages/uniswap/jest-package-mocks.js @@ -20,10 +20,10 @@ jest.mock('utilities/src/device/uniqueId', () => { return jest.requireActual('uniswap/src/test/mocks/uniqueId') }) -jest.mock('uniswap/src/features/gating/sdk/statsig', () => { - const actualStatsig = jest.requireActual('uniswap/src/features/gating/sdk/statsig') +jest.mock('@universe/gating', () => { + const actual = jest.requireActual('@universe/gating') return { - ...actualStatsig, + ...actual, useClientAsyncInit: jest.fn(() => ({ client: null, isLoading: true, diff --git a/packages/uniswap/package.json b/packages/uniswap/package.json index a0e09b69a13..9d4bae0b472 100644 --- a/packages/uniswap/package.json +++ b/packages/uniswap/package.json @@ -26,6 +26,7 @@ "@connectrpc/connect-query": "1.4.1", "@datadog/browser-logs": "5.20.0", "@datadog/browser-rum": "5.23.3", + "@ethersproject/abstract-provider": "5.8.0", "@ethersproject/abstract-signer": "5.7.0", "@ethersproject/address": "5.7.0", "@ethersproject/bignumber": "5.7.0", @@ -40,21 +41,15 @@ "@shopify/flash-list": "1.7.3", "@simplewebauthn/browser": "13.1.0", "@solana/web3.js": "1.92.0", - "@statsig/client-core": "3.12.2", - "@statsig/js-client": "3.12.2", - "@statsig/js-local-overrides": "3.12.2", - "@statsig/react-bindings": "3.12.2", - "@statsig/react-native-bindings": "3.12.2", "@tanstack/query-async-storage-persister": "5.51.21", "@tanstack/react-query": "5.77.2", "@tanstack/react-query-persist-client": "5.77.2", "@typechain/ethers-v5": "7.2.0", "@types/poisson-disk-sampling": "2.2.4", "@uniswap/analytics-events": "2.43.0", - "@uniswap/client-data-api": "0.0.14", + "@uniswap/client-data-api": "0.0.18", "@uniswap/client-embeddedwallet": "0.0.16", "@uniswap/client-explore": "0.0.17", - "@uniswap/client-pools": "0.0.17", "@uniswap/client-search": "0.0.10", "@uniswap/client-trading": "0.1.0", "@uniswap/permit2-sdk": "1.3.0", @@ -67,6 +62,7 @@ "@uniswap/v4-sdk": "1.21.2", "@universe/api": "workspace:^", "@universe/config": "workspace:^", + "@universe/gating": "workspace:^", "apollo-link-rest": "0.9.0", "date-fns": "2.30.0", "dayjs": "1.11.7", diff --git a/packages/uniswap/src/components/BridgedAsset/BridgedAssetModal.tsx b/packages/uniswap/src/components/BridgedAsset/BridgedAssetModal.tsx index ab6b1161337..5aab811db58 100644 --- a/packages/uniswap/src/components/BridgedAsset/BridgedAssetModal.tsx +++ b/packages/uniswap/src/components/BridgedAsset/BridgedAssetModal.tsx @@ -17,7 +17,6 @@ import { EnvelopeHeart } from 'ui/src/components/icons/EnvelopeHeart' import { OrderRouting } from 'ui/src/components/icons/OrderRouting' import { Verified } from 'ui/src/components/icons/Verified' import { iconSizes } from 'ui/src/theme' -import { getBridgedAsset } from 'uniswap/src/components/BridgedAsset/utils' import { CurrencyLogo } from 'uniswap/src/components/CurrencyLogo/CurrencyLogo' import { Modal } from 'uniswap/src/components/modals/Modal' import { uniswapUrls } from 'uniswap/src/constants/urls' @@ -47,8 +46,7 @@ export const BridgedAssetModalAtom = atom(un function BridgedAssetModalContent({ currencyInfo }: { currencyInfo: CurrencyInfo }): JSX.Element | null { const { t } = useTranslation() const chainName = getChainLabel(currencyInfo.currency.chainId) - const bridgedAsset = getBridgedAsset(currencyInfo) - if (!currencyInfo.currency.symbol || !bridgedAsset) { + if (!currencyInfo.currency.symbol || !currencyInfo.isBridged) { return null } @@ -104,11 +102,13 @@ function BridgedAssetModalContent({ currencyInfo }: { currencyInfo: CurrencyInfo - {t('bridgedAsset.modal.feature.withdrawToNativeChain', { nativeChainName: bridgedAsset.nativeChain })} + {t('bridgedAsset.modal.feature.withdrawToNativeChain', { + nativeChainName: currencyInfo.bridgedWithdrawalInfo?.chain ?? '', + })} {t('bridgedAsset.modal.feature.withdrawToNativeChain.description', { - nativeChainName: bridgedAsset.nativeChain, + nativeChainName: currencyInfo.bridgedWithdrawalInfo?.chain ?? '', })} diff --git a/packages/uniswap/src/components/BridgedAsset/WormholeModal.tsx b/packages/uniswap/src/components/BridgedAsset/WormholeModal.tsx index 38973fe1773..394941a0b6f 100644 --- a/packages/uniswap/src/components/BridgedAsset/WormholeModal.tsx +++ b/packages/uniswap/src/components/BridgedAsset/WormholeModal.tsx @@ -17,7 +17,6 @@ import { ExternalLink } from 'ui/src/components/icons/ExternalLink' import { Shuffle } from 'ui/src/components/icons/Shuffle' import { iconSizes } from 'ui/src/theme' import { BaseModalProps } from 'uniswap/src/components/BridgedAsset/BridgedAssetModal' -import { getBridgedAsset } from 'uniswap/src/components/BridgedAsset/utils' import { CurrencyLogo } from 'uniswap/src/components/CurrencyLogo/CurrencyLogo' import { Modal } from 'uniswap/src/components/modals/Modal' import { uniswapUrls } from 'uniswap/src/constants/urls' @@ -52,7 +51,7 @@ export function WormholeModal({ const textColor = useMemo(() => { return getContrastPassingTextColor(validTokenColor ?? colors.accent1.val) }, [colors.accent1.val, validTokenColor]) - const bridgedAsset = getBridgedAsset(currencyInfo) + const bridgedWithdrawalInfo = currencyInfo?.bridgedWithdrawalInfo const onPressLearnMore = async (): Promise => { await openUri({ uri: uniswapUrls.helpArticleUrls.bridgedAssets }) @@ -60,14 +59,17 @@ export function WormholeModal({ } const onPressContinue = useEvent(async () => { + if (!bridgedWithdrawalInfo?.url) { + return + } await openUri({ - uri: `${uniswapUrls.wormholeUrl}?sourceChain=unichain&targetChain=${bridgedAsset?.nativeChain}&asset=${bridgedAsset?.unichainAddress}&targetAsset=${bridgedAsset?.nativeAddress}`, + uri: bridgedWithdrawalInfo.url, openExternalBrowser: true, }) onClose() }) - if (!currencyInfo || !currencyInfo.currency.symbol || !bridgedAsset) { + if (!currencyInfo || !currencyInfo.currency.symbol || !bridgedWithdrawalInfo) { return null } const chainName = getChainLabel(currencyInfo.currency.chainId) @@ -127,14 +129,15 @@ export function WormholeModal({ {t('bridgedAsset.wormhole.title', { currencySymbol: currencyInfo.currency.symbol, - nativeChainName: bridgedAsset.nativeChain, + nativeChainName: bridgedWithdrawalInfo.chain, })} {t('bridgedAsset.wormhole.description', { currencySymbol: currencyInfo.currency.symbol, chainName, - nativeChainName: bridgedAsset.nativeChain, + nativeChainName: bridgedWithdrawalInfo.chain, + provider: bridgedWithdrawalInfo.provider, })} @@ -158,7 +161,7 @@ export function WormholeModal({ onPress={onPressContinue} > - {t('bridgedAsset.wormhole.button')} + {t('bridgedAsset.wormhole.button', { provider: bridgedWithdrawalInfo.provider })} diff --git a/packages/uniswap/src/components/BridgedAsset/utils.ts b/packages/uniswap/src/components/BridgedAsset/utils.ts deleted file mode 100644 index c74ddbe26d9..00000000000 --- a/packages/uniswap/src/components/BridgedAsset/utils.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { BridgedAsset, isBridgedAsset, UNICHAIN_BRIDGED_ASSETS } from 'uniswap/src/constants/tokens' -import { UniverseChainId } from 'uniswap/src/features/chains/types' -import { CurrencyInfo } from 'uniswap/src/features/dataApi/types' - -export function checkIsBridgedAsset(currencyInfo?: CurrencyInfo): boolean { - if (!currencyInfo) { - return false - } - - return ( - currencyInfo.currency.chainId === UniverseChainId.Unichain && - currencyInfo.currency.isToken && - isBridgedAsset(currencyInfo.currency.address) - ) -} - -export function getBridgedAsset(currencyInfo?: Maybe): BridgedAsset | undefined { - if (!currencyInfo || !currencyInfo.currency.isToken) { - return undefined - } - const address = currencyInfo.currency.address - return UNICHAIN_BRIDGED_ASSETS.find((asset) => asset.unichainAddress === address) -} diff --git a/packages/uniswap/src/components/CurrencyInputPanel/AmountInputPresets/AmountInputPresets.tsx b/packages/uniswap/src/components/CurrencyInputPanel/AmountInputPresets/AmountInputPresets.tsx index 1b3889b53ea..1103debb35d 100644 --- a/packages/uniswap/src/components/CurrencyInputPanel/AmountInputPresets/AmountInputPresets.tsx +++ b/packages/uniswap/src/components/CurrencyInputPanel/AmountInputPresets/AmountInputPresets.tsx @@ -1,21 +1,17 @@ -import { Flex, FlexProps } from 'ui/src' +import { Key } from 'react' +import { ButtonProps, Flex, FlexProps } from 'ui/src' import { get200MsAnimationDelayFromIndex } from 'ui/src/theme/animations/delay200ms' -import { PresetAmountButton } from 'uniswap/src/components/CurrencyInputPanel/AmountInputPresets/PresetAmountButton' import { AmountInputPresetsProps } from 'uniswap/src/components/CurrencyInputPanel/AmountInputPresets/types' -import { PRESET_PERCENTAGES } from 'uniswap/src/components/CurrencyInputPanel/AmountInputPresets/utils' -import { ElementName } from 'uniswap/src/features/telemetry/constants' -import { CurrencyField } from 'uniswap/src/types/currency' import { isHoverable } from 'utilities/src/platform' -export function AmountInputPresets({ +export const PRESET_BUTTON_PROPS: ButtonProps = { variant: 'default', py: '$spacing4' } + +export function AmountInputPresets({ hoverLtr, - currencyAmount, - currencyBalance, - transactionType, - buttonProps, - onSetPresetValue, + presets, + renderPreset, ...rest -}: AmountInputPresetsProps & FlexProps): JSX.Element { +}: AmountInputPresetsProps & FlexProps): JSX.Element { return ( - {PRESET_PERCENTAGES.map((percent, index) => ( + {presets.map((preset, index) => ( - + {renderPreset(preset)} ))} diff --git a/packages/uniswap/src/components/CurrencyInputPanel/AmountInputPresets/types.ts b/packages/uniswap/src/components/CurrencyInputPanel/AmountInputPresets/types.ts index 8fda416c162..7734ff991db 100644 --- a/packages/uniswap/src/components/CurrencyInputPanel/AmountInputPresets/types.ts +++ b/packages/uniswap/src/components/CurrencyInputPanel/AmountInputPresets/types.ts @@ -1,15 +1,8 @@ -import { Currency, CurrencyAmount } from '@uniswap/sdk-core' -import { ButtonProps } from 'ui/src/components/buttons/Button/types' -import { TransactionType } from 'uniswap/src/features/transactions/types/transactionDetails' - export type PresetPercentageNumber = 25 | 50 | 75 | 100 export type PresetPercentage = PresetPercentageNumber | 'max' -export interface AmountInputPresetsProps { +export interface AmountInputPresetsProps { hoverLtr?: boolean - currencyAmount: CurrencyAmount | null | undefined - currencyBalance: CurrencyAmount - transactionType?: TransactionType - buttonProps?: ButtonProps - onSetPresetValue: (amount: string, percentage: PresetPercentage) => void + presets: T[] + renderPreset: (preset: T) => JSX.Element } diff --git a/packages/uniswap/src/components/CurrencyInputPanel/CurrencyInputPanel.tsx b/packages/uniswap/src/components/CurrencyInputPanel/CurrencyInputPanel.tsx index 194d817d833..29f5a8e6bb9 100644 --- a/packages/uniswap/src/components/CurrencyInputPanel/CurrencyInputPanel.tsx +++ b/packages/uniswap/src/components/CurrencyInputPanel/CurrencyInputPanel.tsx @@ -1,15 +1,20 @@ /* eslint-disable complexity */ import { forwardRef, memo, useCallback } from 'react' import { Flex, TouchableArea, useIsShortMobileDevice, useShakeAnimation } from 'ui/src' -import { AmountInputPresets } from 'uniswap/src/components/CurrencyInputPanel/AmountInputPresets/AmountInputPresets' +import { + AmountInputPresets, + PRESET_BUTTON_PROPS, +} from 'uniswap/src/components/CurrencyInputPanel/AmountInputPresets/AmountInputPresets' import { PresetAmountButton } from 'uniswap/src/components/CurrencyInputPanel/AmountInputPresets/PresetAmountButton' import type { PresetPercentage } from 'uniswap/src/components/CurrencyInputPanel/AmountInputPresets/types' +import { PRESET_PERCENTAGES } from 'uniswap/src/components/CurrencyInputPanel/AmountInputPresets/utils' import { CurrencyInputPanelBalance } from 'uniswap/src/components/CurrencyInputPanel/CurrencyInputPanelBalance' import { CurrencyInputPanelHeader } from 'uniswap/src/components/CurrencyInputPanel/CurrencyInputPanelHeader' import { CurrencyInputPanelInput } from 'uniswap/src/components/CurrencyInputPanel/CurrencyInputPanelInput' import { CurrencyInputPanelValue } from 'uniswap/src/components/CurrencyInputPanel/CurrencyInputPanelValue' import { useIndicativeQuoteTextDisplay } from 'uniswap/src/components/CurrencyInputPanel/hooks/useIndicativeQuoteTextDisplay' import type { CurrencyInputPanelProps, CurrencyInputPanelRef } from 'uniswap/src/components/CurrencyInputPanel/types' +import { ElementName } from 'uniswap/src/features/telemetry/constants' import { useWallet } from 'uniswap/src/features/wallet/hooks/useWallet' import { CurrencyField } from 'uniswap/src/types/currency' import { isExtensionApp, isMobileWeb, isWebAppDesktop } from 'utilities/src/platform' @@ -80,6 +85,22 @@ export const CurrencyInputPanel = memo( [onSetPresetValue], ) + const renderPreset = useCallback( + (preset: PresetPercentage) => ( + + ), + [currencyAmount, currencyBalance, currencyField, handleSetPresetValue, transactionType], + ) + return ( {showPercentagePresetsOnBottom && currencyBalance && !currencyAmount ? ( - + ) : ( ( + + ), + [currencyAmount, currencyBalance, currencyField, onSetPresetValue], + ) + if (!headerLabel && !showDefaultTokenOptions) { return null } @@ -49,12 +71,7 @@ export function CurrencyInputPanelHeader({ {showInputPresets && ( - + )} {showDefaultTokenOptions && isWebAppDesktop && ( diff --git a/packages/uniswap/src/components/TokenSelector/TokenSelector.tsx b/packages/uniswap/src/components/TokenSelector/TokenSelector.tsx index 8a11c2310d4..ca096c964fd 100644 --- a/packages/uniswap/src/components/TokenSelector/TokenSelector.tsx +++ b/packages/uniswap/src/components/TokenSelector/TokenSelector.tsx @@ -1,5 +1,6 @@ import type { BottomSheetView } from '@gorhom/bottom-sheet' import { Currency } from '@uniswap/sdk-core' +import { FeatureFlags, useFeatureFlag } from '@universe/gating' import { hasStringAsync } from 'expo-clipboard' import { ComponentProps, memo, useCallback, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -23,8 +24,6 @@ import { TradeableAsset } from 'uniswap/src/entities/assets' 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 { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' import { SearchContext } from 'uniswap/src/features/search/SearchModal/analytics/SearchContext' import { useFilterCallbacks } from 'uniswap/src/features/search/SearchModal/hooks/useFilterCallbacks' import { SearchTextInput } from 'uniswap/src/features/search/SearchTextInput' diff --git a/packages/uniswap/src/components/TokenSelector/TokenSelectorList.tsx b/packages/uniswap/src/components/TokenSelector/TokenSelectorList.tsx index 239da7655aa..b3c62a99d83 100644 --- a/packages/uniswap/src/components/TokenSelector/TokenSelectorList.tsx +++ b/packages/uniswap/src/components/TokenSelector/TokenSelectorList.tsx @@ -2,7 +2,6 @@ import { memo, useCallback, useState } from 'react' import { useDispatch } from 'react-redux' import { Text } from 'ui/src' import { BridgedAssetModal } from 'uniswap/src/components/BridgedAsset/BridgedAssetModal' -import { checkIsBridgedAsset } from 'uniswap/src/components/BridgedAsset/utils' import { TokenOptionItem as BaseTokenOptionItem, TokenContextMenuVariant, @@ -80,7 +79,7 @@ const TokenOptionItem = memo(function _TokenOptionItem({ const shouldShowWarningModalOnPress = showWarnings && (isBlocked || (severity !== WarningSeverity.None && !tokenWarningDismissed)) - const isBridgedAsset = checkIsBridgedAsset(currencyInfo) + const isBridgedAsset = Boolean(currencyInfo.isBridged) const [showBridgedAssetWarningModal, setShowBridgedAssetWarningModal] = useState(false) const { tokenWarningDismissed: bridgedAssetTokenWarningDismissed } = useDismissedBridgedAssetWarnings( currencyInfo.currency, diff --git a/packages/uniswap/src/components/TokenSelector/hooks.test.ts b/packages/uniswap/src/components/TokenSelector/hooks.test.ts index 274e9417792..7970d994ec6 100644 --- a/packages/uniswap/src/components/TokenSelector/hooks.test.ts +++ b/packages/uniswap/src/components/TokenSelector/hooks.test.ts @@ -62,6 +62,16 @@ jest.mock('uniswap/src/data/rest/tokenRankings', () => ({ tokenRankingsStatToCurrencyInfo: jest.fn(), })) +// Helper to convert undefined to null for GraphQL compatibility +const convertUndefinedToNull = ( + items: T[], +): T[] => + items.map((item) => ({ + ...item, + isBridged: item.isBridged ?? null, + bridgedWithdrawalInfo: item.bridgedWithdrawalInfo ?? null, + })) + const mockPortfolioHook = jest.requireMock( 'uniswap/src/components/TokenSelector/hooks/usePortfolioBalancesForAddressById', ) @@ -157,12 +167,12 @@ describe(useAllCommonBaseCurrencies, () => { { test: 'returns all currencies when there is no currency with a bridged version on other networks', input: projects, - output: { data: tokenProjectToCurrencyInfos(projects) }, + output: { data: convertUndefinedToNull(tokenProjectToCurrencyInfos(projects)) }, }, { test: 'filters out currencies that have a bridged version on other networks', input: [projectWithBridged], - output: { data: tokenProjectToCurrencyInfos([tokenProjectWithoutBridged]) }, + output: { data: convertUndefinedToNull(tokenProjectToCurrencyInfos([tokenProjectWithoutBridged])) }, }, ] @@ -215,7 +225,7 @@ describe(useFavoriteCurrencies, () => { { test: 'returns favorite tokens when there is data', input: [project], - output: { data: tokenProjectToCurrencyInfos([projectWithFavoritesOnly]) }, + output: { data: convertUndefinedToNull(tokenProjectToCurrencyInfos([projectWithFavoritesOnly])) }, }, ] @@ -319,22 +329,22 @@ describe(useFilterCallbacks, () => { expect(result.current.chainFilter).toEqual(UniverseChainId.ArbitrumOne) expect(result.current.searchFilter).toEqual('base uni') expect(result.current.parsedSearchFilter).toEqual(null) - expect(result.current.parsedSearchFilter).toEqual(null) }) it('does not parse unsupported chains', async () => { + const searchText = 'UNSUPPORTED uni' const { result } = renderHook(() => useFilterCallbacks(null, ModalName.Swap)) expect(result.current.parsedSearchFilter).toEqual(null) await act(() => { - result.current.onChangeText('UNSUPPORTED uni') + result.current.onChangeText(searchText) }) expect(result.current.chainFilter).toEqual(null) - expect(result.current.searchFilter).toEqual('UNSUPPORTED uni') + expect(result.current.searchFilter).toEqual(searchText) expect(result.current.parsedChainFilter).toEqual(null) - expect(result.current.parsedSearchFilter).toEqual(null) + expect(result.current.parsedSearchFilter).toEqual(searchText) }) it('only parses after the first space', async () => { @@ -399,17 +409,18 @@ describe(useFilterCallbacks, () => { it('does not parse unsupported chains from end', async () => { const { result } = renderHook(() => useFilterCallbacks(null, ModalName.Swap)) + const searchText = 'uni UNSUPPORTED' expect(result.current.parsedSearchFilter).toEqual(null) await act(() => { - result.current.onChangeText('uni UNSUPPORTED') + result.current.onChangeText(searchText) }) expect(result.current.chainFilter).toEqual(null) - expect(result.current.searchFilter).toEqual('uni UNSUPPORTED') + expect(result.current.searchFilter).toEqual(searchText) expect(result.current.parsedChainFilter).toEqual(null) - expect(result.current.parsedSearchFilter).toEqual(null) + expect(result.current.parsedSearchFilter).toEqual(searchText) }) }) diff --git a/packages/uniswap/src/components/activity/details/TransactionDetailsModal.test.tsx b/packages/uniswap/src/components/activity/details/TransactionDetailsModal.test.tsx index ab3a2ad969a..7aa920b47a9 100644 --- a/packages/uniswap/src/components/activity/details/TransactionDetailsModal.test.tsx +++ b/packages/uniswap/src/components/activity/details/TransactionDetailsModal.test.tsx @@ -83,7 +83,8 @@ jest.mock('uniswap/src/features/tokens/useCurrencyInfo', () => ({ }, })) -jest.mock('uniswap/src/features/gating/hooks', () => ({ +jest.mock('@universe/gating', () => ({ + ...jest.requireActual('@universe/gating'), useDynamicConfigValue: jest .fn() .mockImplementation(({ defaultValue }: { config: unknown; key: unknown; defaultValue: unknown }) => { diff --git a/packages/uniswap/src/components/activity/details/transactions/SwapTransactionDetails.test.tsx b/packages/uniswap/src/components/activity/details/transactions/SwapTransactionDetails.test.tsx index 5b4154799d9..ac166a53b89 100644 --- a/packages/uniswap/src/components/activity/details/transactions/SwapTransactionDetails.test.tsx +++ b/packages/uniswap/src/components/activity/details/transactions/SwapTransactionDetails.test.tsx @@ -51,7 +51,8 @@ jest.mock('uniswap/src/features/tokens/useCurrencyInfo', () => ({ }, })) -jest.mock('uniswap/src/features/gating/hooks', () => ({ +jest.mock('@universe/gating', () => ({ + ...jest.requireActual('@universe/gating'), useDynamicConfigValue: jest .fn() .mockImplementation(({ defaultValue }: { config: unknown; key: unknown; defaultValue: unknown }) => { diff --git a/packages/uniswap/src/components/activity/details/transactions/TransferTransactionDetails.test.tsx b/packages/uniswap/src/components/activity/details/transactions/TransferTransactionDetails.test.tsx index 6827f2805c4..52cfaa09a99 100644 --- a/packages/uniswap/src/components/activity/details/transactions/TransferTransactionDetails.test.tsx +++ b/packages/uniswap/src/components/activity/details/transactions/TransferTransactionDetails.test.tsx @@ -67,7 +67,8 @@ const getCurrencyInfoForChain = (chainId: number): CurrencyInfo => { } } -jest.mock('uniswap/src/features/gating/hooks', () => ({ +jest.mock('@universe/gating', () => ({ + ...jest.requireActual('@universe/gating'), useDynamicConfigValue: jest .fn() .mockImplementation(({ defaultValue }: { config: unknown; key: unknown; defaultValue: unknown }) => { diff --git a/packages/uniswap/src/components/gating/DynamicConfigDropdown.tsx b/packages/uniswap/src/components/gating/DynamicConfigDropdown.tsx index 17d4e3450cb..c3797f2dbd4 100644 --- a/packages/uniswap/src/components/gating/DynamicConfigDropdown.tsx +++ b/packages/uniswap/src/components/gating/DynamicConfigDropdown.tsx @@ -1,8 +1,6 @@ +import { DynamicConfigKeys, DynamicConfigs, getOverrideAdapter, useDynamicConfigValue } from '@universe/gating' import { Flex, Text } from 'ui/src' import { ActionSheetDropdown } from 'uniswap/src/components/dropdowns/ActionSheetDropdown' -import { DynamicConfigKeys, DynamicConfigs } from 'uniswap/src/features/gating/configs' -import { useDynamicConfigValue } from 'uniswap/src/features/gating/hooks' -import { getOverrideAdapter } from 'uniswap/src/features/gating/sdk/statsig' export function DynamicConfigDropdown({ config, diff --git a/packages/uniswap/src/components/gating/GatingOverrides.tsx b/packages/uniswap/src/components/gating/GatingOverrides.tsx index 4d82f77b9b2..94bae18eb5a 100644 --- a/packages/uniswap/src/components/gating/GatingOverrides.tsx +++ b/packages/uniswap/src/components/gating/GatingOverrides.tsx @@ -1,3 +1,16 @@ +import { + DynamicConfigs, + EmbeddedWalletConfigKey, + Experiments, + ExtensionBiometricUnlockConfigKey, + FeatureFlags, + ForceUpgradeConfigKey, + getFeatureFlagName, + getOverrideAdapter, + Layers, + useFeatureFlagWithExposureLoggingDisabled, + WALLET_FEATURE_FLAG_NAMES, +} from '@universe/gating' import React, { PropsWithChildren, useCallback } from 'react' import { Accordion, Flex, Separator, Switch, Text } from 'ui/src' import { RotatableChevron } from 'ui/src/components/icons/RotatableChevron' @@ -14,16 +27,6 @@ import { GatingButton } from 'uniswap/src/components/gating/GatingButton' import { ExperimentRow, LayerRow } from 'uniswap/src/components/gating/Rows' import { useForceUpgradeStatus } from 'uniswap/src/features/forceUpgrade/hooks/useForceUpgradeStatus' import { useForceUpgradeTranslations } from 'uniswap/src/features/forceUpgrade/hooks/useForceUpgradeTranslations' -import { - DynamicConfigs, - EmbeddedWalletConfigKey, - ExtensionBiometricUnlockConfigKey, - ForceUpgradeConfigKey, -} from 'uniswap/src/features/gating/configs' -import { Experiments, Layers } from 'uniswap/src/features/gating/experiments' -import { FeatureFlags, getFeatureFlagName, WALLET_FEATURE_FLAG_NAMES } from 'uniswap/src/features/gating/flags' -import { useFeatureFlagWithExposureLoggingDisabled } from 'uniswap/src/features/gating/hooks' -import { getOverrideAdapter } from 'uniswap/src/features/gating/sdk/statsig' import { useEmbeddedWalletBaseUrl } from 'uniswap/src/features/passkey/hooks/useEmbeddedWalletBaseUrl' import { isExtensionApp, isMobileApp } from 'utilities/src/platform' import { useEvent } from 'utilities/src/react/hooks' diff --git a/packages/uniswap/src/components/gating/Rows.tsx b/packages/uniswap/src/components/gating/Rows.tsx index c5797d1a13e..fac0346bbaf 100644 --- a/packages/uniswap/src/components/gating/Rows.tsx +++ b/packages/uniswap/src/components/gating/Rows.tsx @@ -1,7 +1,6 @@ +import { Experiments, getOverrideAdapter, LayerProperties, Layers, useExperiment, useLayer } from '@universe/gating' import { useCallback } from 'react' import { Flex, Input, Switch, Text } from 'ui/src' -import { Experiments, LayerProperties, Layers } from 'uniswap/src/features/gating/experiments' -import { getOverrideAdapter, useExperiment, useLayer } from 'uniswap/src/features/gating/sdk/statsig' export function LayerRow({ value: layerName, diff --git a/packages/uniswap/src/components/gating/dynamicConfigOverrides.tsx b/packages/uniswap/src/components/gating/dynamicConfigOverrides.tsx index a7ac74e9b63..166f0afd754 100644 --- a/packages/uniswap/src/components/gating/dynamicConfigOverrides.tsx +++ b/packages/uniswap/src/components/gating/dynamicConfigOverrides.tsx @@ -1,6 +1,6 @@ +import { ForceUpgradeStatus, ForceUpgradeTranslations } from '@universe/gating' import { ComponentProps } from 'react' import { DynamicConfigDropdown } from 'uniswap/src/components/gating/DynamicConfigDropdown' -import { ForceUpgradeStatus, ForceUpgradeTranslations } from 'uniswap/src/features/gating/configs' type DynamicConfigOptions = ComponentProps['options'] diff --git a/packages/uniswap/src/components/lists/items/pools/PoolOptionItem.tsx b/packages/uniswap/src/components/lists/items/pools/PoolOptionItem.tsx index 48a9871d69c..998c2f3630f 100644 --- a/packages/uniswap/src/components/lists/items/pools/PoolOptionItem.tsx +++ b/packages/uniswap/src/components/lists/items/pools/PoolOptionItem.tsx @@ -1,4 +1,4 @@ -import { ProtocolVersion } from '@uniswap/client-pools/dist/pools/v1/types_pb' +import { ProtocolVersion } from '@uniswap/client-data-api/dist/data/v1/poolTypes_pb' import { memo } from 'react' import { Flex, Text } from 'ui/src' import { iconSizes } from 'ui/src/theme' diff --git a/packages/uniswap/src/components/lists/items/pools/PoolOptionItemContextMenu.tsx b/packages/uniswap/src/components/lists/items/pools/PoolOptionItemContextMenu.tsx index a6971c2edca..e2bd8a72534 100644 --- a/packages/uniswap/src/components/lists/items/pools/PoolOptionItemContextMenu.tsx +++ b/packages/uniswap/src/components/lists/items/pools/PoolOptionItemContextMenu.tsx @@ -1,4 +1,4 @@ -import { ProtocolVersion } from '@uniswap/client-pools/dist/pools/v1/types_pb' +import { ProtocolVersion } from '@uniswap/client-data-api/dist/data/v1/poolTypes_pb' import React, { ReactNode, useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { CheckCircleFilled } from 'ui/src/components/icons/CheckCircleFilled' diff --git a/packages/uniswap/src/components/lists/items/pools/usePoolSearchResultsToPoolOptions.tsx b/packages/uniswap/src/components/lists/items/pools/usePoolSearchResultsToPoolOptions.tsx index d1d48af9eb7..146cefe7f3d 100644 --- a/packages/uniswap/src/components/lists/items/pools/usePoolSearchResultsToPoolOptions.tsx +++ b/packages/uniswap/src/components/lists/items/pools/usePoolSearchResultsToPoolOptions.tsx @@ -1,4 +1,4 @@ -import { ProtocolVersion } from '@uniswap/client-pools/dist/pools/v1/types_pb' +import { ProtocolVersion } from '@uniswap/client-data-api/dist/data/v1/poolTypes_pb' import { useMemo } from 'react' import { OnchainItemListOptionType, PoolOption } from 'uniswap/src/components/lists/items/types' import { ZERO_ADDRESS } from 'uniswap/src/constants/misc' diff --git a/packages/uniswap/src/components/lists/items/pools/usePoolStatsToPoolOptions.tsx b/packages/uniswap/src/components/lists/items/pools/usePoolStatsToPoolOptions.tsx index fcdd6b49718..715546ef5be 100644 --- a/packages/uniswap/src/components/lists/items/pools/usePoolStatsToPoolOptions.tsx +++ b/packages/uniswap/src/components/lists/items/pools/usePoolStatsToPoolOptions.tsx @@ -1,5 +1,5 @@ +import { ProtocolVersion } from '@uniswap/client-data-api/dist/data/v1/poolTypes_pb' import { PoolStats } from '@uniswap/client-explore/dist/uniswap/explore/v1/service_pb' -import { ProtocolVersion } from '@uniswap/client-pools/dist/pools/v1/types_pb' import { parseRestProtocolVersion } from '@universe/api' import { useMemo } from 'react' import { OnchainItemListOptionType, PoolOption } from 'uniswap/src/components/lists/items/types' diff --git a/packages/uniswap/src/components/lists/items/types.ts b/packages/uniswap/src/components/lists/items/types.ts index 0944d974245..2ab0ff8035b 100644 --- a/packages/uniswap/src/components/lists/items/types.ts +++ b/packages/uniswap/src/components/lists/items/types.ts @@ -1,4 +1,4 @@ -import { ProtocolVersion } from '@uniswap/client-pools/dist/pools/v1/types_pb' +import { ProtocolVersion } from '@uniswap/client-data-api/dist/data/v1/poolTypes_pb' import { UniverseChainId } from 'uniswap/src/features/chains/types' import { CurrencyInfo } from 'uniswap/src/features/dataApi/types' diff --git a/packages/uniswap/src/components/nfts/NftView.tsx b/packages/uniswap/src/components/nfts/NftView.tsx index b9f9dffbae8..9c57a605347 100644 --- a/packages/uniswap/src/components/nfts/NftView.tsx +++ b/packages/uniswap/src/components/nfts/NftView.tsx @@ -10,9 +10,10 @@ export type NftViewProps = { onPress: () => void walletAddresses: Address[] openContextMenu?: () => void + hoverAnimation?: boolean } -export function NftView({ item, onPress, index, openContextMenu }: NftViewProps): JSX.Element { +export function NftView({ item, onPress, index, openContextMenu, hoverAnimation = true }: NftViewProps): JSX.Element { const nftView = ( skip?: boolean customEmptyState?: JSX.Element + autoColumns?: boolean + /** Web-only: when true, use a flex-wrap container instead of 2-col grid */ + wrapFlex?: boolean }, 'renderItem' | 'data' -> +> & { + loadingSkeletonCount?: number +} export function NftsList(_props: NftsListProps): JSX.Element { throw new PlatformSplitStubError('NftsList') diff --git a/packages/uniswap/src/components/nfts/NftsList.web.tsx b/packages/uniswap/src/components/nfts/NftsList.web.tsx index 5d957a39e0d..1e92a24062b 100644 --- a/packages/uniswap/src/components/nfts/NftsList.web.tsx +++ b/packages/uniswap/src/components/nfts/NftsList.web.tsx @@ -2,7 +2,7 @@ import { isNonPollingRequestInFlight } from '@universe/api' import { useCallback, useEffect, useMemo } from 'react' import { useTranslation } from 'react-i18next' import InfiniteScroll from 'react-infinite-scroll-component' -import { Flex, Loader, View } from 'ui/src' +import { Flex, Loader, styled, View } from 'ui/src' import { NoNfts } from 'ui/src/components/icons/NoNfts' import { BaseCard } from 'uniswap/src/components/BaseCard/BaseCard' import { ExpandoRow } from 'uniswap/src/components/ExpandoRow/ExpandoRow' @@ -17,24 +17,29 @@ import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' import { TestID } from 'uniswap/src/test/fixtures/testIDs' import { isExtensionApp } from 'utilities/src/platform' -const AssetsContainer = ({ children, useGrid }: { children: React.ReactNode; useGrid: boolean }): JSX.Element => { - return ( - - {children} - - ) -} +const AssetsContainer = styled(View, { + width: '100%', + gap: '$spacing2', + variants: { + useGrid: { + true: { + '$platform-web': { + display: 'grid', + // default to 2 columns + gridTemplateColumns: 'minmax(0, 1fr) minmax(0, 1fr)', + gridGap: '12px', + }, + }, + }, + autoColumns: { + true: { + '$platform-web': { + gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))', + }, + }, + }, + }, +}) const LOADING_ITEM = 'loading' @@ -48,6 +53,8 @@ export function NftsList({ renderNFTItem, skip, customEmptyState, + autoColumns = false, + loadingSkeletonCount = 6, }: NftsListProps): JSX.Element { const { t } = useTranslation() @@ -96,7 +103,7 @@ export function NftsList({ return null case HIDDEN_NFTS_ROW: return ( - + ( <> - - - - - - - - - - + {Array.from({ length: loadingSkeletonCount }, (_, i) => ( + + ))} ), - [], + [loadingSkeletonCount], ) const emptyState = useMemo( @@ -197,7 +197,9 @@ export function NftsList({ style={{ overflow: 'unset' }} scrollableTarget="wallet-dropdown-scroll-wrapper" > - 0}>{listContent} + 0} autoColumns={autoColumns}> + {listContent} + ) diff --git a/packages/uniswap/src/components/notifications/NotificationToast.native.tsx b/packages/uniswap/src/components/notifications/NotificationToast.native.tsx index b97222b2910..fd0a2c1a101 100644 --- a/packages/uniswap/src/components/notifications/NotificationToast.native.tsx +++ b/packages/uniswap/src/components/notifications/NotificationToast.native.tsx @@ -39,11 +39,11 @@ export function NotificationToast({ const onDismissLatest = useCallback(() => { bannerOffset.value = withSpring(HIDE_OFFSET_Y, SPRING_ANIMATION) - }, [bannerOffset]) + }, []) const onShowCurrentNotification = useCallback(() => { bannerOffset.value = withDelay(SPRING_ANIMATION_DELAY, withSpring(showOffset, SPRING_ANIMATION)) - }, [bannerOffset, showOffset]) + }, [showOffset]) const { onActionButtonPress, onNotificationPress, cancelDismiss, dismissLatest } = useNotificationLifecycle({ actionButtonOnPress: actionButton?.onPress, diff --git a/packages/uniswap/src/data/apiClients/tradingApi/TradingApiClient.ts b/packages/uniswap/src/data/apiClients/tradingApi/TradingApiClient.ts index 3a5211d76a3..433155b730f 100644 --- a/packages/uniswap/src/data/apiClients/tradingApi/TradingApiClient.ts +++ b/packages/uniswap/src/data/apiClients/tradingApi/TradingApiClient.ts @@ -1,10 +1,9 @@ import { createTradingApiClient, TradingApi } from '@universe/api' +import { FeatureFlags, getFeatureFlag } from '@universe/gating' import { config } from 'uniswap/src/config' import { tradingApiVersionPrefix, uniswapUrls } from 'uniswap/src/constants/urls' import { createUniswapFetchClient } from 'uniswap/src/data/apiClients/createUniswapFetchClient' import { filterChainIdsByPlatform } from 'uniswap/src/features/chains/utils' -import { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { getFeatureFlag } from 'uniswap/src/features/gating/hooks' import { Platform } from 'uniswap/src/features/platforms/types/Platform' const TradingFetchClient = createUniswapFetchClient({ diff --git a/packages/uniswap/src/data/apiClients/uniswapApi/useGasFeeQuery.ts b/packages/uniswap/src/data/apiClients/uniswapApi/useGasFeeQuery.ts index a1bcecfc55c..b2ab75b8cf7 100644 --- a/packages/uniswap/src/data/apiClients/uniswapApi/useGasFeeQuery.ts +++ b/packages/uniswap/src/data/apiClients/uniswapApi/useGasFeeQuery.ts @@ -4,13 +4,13 @@ import { type UseQueryWithImmediateGarbageCollectionApiHelperHookArgs, useQueryWithImmediateGarbageCollection, } from '@universe/api' +import { useStatsigClientStatus } from '@universe/gating' import { uniswapUrls } from 'uniswap/src/constants/urls' import { createFetchGasFee, type GasFeeResultWithoutState, } from 'uniswap/src/data/apiClients/uniswapApi/UniswapApiClient' import { getActiveGasStrategy } from 'uniswap/src/features/gas/utils' -import { useStatsigClientStatus } from 'uniswap/src/features/gating/hooks' import { ReactQueryCacheKey } from 'utilities/src/reactQuery/cache' export function useGasFeeQuery({ diff --git a/packages/uniswap/src/data/rest/conversionTracking/useConversionProxy.ts b/packages/uniswap/src/data/rest/conversionTracking/useConversionProxy.ts index 87391cd4998..af8c28f62ae 100644 --- a/packages/uniswap/src/data/rest/conversionTracking/useConversionProxy.ts +++ b/packages/uniswap/src/data/rest/conversionTracking/useConversionProxy.ts @@ -3,9 +3,8 @@ import { type ConnectError, type Transport } from '@connectrpc/connect' import { useMutation } from '@connectrpc/connect-query' import { type UseMutationResult } from '@tanstack/react-query' import { ConversionTrackingApi, createConnectTransportWithDefaults } from '@universe/api' +import { FeatureFlags, useFeatureFlag } from '@universe/gating' import { getConversionProxyApiBaseUrl } from 'uniswap/src/data/rest/conversionTracking/utils' -import { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' const createConversionProxyTransport = (isConversionApiMigrationEnabled: boolean): Transport => createConnectTransportWithDefaults({ diff --git a/packages/uniswap/src/data/rest/conversionTracking/useConversionTracking.ts b/packages/uniswap/src/data/rest/conversionTracking/useConversionTracking.ts index b64191593ff..c9307f17d9a 100644 --- a/packages/uniswap/src/data/rest/conversionTracking/useConversionTracking.ts +++ b/packages/uniswap/src/data/rest/conversionTracking/useConversionTracking.ts @@ -1,4 +1,5 @@ import { ConnectError } from '@connectrpc/connect' +import { FeatureFlags, useFeatureFlag } from '@universe/gating' import { useAtom } from 'jotai' import { atomWithStorage } from 'jotai/utils' import { parse } from 'qs' @@ -12,8 +13,6 @@ import { buildProxyRequest } from 'uniswap/src/data/rest/conversionTracking/trac import { ConversionLead, PlatformIdType, TrackConversionArgs } from 'uniswap/src/data/rest/conversionTracking/types' import { useConversionProxy } from 'uniswap/src/data/rest/conversionTracking/useConversionProxy' import { getExternalConversionLeadsCookie } from 'uniswap/src/data/rest/conversionTracking/utils' -import { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' import { UniswapEventName } from 'uniswap/src/features/telemetry/constants' import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' import { HexString } from 'utilities/src/addresses/hex' diff --git a/packages/uniswap/src/data/rest/getPair.ts b/packages/uniswap/src/data/rest/getPair.ts deleted file mode 100644 index b108414c5d7..00000000000 --- a/packages/uniswap/src/data/rest/getPair.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { PartialMessage } from '@bufbuild/protobuf' -import { ConnectError } from '@connectrpc/connect' -import { useQuery } from '@connectrpc/connect-query' -import { UseQueryResult } from '@tanstack/react-query' -import { GetPairRequest, GetPairResponse } from '@uniswap/client-pools/dist/pools/v1/api_pb' -import { getPair } from '@uniswap/client-pools/dist/pools/v1/api-PoolsService_connectquery' -import { uniswapGetTransport } from 'uniswap/src/data/rest/base' - -/** - * eslint-disable import/no-unused-modules -- this endpoint is returning stale data sometimes meaning - * that the data we get (i.e. the dependent amount) is incorrect and the transaction does not complete on chain. - * Use this endpoint again once the data is more up to date or the trading API handles the data discrepancy. - */ -export function useGetPair( - input?: PartialMessage, - enabled = true, -): UseQueryResult { - return useQuery(getPair, input, { transport: uniswapGetTransport, enabled, retry: false }) -} diff --git a/packages/uniswap/src/data/rest/getPools.ts b/packages/uniswap/src/data/rest/getPools.ts index 106bca1c926..1dcc0a98ead 100644 --- a/packages/uniswap/src/data/rest/getPools.ts +++ b/packages/uniswap/src/data/rest/getPools.ts @@ -2,8 +2,8 @@ import { PartialMessage } from '@bufbuild/protobuf' import { ConnectError } from '@connectrpc/connect' import { useQuery } from '@connectrpc/connect-query' import { UseQueryResult } from '@tanstack/react-query' -import { ListPoolsRequest, ListPoolsResponse } from '@uniswap/client-pools/dist/pools/v1/api_pb' -import { listPools } from '@uniswap/client-pools/dist/pools/v1/api-PoolsService_connectquery' +import { ListPoolsRequest, ListPoolsResponse } from '@uniswap/client-data-api/dist/data/v1/api_pb' +import { listPools } from '@uniswap/client-data-api/dist/data/v1/api-DataApiService_connectquery' import { uniswapGetTransport } from 'uniswap/src/data/rest/base' export function useGetPoolsByTokens( diff --git a/packages/uniswap/src/data/rest/getPoolsRewards.ts b/packages/uniswap/src/data/rest/getPoolsRewards.ts index 58a5abfed60..8d681b88749 100644 --- a/packages/uniswap/src/data/rest/getPoolsRewards.ts +++ b/packages/uniswap/src/data/rest/getPoolsRewards.ts @@ -2,8 +2,8 @@ import { PartialMessage } from '@bufbuild/protobuf' import { ConnectError } from '@connectrpc/connect' import { useQuery } from '@connectrpc/connect-query' import { UseQueryResult } from '@tanstack/react-query' -import { GetRewardsRequest, GetRewardsResponse } from '@uniswap/client-pools/dist/pools/v1/api_pb' -import { getRewards } from '@uniswap/client-pools/dist/pools/v1/api-PoolsService_connectquery' +import { GetRewardsRequest, GetRewardsResponse } from '@uniswap/client-data-api/dist/data/v1/api_pb' +import { getRewards } from '@uniswap/client-data-api/dist/data/v1/api-DataApiService_connectquery' import { uniswapGetTransport } from 'uniswap/src/data/rest/base' export function useGetPoolsRewards( diff --git a/packages/uniswap/src/data/rest/getPortfolio.ts b/packages/uniswap/src/data/rest/getPortfolio.ts index 5db0dd4021d..3b3db8f6bb3 100644 --- a/packages/uniswap/src/data/rest/getPortfolio.ts +++ b/packages/uniswap/src/data/rest/getPortfolio.ts @@ -15,7 +15,6 @@ import { cleanupCaughtUpOverrides, getOverridesForAddress, getOverridesForQuery, - getPortfolioQueryApolloClient, getPortfolioQueryReduxStore, } from 'uniswap/src/data/rest/portfolioBalanceOverrides' import { useEnabledChains } from 'uniswap/src/features/chains/hooks/useEnabledChains' @@ -107,9 +106,8 @@ export const getPortfolioQuery = ({ try { const reduxStore = getPortfolioQueryReduxStore() - const apolloClient = getPortfolioQueryApolloClient() - if (!reduxStore || !apolloClient) { + if (!reduxStore) { log.warn('`getPortfolioQuery` called before `initializePortfolioQueryOverrides`') return apiResponse } @@ -150,7 +148,6 @@ export const getPortfolioQuery = ({ }) const mergedResult = await fetchAndMergeOnchainBalances({ - apolloClient, cachedPortfolio: modifiedResponse.portfolio, accountAddress: address, currencyIds: overridesForCurrentAddress, diff --git a/packages/uniswap/src/data/rest/getPosition.ts b/packages/uniswap/src/data/rest/getPosition.ts index 857c7d5a83c..22218d7f21a 100644 --- a/packages/uniswap/src/data/rest/getPosition.ts +++ b/packages/uniswap/src/data/rest/getPosition.ts @@ -2,8 +2,8 @@ import { PartialMessage } from '@bufbuild/protobuf' import { ConnectError } from '@connectrpc/connect' import { useQuery } from '@connectrpc/connect-query' import { UseQueryResult } from '@tanstack/react-query' -import { GetPositionRequest, GetPositionResponse } from '@uniswap/client-pools/dist/pools/v1/api_pb' -import { getPosition } from '@uniswap/client-pools/dist/pools/v1/api-PoolsService_connectquery' +import { GetPositionRequest, GetPositionResponse } from '@uniswap/client-data-api/dist/data/v1/api_pb' +import { getPosition } from '@uniswap/client-data-api/dist/data/v1/api-DataApiService_connectquery' import { uniswapPostTransport } from 'uniswap/src/data/rest/base' export function useGetPositionQuery( diff --git a/packages/uniswap/src/data/rest/getPositions.ts b/packages/uniswap/src/data/rest/getPositions.ts index 0a5431262e6..400b3115f92 100644 --- a/packages/uniswap/src/data/rest/getPositions.ts +++ b/packages/uniswap/src/data/rest/getPositions.ts @@ -12,9 +12,9 @@ import { GetPositionResponse, ListPositionsRequest, ListPositionsResponse, -} from '@uniswap/client-pools/dist/pools/v1/api_pb' -import { getPosition, listPositions } from '@uniswap/client-pools/dist/pools/v1/api-PoolsService_connectquery' -import { ProtocolVersion } from '@uniswap/client-pools/dist/pools/v1/types_pb' +} from '@uniswap/client-data-api/dist/data/v1/api_pb' +import { getPosition, listPositions } from '@uniswap/client-data-api/dist/data/v1/api-DataApiService_connectquery' +import { ProtocolVersion } from '@uniswap/client-data-api/dist/data/v1/poolTypes_pb' import { Pair } from '@uniswap/v2-sdk' import { useMemo } from 'react' import { uniswapPostTransport } from 'uniswap/src/data/rest/base' diff --git a/packages/uniswap/src/data/rest/portfolioBalanceOverrides.ts b/packages/uniswap/src/data/rest/portfolioBalanceOverrides.ts index 695e5229893..24badbde9bc 100644 --- a/packages/uniswap/src/data/rest/portfolioBalanceOverrides.ts +++ b/packages/uniswap/src/data/rest/portfolioBalanceOverrides.ts @@ -1,4 +1,3 @@ -import { ApolloClient, NormalizedCacheObject } from '@apollo/client' import { ToolkitStore } from '@reduxjs/toolkit/dist/configureStore' import { GetPortfolioResponse } from '@uniswap/client-data-api/dist/data/v1/api_pb' import { getNativeAddress } from 'uniswap/src/constants/addresses' @@ -16,30 +15,22 @@ const FILE_NAME = 'portfolioBalanceOverrides.ts' // so instead of checking for exact equality, we check if the quantities are "aproximately" equal. const APPROXIMATE_EQUALITY_THRESHOLD_PERCENT = 0.02 // 2% -// Module-level references to Redux store and Apollo client +// Module-level references to Redux store // These are initialized once during app startup let portfolioQueryReduxStore: ToolkitStore | null = null -let portfolioQueryApolloClient: ApolloClient | null = null /** * Initializes the portfolio balance override mechanism. - * This must be called once during each app initialization after both the Redux store and Apollo client are created. + * This must be called once during each app initialization after the Redux store is created. */ -export function initializePortfolioQueryOverrides({ - store, - apolloClient, -}: { - store: ToolkitStore - apolloClient: ApolloClient -}): void { +export function initializePortfolioQueryOverrides({ store }: { store: ToolkitStore }): void { const log = createLogger(FILE_NAME, 'initializePortfolioQueryOverrides', '[REST-ITBU]') - if (portfolioQueryReduxStore || portfolioQueryApolloClient) { + if (portfolioQueryReduxStore) { log.warn('`initializePortfolioQueryOverrides` called multiple times') } portfolioQueryReduxStore = store - portfolioQueryApolloClient = apolloClient log.debug('Portfolio query overrides successfully initialized') } @@ -48,10 +39,6 @@ export function getPortfolioQueryReduxStore(): ToolkitStore | null { return portfolioQueryReduxStore } -export function getPortfolioQueryApolloClient(): ApolloClient | null { - return portfolioQueryApolloClient -} - const selectTokenBalanceOverridesForWalletAddress = makeSelectTokenBalanceOverridesForWalletAddress() /** diff --git a/packages/uniswap/src/data/rest/searchTokensAndPools.ts b/packages/uniswap/src/data/rest/searchTokensAndPools.ts index 47fcae4534a..0657cb1681c 100644 --- a/packages/uniswap/src/data/rest/searchTokensAndPools.ts +++ b/packages/uniswap/src/data/rest/searchTokensAndPools.ts @@ -1,22 +1,29 @@ import { PartialMessage } from '@bufbuild/protobuf' import { ConnectError } from '@connectrpc/connect' -import { useQuery } from '@connectrpc/connect-query' +import { createQueryOptions, useQuery } from '@connectrpc/connect-query' import { UseQueryResult } from '@tanstack/react-query' import { Pool, type Token as SearchToken, SearchTokensRequest, SearchTokensResponse, + SearchType, } from '@uniswap/client-search/dist/search/v1/api_pb' import { searchTokens } from '@uniswap/client-search/dist/search/v1/api-searchService_connectquery' -import { parseProtectionInfo, parseRestProtocolVersion, parseSafetyLevel } from '@universe/api' +import { parseProtectionInfo, parseRestProtocolVersion, parseSafetyLevel, SharedQueryClient } from '@universe/api' import { getNativeAddress } from 'uniswap/src/constants/addresses' import { uniswapPostTransport } from 'uniswap/src/data/rest/base' +import { createLogger } from 'utilities/src/logger/logger' + +const FILE_NAME = 'searchTokensAndPools.ts' + +import { UniverseChainId } from 'uniswap/src/features/chains/types' import { CurrencyInfo } from 'uniswap/src/features/dataApi/types' import { buildCurrency, buildCurrencyInfo } from 'uniswap/src/features/dataApi/utils/buildCurrency' import { getCurrencySafetyInfo } from 'uniswap/src/features/dataApi/utils/getCurrencySafetyInfo' import { PoolSearchHistoryResult, SearchHistoryResultType } from 'uniswap/src/features/search/SearchHistoryResult' import { buildCurrencyId, currencyId, isNativeCurrencyAddress } from 'uniswap/src/utils/currencyId' +import { ONE_DAY_MS, ONE_HOUR_MS } from 'utilities/src/time/time' /** * Wrapper around Tanstack useQuery for the Uniswap REST BE service SearchTokens @@ -40,6 +47,56 @@ export function useSearchTokensAndPoolsQuery({ }) } +/** + * Fetch a single token by address outside of React components + * @param chainId - The chain ID to search on + * @param address - The token address to look up + * @returns Token data or null if not found + */ +export async function fetchTokenByAddress({ + chainId, + address, +}: { + chainId: UniverseChainId + address: string +}): Promise { + const log = createLogger(FILE_NAME, 'fetchTokenByAddress') + + try { + const result = await SharedQueryClient.fetchQuery({ + ...createQueryOptions( + searchTokens, + { + searchQuery: address, + chainIds: [chainId], + searchType: SearchType.TOKEN, + size: 1, + page: 1, + }, + { transport: uniswapPostTransport }, + ), + // Token data does not change often, so we can use stale data here. + // This data will be refreshed when fetching the portfolio balances anyway. + staleTime: ONE_HOUR_MS, + gcTime: ONE_DAY_MS, + }) + + const token = result.tokens[0] ?? null + + if (!token) { + log.debug('Token not found in search results', { chainId, address }) + } + + return token + } catch (error) { + log.error(error, { + chainId, + address, + }) + return null + } +} + export function searchTokenToCurrencyInfo(token: SearchToken): CurrencyInfo | null { const { chainId, address, symbol, name, decimals, logoUrl, feeData } = token const safetyLevel = parseSafetyLevel(token.safetyLevel) diff --git a/packages/uniswap/src/features/activity/formatTransactionsByDate.ts b/packages/uniswap/src/features/activity/formatTransactionsByDate.ts index 8283078091f..dff387c77ba 100644 --- a/packages/uniswap/src/features/activity/formatTransactionsByDate.ts +++ b/packages/uniswap/src/features/activity/formatTransactionsByDate.ts @@ -62,15 +62,30 @@ export function formatTransactionsByDate( // For all transactions before yesterday, group by month const priorByMonthTransactionList = olderThan24HTransactionList.reduce( (accum: Record, item) => { + // Skip transactions with invalid timestamps + if (!item.addedTime || item.addedTime <= 0) { + return accum + } + const isPreviousYear = item.addedTime < msTimestampCutoffYear - const key = localizedDayjs(item.addedTime) + const dayjsDate = localizedDayjs(item.addedTime) + const maybeKeyFromDayjsDate = dayjsDate // If in a previous year, append year to key string, else just use month // This key is used as the section title in TransactionList .format(isPreviousYear ? FORMAT_DATE_MONTH_YEAR : FORMAT_DATE_MONTH) .toString() - const currentMonthList = accum[key] ?? [] + + // Fallback to English if localized formatting fails + const validatedKey = dayjsDate.isValid() + ? maybeKeyFromDayjsDate + : dayjs(item.addedTime) + .locale('en') + .format(isPreviousYear ? FORMAT_DATE_MONTH_YEAR : FORMAT_DATE_MONTH) + + const currentMonthList = accum[validatedKey] ?? [] currentMonthList.push(item) - accum[key] = currentMonthList + accum[validatedKey] = currentMonthList + return accum }, {}, diff --git a/packages/uniswap/src/features/behaviorHistory/slice.ts b/packages/uniswap/src/features/behaviorHistory/slice.ts index 72c644fe19f..76c0e39b55b 100644 --- a/packages/uniswap/src/features/behaviorHistory/slice.ts +++ b/packages/uniswap/src/features/behaviorHistory/slice.ts @@ -9,6 +9,7 @@ export interface UniswapBehaviorHistoryState { hasDismissedBridgingWarning?: boolean hasDismissedLowNetworkTokenWarning?: boolean hasViewedContractAddressExplainer?: boolean + hasDismissedBridgedAssetsBannerV2?: boolean unichainPromotion?: { coldBannerDismissed?: boolean warmBannerDismissed?: boolean @@ -33,6 +34,7 @@ export const initialUniswapBehaviorHistoryState: UniswapBehaviorHistoryState = { hasDismissedBridgingWarning: false, hasDismissedLowNetworkTokenWarning: false, hasViewedContractAddressExplainer: false, + hasDismissedBridgedAssetsBannerV2: false, unichainPromotion: { coldBannerDismissed: false, warmBannerDismissed: false, @@ -108,6 +110,9 @@ const slice = createSlice({ setHasSeenToucanIntroModal: (state, action: PayloadAction) => { state.hasSeenToucanIntroModal = action.payload }, + setHasDismissedBridgedAssetsBannerV2: (state, action: PayloadAction) => { + state.hasDismissedBridgedAssetsBannerV2 = action.payload + }, }, }) @@ -128,6 +133,7 @@ export const { setEmbeddedWalletGraduateCardDismissed, setHasShownSmartWalletNudge, setHasSeenToucanIntroModal, + setHasDismissedBridgedAssetsBannerV2, } = slice.actions export const uniswapBehaviorHistoryReducer = slice.reducer diff --git a/packages/uniswap/src/features/chains/evm/info/avalanche.ts b/packages/uniswap/src/features/chains/evm/info/avalanche.ts index e7b8d376100..b439bc592c3 100644 --- a/packages/uniswap/src/features/chains/evm/info/avalanche.ts +++ b/packages/uniswap/src/features/chains/evm/info/avalanche.ts @@ -1,5 +1,6 @@ import { Token } from '@uniswap/sdk-core' import { GraphQLApi } from '@universe/api' +import { SwapConfigKey } from '@universe/gating' import { AVALANCHE_LOGO } from 'ui/src/assets' import { config } from 'uniswap/src/config' import { DEFAULT_NATIVE_ADDRESS_LEGACY, getQuicknodeEndpointUrl } from 'uniswap/src/features/chains/evm/rpc' @@ -11,7 +12,6 @@ import { UniverseChainId, UniverseChainInfo, } from 'uniswap/src/features/chains/types' -import { SwapConfigKey } from 'uniswap/src/features/gating/configs' import { Platform } from 'uniswap/src/features/platforms/types/Platform' import { ElementName } from 'uniswap/src/features/telemetry/constants' import { buildUSDC, buildUSDT } from 'uniswap/src/features/tokens/stablecoin' diff --git a/packages/uniswap/src/features/chains/evm/info/celo.ts b/packages/uniswap/src/features/chains/evm/info/celo.ts index c58fe928cae..d1fc7111b55 100644 --- a/packages/uniswap/src/features/chains/evm/info/celo.ts +++ b/packages/uniswap/src/features/chains/evm/info/celo.ts @@ -1,4 +1,5 @@ import { GraphQLApi } from '@universe/api' +import { SwapConfigKey } from '@universe/gating' import { CELO_LOGO } from 'ui/src/assets' import { config } from 'uniswap/src/config' import { getQuicknodeEndpointUrl } from 'uniswap/src/features/chains/evm/rpc' @@ -10,7 +11,6 @@ import { UniverseChainId, UniverseChainInfo, } from 'uniswap/src/features/chains/types' -import { SwapConfigKey } from 'uniswap/src/features/gating/configs' import { Platform } from 'uniswap/src/features/platforms/types/Platform' import { ElementName } from 'uniswap/src/features/telemetry/constants' import { buildUSDC } from 'uniswap/src/features/tokens/stablecoin' diff --git a/packages/uniswap/src/features/chains/evm/info/mainnet.ts b/packages/uniswap/src/features/chains/evm/info/mainnet.ts index 0283a360cec..374332ae025 100644 --- a/packages/uniswap/src/features/chains/evm/info/mainnet.ts +++ b/packages/uniswap/src/features/chains/evm/info/mainnet.ts @@ -1,5 +1,6 @@ import { CurrencyAmount } from '@uniswap/sdk-core' import { GraphQLApi } from '@universe/api' +import { SwapConfigKey } from '@universe/gating' import { ETH_LOGO, ETHEREUM_LOGO } from 'ui/src/assets' import { config } from 'uniswap/src/config' import { @@ -16,7 +17,6 @@ import { UniverseChainId, UniverseChainInfo, } from 'uniswap/src/features/chains/types' -import { SwapConfigKey } from 'uniswap/src/features/gating/configs' import { Platform } from 'uniswap/src/features/platforms/types/Platform' import { ElementName } from 'uniswap/src/features/telemetry/constants' import { buildDAI, buildUSDC, buildUSDT } from 'uniswap/src/features/tokens/stablecoin' diff --git a/packages/uniswap/src/features/chains/evm/info/monad.ts b/packages/uniswap/src/features/chains/evm/info/monad.ts index 7ea38b26778..740d57a0d05 100644 --- a/packages/uniswap/src/features/chains/evm/info/monad.ts +++ b/packages/uniswap/src/features/chains/evm/info/monad.ts @@ -1,4 +1,5 @@ import { GraphQLApi } from '@universe/api' +import { SwapConfigKey } from '@universe/gating' import { MONAD_LOGO } from 'ui/src/assets' import { DEFAULT_NATIVE_ADDRESS_LEGACY, getQuicknodeEndpointUrl } from 'uniswap/src/features/chains/evm/rpc' import { buildChainTokens } from 'uniswap/src/features/chains/evm/tokens' @@ -9,7 +10,6 @@ import { UniverseChainId, UniverseChainInfo, } from 'uniswap/src/features/chains/types' -import { SwapConfigKey } from 'uniswap/src/features/gating/configs' import { Platform } from 'uniswap/src/features/platforms/types/Platform' import { ElementName } from 'uniswap/src/features/telemetry/constants' import { buildUSDT } from 'uniswap/src/features/tokens/stablecoin' diff --git a/packages/uniswap/src/features/chains/evm/info/polygon.ts b/packages/uniswap/src/features/chains/evm/info/polygon.ts index 2624aded152..e13b25e96d9 100644 --- a/packages/uniswap/src/features/chains/evm/info/polygon.ts +++ b/packages/uniswap/src/features/chains/evm/info/polygon.ts @@ -1,4 +1,5 @@ import { GraphQLApi } from '@universe/api' +import { SwapConfigKey } from '@universe/gating' import { POLYGON_LOGO } from 'ui/src/assets' import { config } from 'uniswap/src/config' import { getQuicknodeEndpointUrl } from 'uniswap/src/features/chains/evm/rpc' @@ -10,7 +11,6 @@ import { UniverseChainId, UniverseChainInfo, } from 'uniswap/src/features/chains/types' -import { SwapConfigKey } from 'uniswap/src/features/gating/configs' import { Platform } from 'uniswap/src/features/platforms/types/Platform' import { ElementName } from 'uniswap/src/features/telemetry/constants' import { buildDAI, buildUSDC, buildUSDT } from 'uniswap/src/features/tokens/stablecoin' diff --git a/packages/uniswap/src/features/chains/gasDefaults.ts b/packages/uniswap/src/features/chains/gasDefaults.ts index 3ef71913717..7e5e8f0c8dd 100644 --- a/packages/uniswap/src/features/chains/gasDefaults.ts +++ b/packages/uniswap/src/features/chains/gasDefaults.ts @@ -1,4 +1,4 @@ -import { SwapConfigKey } from 'uniswap/src/features/gating/configs' +import { SwapConfigKey } from '@universe/gating' /** * Shared gas configuration constants. diff --git a/packages/uniswap/src/features/chains/hooks/useFeatureFlaggedChainIds.ts b/packages/uniswap/src/features/chains/hooks/useFeatureFlaggedChainIds.ts index 969e7995190..36dbf2723ea 100644 --- a/packages/uniswap/src/features/chains/hooks/useFeatureFlaggedChainIds.ts +++ b/packages/uniswap/src/features/chains/hooks/useFeatureFlaggedChainIds.ts @@ -1,8 +1,7 @@ +import { FeatureFlags, getFeatureFlag, useFeatureFlag } from '@universe/gating' import { useMemo } from 'react' import { UniverseChainId } from 'uniswap/src/features/chains/types' import { filterChainIdsByFeatureFlag } from 'uniswap/src/features/chains/utils' -import { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { getFeatureFlag, useFeatureFlag } from 'uniswap/src/features/gating/hooks' export const getFeatureFlaggedChainIds = createGetFeatureFlaggedChainIds({ getSoneiumStatus: () => getFeatureFlag(FeatureFlags.Soneium), diff --git a/packages/uniswap/src/features/chains/hooks/useNewChainIds.ts b/packages/uniswap/src/features/chains/hooks/useNewChainIds.ts index 8a9bd27b642..831f54704fc 100644 --- a/packages/uniswap/src/features/chains/hooks/useNewChainIds.ts +++ b/packages/uniswap/src/features/chains/hooks/useNewChainIds.ts @@ -1,8 +1,7 @@ +import { ChainsConfigKey, DynamicConfigs, useDynamicConfigValue } from '@universe/gating' import { useMemo } from 'react' import { UniverseChainId } from 'uniswap/src/features/chains/types' import { isUniverseChainId } from 'uniswap/src/features/chains/utils' -import { ChainsConfigKey, DynamicConfigs } from 'uniswap/src/features/gating/configs' -import { useDynamicConfigValue } from 'uniswap/src/features/gating/hooks' import { isUniverseChainIdArrayType } from 'uniswap/src/features/gating/typeGuards' export function useNewChainIds(): UniverseChainId[] { diff --git a/packages/uniswap/src/features/chains/hooks/useOrderedChainIds.ts b/packages/uniswap/src/features/chains/hooks/useOrderedChainIds.ts index b74ac7d3e58..da0cbd451dd 100644 --- a/packages/uniswap/src/features/chains/hooks/useOrderedChainIds.ts +++ b/packages/uniswap/src/features/chains/hooks/useOrderedChainIds.ts @@ -1,8 +1,7 @@ +import { ChainsConfigKey, DynamicConfigs, useDynamicConfigValue } from '@universe/gating' import { useMemo } from 'react' import { ALL_CHAIN_IDS } from 'uniswap/src/features/chains/chainInfo' import { UniverseChainId } from 'uniswap/src/features/chains/types' -import { ChainsConfigKey, DynamicConfigs } from 'uniswap/src/features/gating/configs' -import { useDynamicConfigValue } from 'uniswap/src/features/gating/hooks' // Returns the given chains ordered based on the statsig config export function useOrderedChainIds(chainIds: UniverseChainId[]): UniverseChainId[] { diff --git a/packages/uniswap/src/features/chains/types.ts b/packages/uniswap/src/features/chains/types.ts index b302bfeafdb..4e680d421e8 100644 --- a/packages/uniswap/src/features/chains/types.ts +++ b/packages/uniswap/src/features/chains/types.ts @@ -1,10 +1,10 @@ // biome-ignore lint/style/noRestrictedImports: legacy import will be migrated import { CurrencyAmount, Token, ChainId as UniswapSDKChainId } from '@uniswap/sdk-core' import type { GraphQLApi } from '@universe/api' +import { SwapConfigKey } from '@universe/gating' import type { ImageSourcePropType } from 'react-native' // biome-ignore lint/style/noRestrictedImports: legacy import will be migrated import { type UNIVERSE_CHAIN_INFO } from 'uniswap/src/features/chains/chainInfo' -import { SwapConfigKey } from 'uniswap/src/features/gating/configs' import { Platform } from 'uniswap/src/features/platforms/types/Platform' import { ElementName } from 'uniswap/src/features/telemetry/constants' import { NonEmptyArray } from 'utilities/src/primitives/array' diff --git a/packages/uniswap/src/features/dataApi/balances/balances.ts b/packages/uniswap/src/features/dataApi/balances/balances.ts index 7fe5058c6ed..fa121a145af 100644 --- a/packages/uniswap/src/features/dataApi/balances/balances.ts +++ b/packages/uniswap/src/features/dataApi/balances/balances.ts @@ -38,10 +38,12 @@ export type PortfolioCacheUpdater = (hidden: boolean, portfolioBalance?: Portfol export function usePortfolioBalances({ evmAddress, svmAddress, + chainIds, ...queryOptions }: { evmAddress?: Address svmAddress?: Address + chainIds?: UniverseChainId[] } & QueryHookOptions< GraphQLApi.PortfolioBalancesQuery, GraphQLApi.PortfolioBalancesQueryVariables @@ -49,6 +51,7 @@ export function usePortfolioBalances({ return usePortfolioData({ evmAddress: evmAddress || '', svmAddress: svmAddress || '', + chainIds, ...queryOptions, skip: !(evmAddress ?? svmAddress) || queryOptions.skip, }) @@ -270,6 +273,8 @@ export function useTokenBalancesGroupedByVisibility({ }, [balancesById, currencyIdToTokenVisibility, isTestnetModeEnabled]) } +type SortedPortfolioBalancesResult = GqlResult & { networkStatus: NetworkStatus } + /** * Returns portfolio balances for a given address sorted by USD value. * @@ -284,12 +289,14 @@ export function useSortedPortfolioBalances({ svmAddress, pollInterval, onCompleted, + chainIds, }: { evmAddress?: Address svmAddress?: Address pollInterval?: PollingInterval onCompleted?: () => void -}): GqlResult & { networkStatus: NetworkStatus } { + chainIds?: UniverseChainId[] +}): SortedPortfolioBalancesResult { const { isTestnetModeEnabled } = useEnabledChains() // Fetch all balances including small balances and spam tokens because we want to return those in separate arrays @@ -304,19 +311,23 @@ export function useSortedPortfolioBalances({ pollInterval, onCompleted, fetchPolicy: 'cache-and-network', + chainIds, }) const { shownTokens, hiddenTokens } = useTokenBalancesGroupedByVisibility({ balancesById }) - return { - data: { - balances: sortPortfolioBalances({ balances: shownTokens || [], isTestnetModeEnabled }), - hiddenBalances: sortPortfolioBalances({ balances: hiddenTokens || [], isTestnetModeEnabled }), - }, - loading, - networkStatus, - refetch, - } + return useMemo( + () => ({ + data: { + balances: sortPortfolioBalances({ balances: shownTokens || [], isTestnetModeEnabled }), + hiddenBalances: sortPortfolioBalances({ balances: hiddenTokens || [], isTestnetModeEnabled }), + }, + loading, + networkStatus, + refetch, + }), + [shownTokens, hiddenTokens, isTestnetModeEnabled, loading, networkStatus, refetch], + ) } /** diff --git a/packages/uniswap/src/features/dataApi/balances/balancesRest.ts b/packages/uniswap/src/features/dataApi/balances/balancesRest.ts index 64f1f59fb9e..b288659bb99 100644 --- a/packages/uniswap/src/features/dataApi/balances/balancesRest.ts +++ b/packages/uniswap/src/features/dataApi/balances/balancesRest.ts @@ -48,7 +48,8 @@ export function usePortfolioData({ pollInterval?: PollingInterval fetchPolicy?: WatchQueryFetchPolicy } & GetPortfolioInput['input']): PortfolioDataResult { - const { chains: chainIds } = useEnabledChains() + const { chains: defaultChainIds } = useEnabledChains() + const chainIds = queryOptions.chainIds || defaultChainIds // TODO(SWAP-388): GetPortfolio REST endpoint does not yet support modifier array; it will take 1 evm/svm address, but will apply the modifications across the board const modifier = useRestPortfolioValueModifier(evmAddress ?? svmAddress) diff --git a/packages/uniswap/src/features/dataApi/tokenProjects/tokenProjects.test.tsx b/packages/uniswap/src/features/dataApi/tokenProjects/tokenProjects.test.tsx index 7bb8281ae25..d588ebf377b 100644 --- a/packages/uniswap/src/features/dataApi/tokenProjects/tokenProjects.test.tsx +++ b/packages/uniswap/src/features/dataApi/tokenProjects/tokenProjects.test.tsx @@ -28,9 +28,17 @@ describe(useTokenProjects, () => { resolvers, }) - await waitFor(async () => { - const data = result.current.data - expect(data).toEqual(tokenProjectToCurrencyInfos(await resolved.tokenProjects)) + const expected = tokenProjectToCurrencyInfos(await resolved.tokenProjects) + // GraphQL converts undefined to null, so we need to do the same for comparison + const expectedWithNull = expected.map((item) => ({ + ...item, + isBridged: item.isBridged ?? null, + bridgedWithdrawalInfo: item.bridgedWithdrawalInfo ?? null, + })) + + await waitFor(() => { + expect(result.current.loading).toEqual(false) + expect(result.current.data).toEqual(expectedWithNull) }) }) }) diff --git a/packages/uniswap/src/features/dataApi/tokenProjects/utils/tokenProjectToCurrencyInfos.test.ts b/packages/uniswap/src/features/dataApi/tokenProjects/utils/tokenProjectToCurrencyInfos.test.ts index 996eaa13b22..1ac2dc67a12 100644 --- a/packages/uniswap/src/features/dataApi/tokenProjects/utils/tokenProjectToCurrencyInfos.test.ts +++ b/packages/uniswap/src/features/dataApi/tokenProjects/utils/tokenProjectToCurrencyInfos.test.ts @@ -20,6 +20,8 @@ describe(tokenProjectToCurrencyInfos, () => { symbol: token.symbol, name: token.name ?? project.name, }), + isBridged: token.isBridged, + bridgedWithdrawalInfo: token.bridgedWithdrawalInfo, }) as CurrencyInfo it('converts tokenProject to CurrencyInfo', () => { diff --git a/packages/uniswap/src/features/dataApi/tokenProjects/utils/tokenProjectToCurrencyInfos.ts b/packages/uniswap/src/features/dataApi/tokenProjects/utils/tokenProjectToCurrencyInfos.ts index c84763a73e0..01c78c2e2e4 100644 --- a/packages/uniswap/src/features/dataApi/tokenProjects/utils/tokenProjectToCurrencyInfos.ts +++ b/packages/uniswap/src/features/dataApi/tokenProjects/utils/tokenProjectToCurrencyInfos.ts @@ -14,7 +14,8 @@ export function tokenProjectToCurrencyInfos( ?.flatMap((project) => project?.tokens.map((token) => { const { logoUrl, safetyLevel } = project - const { name, chain, address, decimals, symbol, feeData, protectionInfo } = token + const { name, chain, address, decimals, symbol, feeData, protectionInfo, isBridged, bridgedWithdrawalInfo } = + token const chainId = fromGraphQLChain(chain) if (chainFilter && chainFilter !== chainId) { @@ -40,6 +41,8 @@ export function tokenProjectToCurrencyInfos( currencyId: currencyId(currency), logoUrl, safetyInfo: getCurrencySafetyInfo(safetyLevel, protectionInfo), + isBridged, + bridgedWithdrawalInfo, }) return currencyInfo diff --git a/packages/uniswap/src/features/dataApi/types.ts b/packages/uniswap/src/features/dataApi/types.ts index 00138794455..acb786fb064 100644 --- a/packages/uniswap/src/features/dataApi/types.ts +++ b/packages/uniswap/src/features/dataApi/types.ts @@ -2,6 +2,7 @@ import { NetworkStatus } from '@apollo/client' import { Contract } from '@uniswap/client-data-api/dist/data/v1/types_pb' import { Currency } from '@uniswap/sdk-core' import { GraphQLApi, SpamCode } from '@universe/api' +import { BridgedWithdrawalInfo } from '@universe/api/src/clients/graphql/__generated__/types-and-hooks' import { FoTPercent } from 'uniswap/src/features/tokens/TokenWarningModal' import { CurrencyId } from 'uniswap/src/types/currency' @@ -45,6 +46,10 @@ export type CurrencyInfo = { isSpam?: Maybe // Indicates if this currency is from another chain than user searched isFromOtherNetwork?: boolean + // Indicates if this token is a bridged asset + isBridged?: Maybe + // Information about how to withdraw a bridged asset to its native chain + bridgedWithdrawalInfo?: Maybe } // Portfolio balance as exposed to the app diff --git a/packages/uniswap/src/features/dataApi/utils/gqlTokenToCurrencyInfo.test.ts b/packages/uniswap/src/features/dataApi/utils/gqlTokenToCurrencyInfo.test.ts index 75b58eea063..1df17498d9f 100644 --- a/packages/uniswap/src/features/dataApi/utils/gqlTokenToCurrencyInfo.test.ts +++ b/packages/uniswap/src/features/dataApi/utils/gqlTokenToCurrencyInfo.test.ts @@ -23,6 +23,8 @@ describe(gqlTokenToCurrencyInfo, () => { currencyId: `${fromGraphQLChain(token.chain)}-${token.address}`, logoUrl: token.project.logoUrl, isSpam: token.project.isSpam, + isBridged: token.isBridged, + bridgedWithdrawalInfo: token.bridgedWithdrawalInfo, }) }) diff --git a/packages/uniswap/src/features/dataApi/utils/gqlTokenToCurrencyInfo.ts b/packages/uniswap/src/features/dataApi/utils/gqlTokenToCurrencyInfo.ts index ce4d1560a4c..150c1063372 100644 --- a/packages/uniswap/src/features/dataApi/utils/gqlTokenToCurrencyInfo.ts +++ b/packages/uniswap/src/features/dataApi/utils/gqlTokenToCurrencyInfo.ts @@ -11,7 +11,8 @@ export type GqlTokenToCurrencyInfoToken = Omit, + ) => FormattedFiatDelta +} { + const currency = useAppFiatCurrency() + const { formatNumberOrString } = useLocalizationContext() + + return useMemo( + () => ({ + formatChartFiatDelta: ( + options: Omit, + ): FormattedFiatDelta => { + return formatChartFiatDelta({ ...options, currency, formatNumberOrString }) + }, + }), + [currency, formatNumberOrString], + ) +} diff --git a/packages/uniswap/src/features/fiatCurrency/priceChart/formatters/shared/types.ts b/packages/uniswap/src/features/fiatCurrency/priceChart/formatters/shared/types.ts new file mode 100644 index 00000000000..b86b504b881 --- /dev/null +++ b/packages/uniswap/src/features/fiatCurrency/priceChart/formatters/shared/types.ts @@ -0,0 +1,37 @@ +import { FiatCurrency } from 'uniswap/src/features/fiatCurrency/constants' +import { FormatNumberOrStringInput } from 'uniswap/src/features/language/formatter' + +export type DecimalPlaceNumber = number | 'threshold' | 'zero' + +export interface TrimTrailingZerosParams { + formatted: string + decimals: number + roundedValue?: number // Optional - only used by stablecoin formatter +} + +export interface FiatDeltaFormatter { + getDecimalPlaces: (absValue: number) => DecimalPlaceNumber + trimTrailingZeros: (params: TrimTrailingZerosParams) => string + shouldShowBelowThreshold: (absValue: number) => boolean + format: (params: FormatParams) => string +} + +export interface FormatParams { + value: number + currency: FiatCurrency + formatNumberOrString: (input: FormatNumberOrStringInput) => string +} + +export interface FiatDeltaFormatOptions { + startingPrice: number + endingPrice: number + isStablecoin?: boolean + currency?: FiatCurrency + formatNumberOrString: (input: FormatNumberOrStringInput) => string +} + +export interface FormattedFiatDelta { + formatted: string + rawDelta: number + belowThreshold?: boolean +} diff --git a/packages/uniswap/src/features/fiatCurrency/priceChart/formatters/shared/utils.ts b/packages/uniswap/src/features/fiatCurrency/priceChart/formatters/shared/utils.ts new file mode 100644 index 00000000000..0bc790e11a5 --- /dev/null +++ b/packages/uniswap/src/features/fiatCurrency/priceChart/formatters/shared/utils.ts @@ -0,0 +1,93 @@ +import { FiatCurrency } from 'uniswap/src/features/fiatCurrency/constants' +import { getFiatCurrencyCode } from 'uniswap/src/features/fiatCurrency/hooks' +import { TrimTrailingZerosParams } from 'uniswap/src/features/fiatCurrency/priceChart/formatters/shared/types' +import { FormatNumberOrStringInput } from 'uniswap/src/features/language/formatter' +import { NumberType } from 'utilities/src/format/types' + +export const FIAT_DELTA_THRESHOLD = 0.000001 +const FORMATTED_NUMBER_PATTERN = /^([^0-9]*)([0-9,.\s]+)(.*)$/ + +export function roundToDecimals(value: number, decimals: number): number { + const factor = Math.pow(10, decimals) + return Math.round(value * factor) / factor +} + +export function formatZero( + currency: FiatCurrency, + formatNumberOrString: (input: FormatNumberOrStringInput) => string, +): string { + const currencyCode = getFiatCurrencyCode(currency) + return formatNumberOrString({ + value: 0, + type: NumberType.FiatStandard, + currencyCode, + }) +} + +export function formatThreshold( + currency: FiatCurrency, + formatNumberOrString: (input: FormatNumberOrStringInput) => string, +): string { + const currencyCode = getFiatCurrencyCode(currency) + + // Format just the threshold value to get proper currency symbol + const formatted = formatNumberOrString({ + value: FIAT_DELTA_THRESHOLD, + type: NumberType.FiatTokenDetails, + currencyCode, + }) + + return `<${formatted}` +} + +export function formatWithDecimals(params: { + value: number + decimals: number + currency: FiatCurrency + formatNumberOrString: (input: FormatNumberOrStringInput) => string + trimZeros: (params: TrimTrailingZerosParams) => string +}): string { + const { value, decimals, currency, formatNumberOrString, trimZeros } = params + const absValue = Math.abs(value) + const currencyCode = getFiatCurrencyCode(currency) + + // Round the value to the specified decimals + const roundedValue = roundToDecimals(absValue, decimals) + + // For very small values, we need to use a different number type that preserves precision + // NumberType.FiatStandard uses StandardCurrency which defaults to 2 decimals + // NumberType.FiatTokenDetails uses rules that preserve precision for small values + let formatted: string + + if (decimals > 2 && roundedValue < 1) { + // Use FiatTokenDetails which has SmallestNumCurrency for small values + // This preserves up to 20 decimals and respects the user's locale + formatted = formatNumberOrString({ + value: roundedValue, + type: NumberType.FiatTokenDetails, + currencyCode, + }) + } else { + // For larger values or values with 2 decimals, use the standard formatter + formatted = formatNumberOrString({ + value: roundedValue, + type: NumberType.FiatStandard, + currencyCode, + }) + } + + return trimZeros({ formatted, decimals, roundedValue }) +} + +export function parseFormattedNumber(formatted: string): { + prefix: string + numberPart: string + suffix: string +} { + const match = formatted.match(FORMATTED_NUMBER_PATTERN) + if (!match) { + return { prefix: '', numberPart: formatted, suffix: '' } + } + const [, prefix = '', numberPart = '', suffix = ''] = match + return { prefix, numberPart, suffix } +} diff --git a/packages/uniswap/src/features/fiatCurrency/priceChart/formatters/stablecoinFormatter.ts b/packages/uniswap/src/features/fiatCurrency/priceChart/formatters/stablecoinFormatter.ts new file mode 100644 index 00000000000..4ff1d52a6fd --- /dev/null +++ b/packages/uniswap/src/features/fiatCurrency/priceChart/formatters/stablecoinFormatter.ts @@ -0,0 +1,84 @@ +import type { + DecimalPlaceNumber, + FiatDeltaFormatter, + TrimTrailingZerosParams, +} from 'uniswap/src/features/fiatCurrency/priceChart/formatters/shared/types' +import { + formatWithDecimals, + formatZero, + parseFormattedNumber, + roundToDecimals, +} from 'uniswap/src/features/fiatCurrency/priceChart/formatters/shared/utils' + +function getDecimalPlaces(absValue: number): DecimalPlaceNumber { + if (absValue === 0) { + return 'zero' + } + if (absValue >= 0.01) { + return 2 + } + if (absValue >= 0.001) { + return 3 + } + return 2 // Show $0.00 for < 0.001 +} + +function trimTrailingZeros(params: TrimTrailingZerosParams): string { + const { formatted, decimals, roundedValue } = params + + // Special handling for stablecoins that round to zero + // don't trim 3 decimal places + if (roundedValue === 0 || decimals === 3) { + return formatted + } + + const { prefix, numberPart, suffix } = parseFormattedNumber(formatted) + + // Only trim 00 for 2 decimal places + if (decimals === 2 && numberPart.match(/[.,]00$/)) { + return prefix + numberPart.replace(/[.,]00$/, '') + suffix + } + + return formatted +} + +export function createStablecoinFormatter(): FiatDeltaFormatter { + return { + getDecimalPlaces, + trimTrailingZeros, + + shouldShowBelowThreshold: () => false, // Never show threshold for stablecoins + + format: (params): string => { + const { value, currency, formatNumberOrString } = params + const absValue = Math.abs(value) + let decimals = getDecimalPlaces(absValue) + + if (decimals === 'zero') { + return formatZero(currency, formatNumberOrString) + } + + // Stablecoins treat values < 0.001 as zero (return $0.00) + if (absValue < 0.001 && absValue > 0) { + return formatZero(currency, formatNumberOrString) + } + + // Check if rounding changes which decimal bucket we're in + // For example, 0.0099 rounds to 0.01 with 3 decimals, which should use 2 decimals + if (decimals === 3) { + const rounded = roundToDecimals(absValue, 3) + if (rounded >= 0.01) { + decimals = 2 + } + } + + return formatWithDecimals({ + value, + decimals: decimals as number, + currency, + formatNumberOrString, + trimZeros: trimTrailingZeros, + }) + }, + } +} diff --git a/packages/uniswap/src/features/fiatCurrency/priceChart/formatters/standardFormatter.ts b/packages/uniswap/src/features/fiatCurrency/priceChart/formatters/standardFormatter.ts new file mode 100644 index 00000000000..f9597121a4c --- /dev/null +++ b/packages/uniswap/src/features/fiatCurrency/priceChart/formatters/standardFormatter.ts @@ -0,0 +1,88 @@ +import type { + DecimalPlaceNumber, + FiatDeltaFormatter, + TrimTrailingZerosParams, +} from 'uniswap/src/features/fiatCurrency/priceChart/formatters/shared/types' +import { + FIAT_DELTA_THRESHOLD, + formatThreshold, + formatWithDecimals, + formatZero, + parseFormattedNumber, +} from 'uniswap/src/features/fiatCurrency/priceChart/formatters/shared/utils' + +const DECIMAL_THRESHOLDS = [ + { min: 1, decimals: 2 }, + { min: 0.1, decimals: 2 }, + { min: 0.01, decimals: 3 }, + { min: 0.001, decimals: 4 }, + { min: 0.0001, decimals: 5 }, + { min: 0.00001, decimals: 6 }, +] as const + +function getDecimalPlaces(absValue: number): DecimalPlaceNumber { + if (absValue === 0) { + return 'zero' + } + + // Use a small epsilon for floating point comparison + const EPSILON = 1e-10 + + for (const { min, decimals } of DECIMAL_THRESHOLDS) { + // Use epsilon comparison to handle floating point errors + if (absValue >= min - EPSILON) { + return decimals + } + } + + return 'threshold' +} + +function trimTrailingZeros(params: TrimTrailingZerosParams): string { + const { formatted, decimals } = params + const { prefix, numberPart, suffix } = parseFormattedNumber(formatted) + let trimmed = numberPart + + if (decimals === 2) { + // For 2 decimal places, keep both decimals (don't trim trailing zeros) + // This ensures values like $0.10 stay as $0.10, not $0.1 + trimmed = numberPart + } else if (decimals > 2) { + // For decimals > 2, trim all trailing zeros (including the decimal point if no significant digits remain) + trimmed = numberPart.replace(/(\.\d*?)0+$/, '$1') + // If we end with just a decimal point, remove it + trimmed = trimmed.replace(/\.$/, '') + } + + return prefix + trimmed + suffix +} + +export function createStandardFormatter(): FiatDeltaFormatter { + return { + getDecimalPlaces, + trimTrailingZeros, + + shouldShowBelowThreshold: (absValue: number) => absValue > 0 && absValue < FIAT_DELTA_THRESHOLD, + + format: (params): string => { + const { value, currency, formatNumberOrString } = params + const absValue = Math.abs(value) + const decimals = getDecimalPlaces(absValue) + + switch (decimals) { + case 'zero': + return formatZero(currency, formatNumberOrString) + case 'threshold': + return formatThreshold(currency, formatNumberOrString) + default: + return formatWithDecimals({ + value, + decimals, + currency, + formatNumberOrString, + trimZeros: trimTrailingZeros, + }) + } + }, + } +} diff --git a/packages/uniswap/src/features/fiatCurrency/priceChart/priceChartConversion.test.ts b/packages/uniswap/src/features/fiatCurrency/priceChart/priceChartConversion.test.ts new file mode 100644 index 00000000000..fbf15548f29 --- /dev/null +++ b/packages/uniswap/src/features/fiatCurrency/priceChart/priceChartConversion.test.ts @@ -0,0 +1,1204 @@ +/* eslint-disable max-lines */ +import { FiatCurrency } from 'uniswap/src/features/fiatCurrency/constants' +import { formatChartFiatDelta } from 'uniswap/src/features/fiatCurrency/priceChart/priceChartConversion' +import { FormatNumberOrStringInput } from 'uniswap/src/features/language/formatter' + +// Minimal test formatter that matches expected test output +const defaultFormatter = (input: FormatNumberOrStringInput): string => { + const { value, currencyCode, type } = input + if (value === null || value === undefined) { + return '-' + } + + const num = typeof value === 'number' ? value : parseFloat(value) + const currencySymbols: Record = { + USD: '$', + GBP: '£', + EUR: 'EUR ', + JPY: '¥', + INR: '₹', + } + + const symbol = currencySymbols[currencyCode || 'USD'] || currencyCode + ' ' + + if (type === 'fiat-standard') { + let decimals = 2 + const str = num.toString() + const match = str.match(/\.(\d+)/) + if (match && match[1]) { + decimals = Math.max(2, match[1].length) + } + + const formatted = num.toLocaleString('en-US', { + minimumFractionDigits: decimals, + maximumFractionDigits: decimals, + }) + if (num >= 1 && num === Math.floor(num)) { + return `${symbol}${formatted.replace(/\.00$/, '')}` + } + return `${symbol}${formatted}` + } + + const getDecimals = (n: number): number => { + const abs = Math.abs(n) + if (abs === 0 || abs >= 0.1) { + return 2 + } + if (abs >= 0.01) { + return Math.max(2, n.toString().replace(/.*\./, '').replace(/0+$/, '').length) + } + if (abs >= 0.001) { + return Math.max(3, n.toString().replace(/.*\./, '').replace(/0+$/, '').length) + } + if (abs >= 0.0001) { + return Math.max(4, n.toString().replace(/.*\./, '').replace(/0+$/, '').length) + } + if (abs >= 0.00001) { + return Math.max(5, n.toString().replace(/.*\./, '').replace(/0+$/, '').length) + } + return 6 + } + + const decimals = getDecimals(num) + let formatted = num.toLocaleString('en-US', { + minimumFractionDigits: decimals, + maximumFractionDigits: decimals, + }) + + // Trim trailing zeros for whole numbers (e.g., $1.00 -> $1) + if (decimals === 2 && num >= 1 && num === Math.floor(num)) { + formatted = formatted.replace(/\.00$/, '') + } + + return `${symbol}${formatted}` +} + +describe('formatChartFiatDelta', () => { + describe('normal crypto formatting', () => { + describe('values >= $1', () => { + it('formats positive values with 2 decimals', () => { + expect( + formatChartFiatDelta({ startingPrice: 100, endingPrice: 101.25, formatNumberOrString: defaultFormatter }) + .formatted, + ).toBe('$1.25') + expect( + formatChartFiatDelta({ startingPrice: 100, endingPrice: 2530.1, formatNumberOrString: defaultFormatter }) + .formatted, + ).toBe('$2,430.10') + expect( + formatChartFiatDelta({ startingPrice: 100, endingPrice: 101, formatNumberOrString: defaultFormatter }) + .formatted, + ).toBe('$1') + expect( + formatChartFiatDelta({ startingPrice: 100, endingPrice: 1099.99, formatNumberOrString: defaultFormatter }) + .formatted, + ).toBe('$999.99') + }) + + it('formats negative values with 2 decimals', () => { + expect( + formatChartFiatDelta({ startingPrice: 100, endingPrice: 100 + -1.25, formatNumberOrString: defaultFormatter }) + .formatted, + ).toBe('$1.25') + expect( + formatChartFiatDelta({ + startingPrice: 100, + endingPrice: 100 + -2430.1, + formatNumberOrString: defaultFormatter, + }).formatted, + ).toBe('$2,430.10') + expect( + formatChartFiatDelta({ startingPrice: 100, endingPrice: 100 + -1.0, formatNumberOrString: defaultFormatter }) + .formatted, + ).toBe('$1') + expect( + formatChartFiatDelta({ + startingPrice: 100, + endingPrice: 100 + -999.99, + formatNumberOrString: defaultFormatter, + }).formatted, + ).toBe('$999.99') + }) + + it('uses thousand separators', () => { + expect( + formatChartFiatDelta({ + startingPrice: 100, + endingPrice: 100 + 1234.56, + formatNumberOrString: defaultFormatter, + }).formatted, + ).toBe('$1,234.56') + expect( + formatChartFiatDelta({ + startingPrice: 100, + endingPrice: 100 + 1234567.89, + formatNumberOrString: defaultFormatter, + }).formatted, + ).toBe('$1,234,567.89') + expect( + formatChartFiatDelta({ + startingPrice: 100, + endingPrice: 100 + -1234567.89, + formatNumberOrString: defaultFormatter, + }).formatted, + ).toBe('$1,234,567.89') + }) + + it('trims trailing zeros', () => { + expect( + formatChartFiatDelta({ startingPrice: 100, endingPrice: 100 + 1.0, formatNumberOrString: defaultFormatter }) + .formatted, + ).toBe('$1') + expect( + formatChartFiatDelta({ startingPrice: 100, endingPrice: 100 + 1.1, formatNumberOrString: defaultFormatter }) + .formatted, + ).toBe('$1.10') + expect( + formatChartFiatDelta({ startingPrice: 100, endingPrice: 100 + 1.2, formatNumberOrString: defaultFormatter }) + .formatted, + ).toBe('$1.20') + }) + }) + + describe('values >= $0.10 and < $1', () => { + it('formats positive values with 2 decimals', () => { + expect( + formatChartFiatDelta({ startingPrice: 100, endingPrice: 100 + 0.57, formatNumberOrString: defaultFormatter }) + .formatted, + ).toBe('$0.57') + expect( + formatChartFiatDelta({ startingPrice: 100, endingPrice: 100 + 0.14, formatNumberOrString: defaultFormatter }) + .formatted, + ).toBe('$0.14') + expect( + formatChartFiatDelta({ startingPrice: 100, endingPrice: 100 + 0.1, formatNumberOrString: defaultFormatter }) + .formatted, + ).toBe('$0.10') + expect( + formatChartFiatDelta({ startingPrice: 100, endingPrice: 100 + 0.99, formatNumberOrString: defaultFormatter }) + .formatted, + ).toBe('$0.99') + }) + + it('formats negative values with 2 decimals', () => { + expect( + formatChartFiatDelta({ startingPrice: 100, endingPrice: 100 + -0.57, formatNumberOrString: defaultFormatter }) + .formatted, + ).toBe('$0.57') + expect( + formatChartFiatDelta({ startingPrice: 100, endingPrice: 100 + -0.14, formatNumberOrString: defaultFormatter }) + .formatted, + ).toBe('$0.14') + expect( + formatChartFiatDelta({ startingPrice: 100, endingPrice: 100 + -0.1, formatNumberOrString: defaultFormatter }) + .formatted, + ).toBe('$0.10') + expect( + formatChartFiatDelta({ startingPrice: 100, endingPrice: 100 + -0.99, formatNumberOrString: defaultFormatter }) + .formatted, + ).toBe('$0.99') + }) + + it('trims trailing zeros', () => { + expect( + formatChartFiatDelta({ startingPrice: 100, endingPrice: 100 + 0.1, formatNumberOrString: defaultFormatter }) + .formatted, + ).toBe('$0.10') + expect( + formatChartFiatDelta({ startingPrice: 100, endingPrice: 100 + 0.5, formatNumberOrString: defaultFormatter }) + .formatted, + ).toBe('$0.50') + }) + }) + + describe('values >= $0.01 and < $0.10', () => { + it('formats positive values with 3 decimals', () => { + expect( + formatChartFiatDelta({ startingPrice: 100, endingPrice: 100 + 0.053, formatNumberOrString: defaultFormatter }) + .formatted, + ).toBe('$0.053') + expect( + formatChartFiatDelta({ startingPrice: 100, endingPrice: 100 + 0.096, formatNumberOrString: defaultFormatter }) + .formatted, + ).toBe('$0.096') + expect( + formatChartFiatDelta({ startingPrice: 100, endingPrice: 100 + 0.01, formatNumberOrString: defaultFormatter }) + .formatted, + ).toBe('$0.01') + expect( + formatChartFiatDelta({ startingPrice: 100, endingPrice: 100 + 0.099, formatNumberOrString: defaultFormatter }) + .formatted, + ).toBe('$0.099') + }) + + it('formats negative values with 3 decimals', () => { + expect( + formatChartFiatDelta({ + startingPrice: 100, + endingPrice: 100 + -0.053, + formatNumberOrString: defaultFormatter, + }).formatted, + ).toBe('$0.053') + expect( + formatChartFiatDelta({ + startingPrice: 100, + endingPrice: 100 + -0.096, + formatNumberOrString: defaultFormatter, + }).formatted, + ).toBe('$0.096') + expect( + formatChartFiatDelta({ startingPrice: 100, endingPrice: 100 + -0.01, formatNumberOrString: defaultFormatter }) + .formatted, + ).toBe('$0.01') + expect( + formatChartFiatDelta({ + startingPrice: 100, + endingPrice: 100 + -0.099, + formatNumberOrString: defaultFormatter, + }).formatted, + ).toBe('$0.099') + }) + + it('trims trailing zeros but keeps at least 2 decimals', () => { + expect( + formatChartFiatDelta({ startingPrice: 100, endingPrice: 100 + 0.01, formatNumberOrString: defaultFormatter }) + .formatted, + ).toBe('$0.01') + expect( + formatChartFiatDelta({ startingPrice: 100, endingPrice: 100 + 0.05, formatNumberOrString: defaultFormatter }) + .formatted, + ).toBe('$0.05') + }) + }) + + describe('values >= $0.001 and < $0.01', () => { + it('formats positive values with 4 decimals', () => { + expect( + formatChartFiatDelta({ + startingPrice: 100, + endingPrice: 100 + 0.0075, + formatNumberOrString: defaultFormatter, + }).formatted, + ).toBe('$0.0075') + expect( + formatChartFiatDelta({ + startingPrice: 100, + endingPrice: 100 + 0.0031, + formatNumberOrString: defaultFormatter, + }).formatted, + ).toBe('$0.0031') + expect( + formatChartFiatDelta({ startingPrice: 100, endingPrice: 100 + 0.001, formatNumberOrString: defaultFormatter }) + .formatted, + ).toBe('$0.001') + expect( + formatChartFiatDelta({ + startingPrice: 100, + endingPrice: 100 + 0.0099, + formatNumberOrString: defaultFormatter, + }).formatted, + ).toBe('$0.0099') + }) + + it('formats negative values with 4 decimals', () => { + expect( + formatChartFiatDelta({ + startingPrice: 100, + endingPrice: 100 + -0.0075, + formatNumberOrString: defaultFormatter, + }).formatted, + ).toBe('$0.0075') + expect( + formatChartFiatDelta({ + startingPrice: 100, + endingPrice: 100 + -0.0031, + formatNumberOrString: defaultFormatter, + }).formatted, + ).toBe('$0.0031') + expect( + formatChartFiatDelta({ + startingPrice: 100, + endingPrice: 100 + -0.001, + formatNumberOrString: defaultFormatter, + }).formatted, + ).toBe('$0.001') + expect( + formatChartFiatDelta({ + startingPrice: 100, + endingPrice: 100 + -0.0099, + formatNumberOrString: defaultFormatter, + }).formatted, + ).toBe('$0.0099') + }) + + it('trims trailing zeros but keeps minimum decimals', () => { + expect( + formatChartFiatDelta({ startingPrice: 100, endingPrice: 100 + 0.001, formatNumberOrString: defaultFormatter }) + .formatted, + ).toBe('$0.001') + expect( + formatChartFiatDelta({ + startingPrice: 100, + endingPrice: 100 + 0.0012, + formatNumberOrString: defaultFormatter, + }).formatted, + ).toBe('$0.0012') + }) + }) + + describe('values >= $0.0001 and < $0.001', () => { + it('formats positive values with 5 decimals', () => { + expect( + formatChartFiatDelta({ + startingPrice: 100, + endingPrice: 100 + 0.00083, + formatNumberOrString: defaultFormatter, + }).formatted, + ).toBe('$0.00083') + expect( + formatChartFiatDelta({ + startingPrice: 100, + endingPrice: 100 + 0.00022, + formatNumberOrString: defaultFormatter, + }).formatted, + ).toBe('$0.00022') + expect( + formatChartFiatDelta({ + startingPrice: 100, + endingPrice: 100 + 0.0001, + formatNumberOrString: defaultFormatter, + }).formatted, + ).toBe('$0.0001') + expect( + formatChartFiatDelta({ + startingPrice: 100, + endingPrice: 100 + 0.00099, + formatNumberOrString: defaultFormatter, + }).formatted, + ).toBe('$0.00099') + }) + + it('formats negative values with 5 decimals', () => { + expect( + formatChartFiatDelta({ + startingPrice: 100, + endingPrice: 100 + -0.00083, + formatNumberOrString: defaultFormatter, + }).formatted, + ).toBe('$0.00083') + expect( + formatChartFiatDelta({ + startingPrice: 100, + endingPrice: 100 + -0.00022, + formatNumberOrString: defaultFormatter, + }).formatted, + ).toBe('$0.00022') + expect( + formatChartFiatDelta({ + startingPrice: 100, + endingPrice: 100 + -0.0001, + formatNumberOrString: defaultFormatter, + }).formatted, + ).toBe('$0.0001') + expect( + formatChartFiatDelta({ + startingPrice: 100, + endingPrice: 100 + -0.00099, + formatNumberOrString: defaultFormatter, + }).formatted, + ).toBe('$0.00099') + }) + + it('trims trailing zeros but keeps minimum decimals', () => { + expect( + formatChartFiatDelta({ + startingPrice: 100, + endingPrice: 100 + 0.0001, + formatNumberOrString: defaultFormatter, + }).formatted, + ).toBe('$0.0001') + expect( + formatChartFiatDelta({ + startingPrice: 100, + endingPrice: 100 + 0.00012, + formatNumberOrString: defaultFormatter, + }).formatted, + ).toBe('$0.00012') + }) + }) + + describe('values >= $0.00001 and < $0.0001', () => { + it('formats positive values with 6 decimals', () => { + expect( + formatChartFiatDelta({ + startingPrice: 100, + endingPrice: 100 + 0.000019, + formatNumberOrString: defaultFormatter, + }).formatted, + ).toBe('$0.000019') + expect( + formatChartFiatDelta({ + startingPrice: 100, + endingPrice: 100 + 0.000094, + formatNumberOrString: defaultFormatter, + }).formatted, + ).toBe('$0.000094') + expect( + formatChartFiatDelta({ + startingPrice: 100, + endingPrice: 100 + 0.00001, + formatNumberOrString: defaultFormatter, + }).formatted, + ).toBe('$0.00001') + expect( + formatChartFiatDelta({ + startingPrice: 100, + endingPrice: 100 + 0.000099, + formatNumberOrString: defaultFormatter, + }).formatted, + ).toBe('$0.000099') + }) + + it('formats negative values with 6 decimals', () => { + expect( + formatChartFiatDelta({ + startingPrice: 100, + endingPrice: 100 + -0.000019, + formatNumberOrString: defaultFormatter, + }).formatted, + ).toBe('$0.000019') + expect( + formatChartFiatDelta({ + startingPrice: 100, + endingPrice: 100 + -0.000094, + formatNumberOrString: defaultFormatter, + }).formatted, + ).toBe('$0.000094') + expect( + formatChartFiatDelta({ + startingPrice: 100, + endingPrice: 100 + -0.00001, + formatNumberOrString: defaultFormatter, + }).formatted, + ).toBe('$0.00001') + expect( + formatChartFiatDelta({ + startingPrice: 100, + endingPrice: 100 + -0.000099, + formatNumberOrString: defaultFormatter, + }).formatted, + ).toBe('$0.000099') + }) + + it('trims trailing zeros but keeps minimum decimals', () => { + expect( + formatChartFiatDelta({ + startingPrice: 100, + endingPrice: 100 + 0.00001, + formatNumberOrString: defaultFormatter, + }).formatted, + ).toBe('$0.00001') + expect( + formatChartFiatDelta({ + startingPrice: 100, + endingPrice: 100 + 0.000012, + formatNumberOrString: defaultFormatter, + }).formatted, + ).toBe('$0.000012') + }) + }) + + describe('values < $0.000001', () => { + it('formats as threshold with exact value in tooltip', () => { + expect( + formatChartFiatDelta({ + startingPrice: 100, + endingPrice: 100 + 0.0000001, + formatNumberOrString: defaultFormatter, + }).formatted, + ).toBe('<$0.000001') + expect( + formatChartFiatDelta({ + startingPrice: 100, + endingPrice: 100 + 0.0000009, + formatNumberOrString: defaultFormatter, + }).formatted, + ).toBe('<$0.000001') + expect( + formatChartFiatDelta({ + startingPrice: 100, + endingPrice: 100 + -0.0000001, + formatNumberOrString: defaultFormatter, + }).formatted, + ).toBe('<$0.000001') + expect( + formatChartFiatDelta({ + startingPrice: 100, + endingPrice: 100 + -0.0000009, + formatNumberOrString: defaultFormatter, + }).formatted, + ).toBe('<$0.000001') + }) + }) + + describe('zero value', () => { + it('formats as $0.00', () => { + expect( + formatChartFiatDelta({ startingPrice: 100, endingPrice: 100 + 0, formatNumberOrString: defaultFormatter }) + .formatted, + ).toBe('$0.00') + }) + }) + }) + + describe('stablecoin formatting', () => { + describe('values >= $1', () => { + it('formats positive values with 2 decimals', () => { + expect( + formatChartFiatDelta({ + startingPrice: 100, + endingPrice: 100 + 1.25, + isStablecoin: true, + formatNumberOrString: defaultFormatter, + }).formatted, + ).toBe('$1.25') + expect( + formatChartFiatDelta({ + startingPrice: 100, + endingPrice: 100 + 2430.1, + isStablecoin: true, + formatNumberOrString: defaultFormatter, + }).formatted, + ).toBe('$2,430.10') + }) + + it('formats negative values with 2 decimals', () => { + expect( + formatChartFiatDelta({ + startingPrice: 100, + endingPrice: 100 + -1.25, + isStablecoin: true, + formatNumberOrString: defaultFormatter, + }).formatted, + ).toBe('$1.25') + expect( + formatChartFiatDelta({ + startingPrice: 100, + endingPrice: 100 + -2430.1, + isStablecoin: true, + formatNumberOrString: defaultFormatter, + }).formatted, + ).toBe('$2,430.10') + }) + }) + + describe('values >= $0.10 and < $1', () => { + it('formats positive values with 2 decimals', () => { + expect( + formatChartFiatDelta({ + startingPrice: 100, + endingPrice: 100 + 0.42, + isStablecoin: true, + formatNumberOrString: defaultFormatter, + }).formatted, + ).toBe('$0.42') + expect( + formatChartFiatDelta({ + startingPrice: 100, + endingPrice: 100 + 0.1, + isStablecoin: true, + formatNumberOrString: defaultFormatter, + }).formatted, + ).toBe('$0.10') + expect( + formatChartFiatDelta({ + startingPrice: 100, + endingPrice: 100 + 0.99, + isStablecoin: true, + formatNumberOrString: defaultFormatter, + }).formatted, + ).toBe('$0.99') + }) + + it('formats negative values with 2 decimals', () => { + expect( + formatChartFiatDelta({ + startingPrice: 100, + endingPrice: 100 + -0.42, + isStablecoin: true, + formatNumberOrString: defaultFormatter, + }).formatted, + ).toBe('$0.42') + expect( + formatChartFiatDelta({ + startingPrice: 100, + endingPrice: 100 + -0.1, + isStablecoin: true, + formatNumberOrString: defaultFormatter, + }).formatted, + ).toBe('$0.10') + expect( + formatChartFiatDelta({ + startingPrice: 100, + endingPrice: 100 + -0.99, + isStablecoin: true, + formatNumberOrString: defaultFormatter, + }).formatted, + ).toBe('$0.99') + }) + }) + + describe('values >= $0.01 and < $0.10', () => { + it('formats positive values with 2 decimals', () => { + expect( + formatChartFiatDelta({ + startingPrice: 100, + endingPrice: 100 + 0.07, + isStablecoin: true, + formatNumberOrString: defaultFormatter, + }).formatted, + ).toBe('$0.07') + expect( + formatChartFiatDelta({ + startingPrice: 100, + endingPrice: 100 + 0.01, + isStablecoin: true, + formatNumberOrString: defaultFormatter, + }).formatted, + ).toBe('$0.01') + expect( + formatChartFiatDelta({ + startingPrice: 100, + endingPrice: 100 + 0.099, + isStablecoin: true, + formatNumberOrString: defaultFormatter, + }).formatted, + ).toBe('$0.10') + }) + + it('formats negative values with 2 decimals', () => { + expect( + formatChartFiatDelta({ + startingPrice: 100, + endingPrice: 100 + -0.07, + isStablecoin: true, + formatNumberOrString: defaultFormatter, + }).formatted, + ).toBe('$0.07') + expect( + formatChartFiatDelta({ + startingPrice: 100, + endingPrice: 100 + -0.01, + isStablecoin: true, + formatNumberOrString: defaultFormatter, + }).formatted, + ).toBe('$0.01') + expect( + formatChartFiatDelta({ + startingPrice: 100, + endingPrice: 100 + -0.099, + isStablecoin: true, + formatNumberOrString: defaultFormatter, + }).formatted, + ).toBe('$0.10') + }) + }) + + describe('values >= $0.001 and < $0.01', () => { + it('formats positive values with 3 decimals', () => { + expect( + formatChartFiatDelta({ + startingPrice: 100, + endingPrice: 100 + 0.003, + isStablecoin: true, + formatNumberOrString: defaultFormatter, + }).formatted, + ).toBe('$0.003') + expect( + formatChartFiatDelta({ + startingPrice: 100, + endingPrice: 100 + 0.001, + isStablecoin: true, + formatNumberOrString: defaultFormatter, + }).formatted, + ).toBe('$0.001') + expect( + formatChartFiatDelta({ + startingPrice: 100, + endingPrice: 100 + 0.0099, + isStablecoin: true, + formatNumberOrString: defaultFormatter, + }).formatted, + ).toBe('$0.01') + }) + + it('formats negative values with 3 decimals', () => { + expect( + formatChartFiatDelta({ + startingPrice: 100, + endingPrice: 100 + -0.003, + isStablecoin: true, + formatNumberOrString: defaultFormatter, + }).formatted, + ).toBe('$0.003') + expect( + formatChartFiatDelta({ + startingPrice: 100, + endingPrice: 100 + -0.001, + isStablecoin: true, + formatNumberOrString: defaultFormatter, + }).formatted, + ).toBe('$0.001') + expect( + formatChartFiatDelta({ + startingPrice: 100, + endingPrice: 100 + -0.0099, + isStablecoin: true, + formatNumberOrString: defaultFormatter, + }).formatted, + ).toBe('$0.01') + }) + }) + + describe('values < $0.001', () => { + it('formats as $0.00', () => { + expect( + formatChartFiatDelta({ + startingPrice: 100, + endingPrice: 100 + 0.0001, + isStablecoin: true, + formatNumberOrString: defaultFormatter, + }).formatted, + ).toBe('$0.00') + expect( + formatChartFiatDelta({ + startingPrice: 100, + endingPrice: 100 + 0.0009, + isStablecoin: true, + formatNumberOrString: defaultFormatter, + }).formatted, + ).toBe('$0.00') + expect( + formatChartFiatDelta({ + startingPrice: 100, + endingPrice: 100 + -0.0001, + isStablecoin: true, + formatNumberOrString: defaultFormatter, + }).formatted, + ).toBe('$0.00') + expect( + formatChartFiatDelta({ + startingPrice: 100, + endingPrice: 100 + -0.0009, + isStablecoin: true, + formatNumberOrString: defaultFormatter, + }).formatted, + ).toBe('$0.00') + }) + }) + + describe('zero value', () => { + it('formats as $0.00', () => { + expect( + formatChartFiatDelta({ + startingPrice: 100, + endingPrice: 100 + 0, + isStablecoin: true, + formatNumberOrString: defaultFormatter, + }).formatted, + ).toBe('$0.00') + }) + }) + }) + + describe('formatFiatDelta with isStablecoin flag', () => { + it('uses stablecoin formatting when isStablecoin is true', () => { + expect( + formatChartFiatDelta({ + startingPrice: 100, + endingPrice: 100 + 0.003, + isStablecoin: true, + formatNumberOrString: defaultFormatter, + }).formatted, + ).toBe('$0.003') + expect( + formatChartFiatDelta({ + startingPrice: 100, + endingPrice: 100 + 0.0001, + isStablecoin: true, + formatNumberOrString: defaultFormatter, + }).formatted, + ).toBe('$0.00') + expect( + formatChartFiatDelta({ + startingPrice: 100, + endingPrice: 100 + 1.25, + isStablecoin: true, + formatNumberOrString: defaultFormatter, + }).formatted, + ).toBe('$1.25') + }) + + it('uses normal formatting when isStablecoin is false', () => { + expect( + formatChartFiatDelta({ + startingPrice: 100, + endingPrice: 100 + 0.003, + isStablecoin: false, + formatNumberOrString: defaultFormatter, + }).formatted, + ).toBe('$0.003') + expect( + formatChartFiatDelta({ + startingPrice: 100, + endingPrice: 100 + 0.0001, + isStablecoin: false, + formatNumberOrString: defaultFormatter, + }).formatted, + ).toBe('$0.0001') + expect( + formatChartFiatDelta({ + startingPrice: 100, + endingPrice: 100 + 1.25, + isStablecoin: false, + formatNumberOrString: defaultFormatter, + }).formatted, + ).toBe('$1.25') + }) + }) + + describe('currency support', () => { + it('formats EUR currency with proper symbol', () => { + expect( + formatChartFiatDelta({ + startingPrice: 100, + endingPrice: 100 + 100, + currency: FiatCurrency.Euro, + formatNumberOrString: defaultFormatter, + }).formatted, + ).toBe('EUR 100') + expect( + formatChartFiatDelta({ + startingPrice: 100, + endingPrice: 100 + -50.5, + currency: FiatCurrency.Euro, + formatNumberOrString: defaultFormatter, + }).formatted, + ).toBe('EUR 50.50') + }) + + it('formats GBP currency with proper symbol', () => { + expect( + formatChartFiatDelta({ + startingPrice: 100, + endingPrice: 100 + 100, + currency: FiatCurrency.BritishPound, + formatNumberOrString: defaultFormatter, + }).formatted, + ).toBe('£100') + expect( + formatChartFiatDelta({ + startingPrice: 100, + endingPrice: 100 + -50.5, + currency: FiatCurrency.BritishPound, + formatNumberOrString: defaultFormatter, + }).formatted, + ).toBe('£50.50') + }) + + it('formats JPY currency with proper symbol', () => { + expect( + formatChartFiatDelta({ + startingPrice: 100, + endingPrice: 100 + 100, + currency: FiatCurrency.JapaneseYen, + formatNumberOrString: defaultFormatter, + }).formatted, + ).toBe('¥100') + expect( + formatChartFiatDelta({ + startingPrice: 100, + endingPrice: 100 + -50.5, + currency: FiatCurrency.JapaneseYen, + formatNumberOrString: defaultFormatter, + }).formatted, + ).toBe('¥50.50') + }) + }) + + describe('edge cases', () => { + it('handles trimming edge cases that previously failed', () => { + // Test case 1: Value that trims to whole number (previously would fail regex) + // 1.000 with 4 decimals should trim to "1", not break + expect( + formatChartFiatDelta({ + startingPrice: 100, + endingPrice: 101.0, + formatNumberOrString: defaultFormatter, + }).formatted, + ).toBe('$1') + + // Test case 2: Value with all trailing zeros after rounding + // 0.0010 with 4 decimals should trim to "0.001" + expect( + formatChartFiatDelta({ + startingPrice: 100, + endingPrice: 100.001, + formatNumberOrString: defaultFormatter, + }).formatted, + ).toBe('$0.001') + + // Test case 3: Value in the 3-decimal range that has trailing zeros + // 0.0500 should trim to "0.05" (in the 3-decimal range >= 0.01 and < 0.10) + expect( + formatChartFiatDelta({ + startingPrice: 100, + endingPrice: 100.05, + formatNumberOrString: defaultFormatter, + }).formatted, + ).toBe('$0.05') + + // Test case 4: Very small value that needs all its decimals preserved + // 0.00012 should keep all significant digits + expect( + formatChartFiatDelta({ + startingPrice: 1.0, + endingPrice: 1.00012, + formatNumberOrString: defaultFormatter, + }).formatted, + ).toBe('$0.00012') + + // Test case 5: Value that would have broken the old regex when decimal point is removed + // 0.001000 with 4 decimals trims to "0.001", old logic would try to match non-existent decimal + expect( + formatChartFiatDelta({ + startingPrice: 100, + endingPrice: 100.001, + formatNumberOrString: defaultFormatter, + }).formatted, + ).toBe('$0.001') + }) + + it('handles rounding correctly', () => { + expect( + formatChartFiatDelta({ startingPrice: 100, endingPrice: 100 + 0.0999, formatNumberOrString: defaultFormatter }) + .formatted, + ).toBe('$0.1') + expect( + formatChartFiatDelta({ startingPrice: 100, endingPrice: 100 + 0.00999, formatNumberOrString: defaultFormatter }) + .formatted, + ).toBe('$0.01') + expect( + formatChartFiatDelta({ startingPrice: 100, endingPrice: 100 + 0.0994, formatNumberOrString: defaultFormatter }) + .formatted, + ).toBe('$0.099') + }) + + it('handles very small negative values correctly', () => { + expect( + formatChartFiatDelta({ + startingPrice: 100, + endingPrice: 100 + -0.0000001, + formatNumberOrString: defaultFormatter, + }).formatted, + ).toBe('<$0.000001') + expect( + formatChartFiatDelta({ + startingPrice: 100, + endingPrice: 100 + -0.000001, + formatNumberOrString: defaultFormatter, + }).formatted, + ).toBe('<$0.000001') + }) + + it('handles values exactly at thresholds', () => { + expect( + formatChartFiatDelta({ startingPrice: 100, endingPrice: 100 + 1.0, formatNumberOrString: defaultFormatter }) + .formatted, + ).toBe('$1') + expect( + formatChartFiatDelta({ startingPrice: 100, endingPrice: 100 + 0.1, formatNumberOrString: defaultFormatter }) + .formatted, + ).toBe('$0.10') + expect( + formatChartFiatDelta({ startingPrice: 100, endingPrice: 100 + 0.01, formatNumberOrString: defaultFormatter }) + .formatted, + ).toBe('$0.01') + expect( + formatChartFiatDelta({ startingPrice: 100, endingPrice: 100 + 0.001, formatNumberOrString: defaultFormatter }) + .formatted, + ).toBe('$0.001') + expect( + formatChartFiatDelta({ startingPrice: 100, endingPrice: 100 + 0.0001, formatNumberOrString: defaultFormatter }) + .formatted, + ).toBe('$0.0001') + expect( + formatChartFiatDelta({ startingPrice: 100, endingPrice: 100 + 0.00001, formatNumberOrString: defaultFormatter }) + .formatted, + ).toBe('$0.00001') + expect( + formatChartFiatDelta({ + startingPrice: 100, + endingPrice: 100 + 0.000001, + formatNumberOrString: defaultFormatter, + }).formatted, + ).toBe('<$0.000001') + }) + }) + describe('delta calculation', () => { + it('calculates and formats positive delta for normal crypto', () => { + const result = formatChartFiatDelta({ + startingPrice: 100, + endingPrice: 103.53, + isStablecoin: false, + formatNumberOrString: defaultFormatter, + }) + expect(result.formatted).toBe('$3.53') + expect(result.rawDelta).toBeCloseTo(3.53, 5) + expect(result.belowThreshold).toBeUndefined() + }) + + it('correctly formats DEGEN-like small delta values', () => { + // Test the specific DEGEN case: $0.00370 - $0.00338 = $0.00032 + const result = formatChartFiatDelta({ + startingPrice: 0.00338, + endingPrice: 0.0037, + isStablecoin: false, + formatNumberOrString: defaultFormatter, + }) + expect(result.formatted).toBe('$0.00032') + expect(result.rawDelta).toBeCloseTo(0.00032, 10) + expect(result.belowThreshold).toBeUndefined() + }) + + it('calculates and formats positive delta for stablecoin', () => { + const result = formatChartFiatDelta({ + startingPrice: 1.0, + endingPrice: 1.003, + isStablecoin: true, + formatNumberOrString: defaultFormatter, + }) + expect(result.formatted).toBe('$0.003') + expect(result.rawDelta).toBeCloseTo(0.003, 5) + expect(result.belowThreshold).toBeUndefined() + }) + + it('calculates and formats negative delta for stablecoin', () => { + const result = formatChartFiatDelta({ + startingPrice: 1.0, + endingPrice: 0.997, + isStablecoin: true, + formatNumberOrString: defaultFormatter, + }) + expect(result.formatted).toBe('$0.003') + expect(result.rawDelta).toBeCloseTo(-0.003, 5) + expect(result.belowThreshold).toBeUndefined() + }) + + it('handles very small deltas for stablecoin', () => { + const result = formatChartFiatDelta({ + startingPrice: 1.0, + endingPrice: 1.0001, + isStablecoin: true, + formatNumberOrString: defaultFormatter, + }) + expect(result.formatted).toBe('$0.00') + expect(result.rawDelta).toBeCloseTo(0.0001, 5) + expect(result.belowThreshold).toBeUndefined() + }) + + it('handles zero delta', () => { + const result = formatChartFiatDelta({ + startingPrice: 100, + endingPrice: 100, + isStablecoin: false, + formatNumberOrString: defaultFormatter, + }) + expect(result.formatted).toBe('$0.00') + expect(result.rawDelta).toBe(0) + expect(result.belowThreshold).toBeUndefined() + }) + + it('uses custom currency', () => { + const result = formatChartFiatDelta({ + startingPrice: 100, + endingPrice: 150, + currency: FiatCurrency.Euro, + formatNumberOrString: defaultFormatter, + }) + expect(result.formatted).toBe('EUR 50') + expect(result.rawDelta).toBe(50) + expect(result.belowThreshold).toBeUndefined() + }) + + it('handles below threshold values for normal crypto', () => { + const result = formatChartFiatDelta({ + startingPrice: 1.0, + endingPrice: 1.0000005, + isStablecoin: false, + formatNumberOrString: defaultFormatter, + }) + expect(result.formatted).toBe('<$0.000001') + expect(result.rawDelta).toBeCloseTo(0.0000005, 10) + expect(result.belowThreshold).toBe(true) + }) + + it('handles multiple currencies', () => { + const gbpResult = formatChartFiatDelta({ + startingPrice: 100, + endingPrice: 175.25, + currency: FiatCurrency.BritishPound, + formatNumberOrString: defaultFormatter, + }) + expect(gbpResult.formatted).toBe('£75.25') + expect(gbpResult.rawDelta).toBe(75.25) + + const jpyResult = formatChartFiatDelta({ + startingPrice: 10000, + endingPrice: 12500, + currency: FiatCurrency.JapaneseYen, + formatNumberOrString: defaultFormatter, + }) + expect(jpyResult.formatted).toBe('¥2,500') + expect(jpyResult.rawDelta).toBe(2500) + + const inrResult = formatChartFiatDelta({ + startingPrice: 5000, + endingPrice: 7500.5, + currency: FiatCurrency.IndianRupee, + formatNumberOrString: defaultFormatter, + }) + expect(inrResult.formatted).toBe('₹2,500.50') + expect(inrResult.rawDelta).toBe(2500.5) + }) + + it('correctly sets belowThreshold flag', () => { + // Should set belowThreshold for non-stablecoins + const cryptoResult = formatChartFiatDelta({ + startingPrice: 1.0, + endingPrice: 1.0000003, + isStablecoin: false, + formatNumberOrString: defaultFormatter, + }) + expect(cryptoResult.belowThreshold).toBe(true) + + // Should NOT set belowThreshold for stablecoins + const stableResult = formatChartFiatDelta({ + startingPrice: 1.0, + endingPrice: 1.0000003, + isStablecoin: true, + formatNumberOrString: defaultFormatter, + }) + expect(stableResult.belowThreshold).toBeUndefined() + + // Should NOT set belowThreshold when value is above threshold + const aboveResult = formatChartFiatDelta({ + startingPrice: 1.0, + endingPrice: 1.001, + isStablecoin: false, + formatNumberOrString: defaultFormatter, + }) + expect(aboveResult.belowThreshold).toBeUndefined() + }) + }) +}) diff --git a/packages/uniswap/src/features/fiatCurrency/priceChart/priceChartConversion.ts b/packages/uniswap/src/features/fiatCurrency/priceChart/priceChartConversion.ts new file mode 100644 index 00000000000..91e80a8d9ee --- /dev/null +++ b/packages/uniswap/src/features/fiatCurrency/priceChart/priceChartConversion.ts @@ -0,0 +1,42 @@ +import { FiatCurrency } from 'uniswap/src/features/fiatCurrency/constants' +import type { + FiatDeltaFormatOptions, + FormattedFiatDelta, +} from 'uniswap/src/features/fiatCurrency/priceChart/formatters/shared/types' +import { createStablecoinFormatter } from 'uniswap/src/features/fiatCurrency/priceChart/formatters/stablecoinFormatter' +import { createStandardFormatter } from 'uniswap/src/features/fiatCurrency/priceChart/formatters/standardFormatter' + +/** + * Utility for formatting fiat currency delta values in price charts. + * + * This module provides specialized formatting for price change amounts with: + * - Dynamic decimal precision based on value magnitude (2-6 decimal places) + * - Threshold formatting for very small values (<$0.000001) + * - Intelligent trailing zero trimming while preserving minimum decimals + * - Support for multiple fiat currencies with proper symbol extraction + * - Special handling for stablecoins (simplified precision rules) + */ +export function formatChartFiatDelta({ + startingPrice, + endingPrice, + isStablecoin = false, + currency = FiatCurrency.UnitedStatesDollar, + formatNumberOrString, +}: FiatDeltaFormatOptions): FormattedFiatDelta { + const formatter = isStablecoin ? createStablecoinFormatter() : createStandardFormatter() + const rawDelta = endingPrice - startingPrice + + const formatted = formatter.format({ + value: rawDelta, + currency, + formatNumberOrString, + }) + + const belowThreshold = formatter.shouldShowBelowThreshold(Math.abs(rawDelta)) + + return { + formatted, + rawDelta, + ...(belowThreshold && { belowThreshold: true }), + } +} diff --git a/packages/uniswap/src/features/forceUpgrade/hooks/useForceUpgradeStatus.ts b/packages/uniswap/src/features/forceUpgrade/hooks/useForceUpgradeStatus.ts index 246af09288d..b573fee07df 100644 --- a/packages/uniswap/src/features/forceUpgrade/hooks/useForceUpgradeStatus.ts +++ b/packages/uniswap/src/features/forceUpgrade/hooks/useForceUpgradeStatus.ts @@ -1,5 +1,4 @@ -import { DynamicConfigs, ForceUpgradeConfigKey, ForceUpgradeStatus } from 'uniswap/src/features/gating/configs' -import { useDynamicConfigValue } from 'uniswap/src/features/gating/hooks' +import { DynamicConfigs, ForceUpgradeConfigKey, ForceUpgradeStatus, useDynamicConfigValue } from '@universe/gating' export function useForceUpgradeStatus(): ForceUpgradeStatus { return useDynamicConfigValue({ diff --git a/packages/uniswap/src/features/forceUpgrade/hooks/useForceUpgradeTranslations.ts b/packages/uniswap/src/features/forceUpgrade/hooks/useForceUpgradeTranslations.ts index 5eb16d3693e..63357d0ef22 100644 --- a/packages/uniswap/src/features/forceUpgrade/hooks/useForceUpgradeTranslations.ts +++ b/packages/uniswap/src/features/forceUpgrade/hooks/useForceUpgradeTranslations.ts @@ -1,5 +1,9 @@ -import { DynamicConfigs, ForceUpgradeConfigKey, ForceUpgradeTranslations } from 'uniswap/src/features/gating/configs' -import { useDynamicConfigValue } from 'uniswap/src/features/gating/hooks' +import { + DynamicConfigs, + ForceUpgradeConfigKey, + ForceUpgradeTranslations, + useDynamicConfigValue, +} from '@universe/gating' export function useForceUpgradeTranslations(): ForceUpgradeTranslations { return useDynamicConfigValue({ diff --git a/packages/uniswap/src/features/gas/hooks.ts b/packages/uniswap/src/features/gas/hooks.ts index 96ae3a1cd70..cab1383e613 100644 --- a/packages/uniswap/src/features/gas/hooks.ts +++ b/packages/uniswap/src/features/gas/hooks.ts @@ -1,5 +1,6 @@ import { Currency, CurrencyAmount } from '@uniswap/sdk-core' import { GasStrategy } from '@universe/api' +import { GasStrategyType, useStatsigClientStatus } from '@universe/gating' import { BigNumber, providers } from 'ethers/lib/ethers' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' @@ -12,8 +13,6 @@ import { useEnabledChains } from 'uniswap/src/features/chains/hooks/useEnabledCh import { UniverseChainId } from 'uniswap/src/features/chains/types' import { FormattedUniswapXGasFeeInfo, GasFeeResult } from 'uniswap/src/features/gas/types' import { getActiveGasStrategy, hasSufficientFundsIncludingGas } from 'uniswap/src/features/gas/utils' -import { GasStrategyType } from 'uniswap/src/features/gating/configs' -import { useStatsigClientStatus } from 'uniswap/src/features/gating/hooks' import { useLocalizationContext } from 'uniswap/src/features/language/LocalizationContext' import { useOnChainNativeCurrencyBalance } from 'uniswap/src/features/portfolio/api' import { getCurrencyAmount, ValueType } from 'uniswap/src/features/tokens/getCurrencyAmount' diff --git a/packages/uniswap/src/features/gas/hooks/useMaxAmountSpend.test.ts b/packages/uniswap/src/features/gas/hooks/useMaxAmountSpend.test.ts index 5019f99318b..e1717ddd237 100644 --- a/packages/uniswap/src/features/gas/hooks/useMaxAmountSpend.test.ts +++ b/packages/uniswap/src/features/gas/hooks/useMaxAmountSpend.test.ts @@ -8,8 +8,9 @@ import { MAINNET_CURRENCY } from 'uniswap/src/test/fixtures/wallet/currencies' const mockUseDynamicConfigValue = jest.fn() -jest.mock('uniswap/src/features/gating/hooks', () => { +jest.mock('@universe/gating', () => { return { + ...jest.requireActual('@universe/gating'), useDynamicConfigValue: (params: { config: unknown; key: unknown; defaultValue: unknown }): unknown => mockUseDynamicConfigValue(params), } diff --git a/packages/uniswap/src/features/gas/hooks/useMaxAmountSpend.ts b/packages/uniswap/src/features/gas/hooks/useMaxAmountSpend.ts index 7d223059153..30a1e1ceb77 100644 --- a/packages/uniswap/src/features/gas/hooks/useMaxAmountSpend.ts +++ b/packages/uniswap/src/features/gas/hooks/useMaxAmountSpend.ts @@ -1,10 +1,9 @@ import { Currency, CurrencyAmount } from '@uniswap/sdk-core' +import { DynamicConfigs, SwapConfigKey, useDynamicConfigValue } from '@universe/gating' import JSBI from 'jsbi' import { getChainInfo } from 'uniswap/src/features/chains/chainInfo' import { GENERIC_L2_GAS_CONFIG } from 'uniswap/src/features/chains/gasDefaults' import { UniverseChainId } from 'uniswap/src/features/chains/types' -import { DynamicConfigs, SwapConfigKey } from 'uniswap/src/features/gating/configs' -import { useDynamicConfigValue } from 'uniswap/src/features/gating/hooks' import { getCurrencyAmount, ValueType } from 'uniswap/src/features/tokens/getCurrencyAmount' import { TransactionType } from 'uniswap/src/features/transactions/types/transactionDetails' diff --git a/packages/uniswap/src/features/gas/utils.ts b/packages/uniswap/src/features/gas/utils.ts index f8862b530fa..9c1e2460928 100644 --- a/packages/uniswap/src/features/gas/utils.ts +++ b/packages/uniswap/src/features/gas/utils.ts @@ -1,14 +1,14 @@ import { Currency, CurrencyAmount } from '@uniswap/sdk-core' import { GasEstimate, GasStrategy } from '@universe/api' -import JSBI from 'jsbi' -import { areEqualGasStrategies } from 'uniswap/src/features/gas/types' import { DynamicConfigs, GasStrategies, GasStrategyType, GasStrategyWithConditions, -} from 'uniswap/src/features/gating/configs' -import { getStatsigClient } from 'uniswap/src/features/gating/sdk/statsig' + getStatsigClient, +} from '@universe/gating' +import JSBI from 'jsbi' +import { areEqualGasStrategies } from 'uniswap/src/features/gas/types' import { getCurrencyAmount, ValueType } from 'uniswap/src/features/tokens/getCurrencyAmount' // The default "Urgent" strategy that was previously hardcoded in the gas service diff --git a/packages/uniswap/src/features/gating/StatsigProviderWrapper.tsx b/packages/uniswap/src/features/gating/StatsigProviderWrapper.tsx index 86c703873e0..f73bb72db2a 100644 --- a/packages/uniswap/src/features/gating/StatsigProviderWrapper.tsx +++ b/packages/uniswap/src/features/gating/StatsigProviderWrapper.tsx @@ -1,12 +1,6 @@ +import { StatsigOptions, StatsigProvider, StatsigUser, StorageProvider, useClientAsyncInit } from '@universe/gating' import { ReactNode, useEffect } from 'react' import { config } from 'uniswap/src/config' -import { - StatsigOptions, - StatsigProvider, - StatsigUser, - StorageProvider, - useClientAsyncInit, -} from 'uniswap/src/features/gating/sdk/statsig' import { statsigBaseConfig } from 'uniswap/src/features/gating/statsigBaseConfig' import { logger } from 'utilities/src/logger/logger' diff --git a/packages/uniswap/src/features/gating/statsigBaseConfig.ts b/packages/uniswap/src/features/gating/statsigBaseConfig.ts index 6ea6d023740..dbb1f13d574 100644 --- a/packages/uniswap/src/features/gating/statsigBaseConfig.ts +++ b/packages/uniswap/src/features/gating/statsigBaseConfig.ts @@ -1,6 +1,5 @@ +import { getOverrideAdapter, getStatsigEnvName, StatsigOptions } from '@universe/gating' import { uniswapUrls } from 'uniswap/src/constants/urls' -import { getStatsigEnvName } from 'uniswap/src/features/gating/getStatsigEnvName' -import { getOverrideAdapter, StatsigOptions } from 'uniswap/src/features/gating/sdk/statsig' export const statsigBaseConfig: StatsigOptions = { networkConfig: { api: uniswapUrls.statsigProxyUrl }, diff --git a/packages/uniswap/src/features/gating/typeGuards.ts b/packages/uniswap/src/features/gating/typeGuards.ts index 4f98aa75596..058c3fb0549 100644 --- a/packages/uniswap/src/features/gating/typeGuards.ts +++ b/packages/uniswap/src/features/gating/typeGuards.ts @@ -1,6 +1,6 @@ import { GraphQLApi } from '@universe/api' +import { UwULinkAllowlist } from '@universe/gating' import { UniverseChainId } from 'uniswap/src/features/chains/types' -import { UwULinkAllowlist } from 'uniswap/src/features/gating/configs' export const isUwULinkAllowlistType = (x: unknown): x is UwULinkAllowlist => { const hasFields = diff --git a/packages/uniswap/src/features/language/hooks.tsx b/packages/uniswap/src/features/language/hooks.tsx index 1c550e5213d..5e76bb6999f 100644 --- a/packages/uniswap/src/features/language/hooks.tsx +++ b/packages/uniswap/src/features/language/hooks.tsx @@ -1,9 +1,9 @@ +import { ForceUpgradeTranslations } from '@universe/gating' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' import { useSelector } from 'react-redux' import { AppTFunction } from 'ui/src/i18n/types' import { useUrlContext } from 'uniswap/src/contexts/UrlContext' -import { ForceUpgradeTranslations } from 'uniswap/src/features/gating/configs' import { Language, Locale, diff --git a/packages/uniswap/src/features/nfts/hooks/useNftContextMenuItems.tsx b/packages/uniswap/src/features/nfts/hooks/useNftContextMenuItems.tsx index afebb23bf45..99c9236debd 100644 --- a/packages/uniswap/src/features/nfts/hooks/useNftContextMenuItems.tsx +++ b/packages/uniswap/src/features/nfts/hooks/useNftContextMenuItems.tsx @@ -1,4 +1,5 @@ import { TokenReportEventType } from '@universe/api' +import { FeatureFlags, useFeatureFlag } from '@universe/gating' import { useCallback, useMemo } from 'react' import { useTranslation } from 'react-i18next' import { useDispatch, useSelector } from 'react-redux' @@ -14,8 +15,6 @@ import { useEnabledChains } from 'uniswap/src/features/chains/hooks/useEnabledCh import { useBlockExplorerLogo } from 'uniswap/src/features/chains/logos' import { type UniverseChainId } from 'uniswap/src/features/chains/types' import { getChainExplorerName } from 'uniswap/src/features/chains/utils' -import { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' import { useNavigateToNftExplorerLink } from 'uniswap/src/features/nfts/hooks/useNavigateToNftExplorerLink' import { getIsNftHidden, getNFTAssetKey } from 'uniswap/src/features/nfts/utils' import { pushNotification } from 'uniswap/src/features/notifications/slice/slice' diff --git a/packages/uniswap/src/features/passkey/hooks/useEmbeddedWalletBaseUrl.ts b/packages/uniswap/src/features/passkey/hooks/useEmbeddedWalletBaseUrl.ts index 116f82ffc2b..36eeab329b6 100644 --- a/packages/uniswap/src/features/passkey/hooks/useEmbeddedWalletBaseUrl.ts +++ b/packages/uniswap/src/features/passkey/hooks/useEmbeddedWalletBaseUrl.ts @@ -1,6 +1,5 @@ +import { DynamicConfigs, EmbeddedWalletConfigKey, useDynamicConfigValue } from '@universe/gating' import { UNISWAP_WEB_URL } from 'uniswap/src/constants/urls' -import { DynamicConfigs, EmbeddedWalletConfigKey } from 'uniswap/src/features/gating/configs' -import { useDynamicConfigValue } from 'uniswap/src/features/gating/hooks' export function useEmbeddedWalletBaseUrl(): string { const baseUrl = useDynamicConfigValue({ diff --git a/packages/uniswap/src/features/portfolio/PortfolioBalance/PortfolioBalance.tsx b/packages/uniswap/src/features/portfolio/PortfolioBalance/PortfolioBalance.tsx index 48a9aeccb6b..d5327f5640a 100644 --- a/packages/uniswap/src/features/portfolio/PortfolioBalance/PortfolioBalance.tsx +++ b/packages/uniswap/src/features/portfolio/PortfolioBalance/PortfolioBalance.tsx @@ -17,9 +17,13 @@ import { isWebPlatform } from 'utilities/src/platform' interface PortfolioBalanceProps { owner: Address + endText?: JSX.Element | string } -export const PortfolioBalance = memo(function _PortfolioBalance({ owner }: PortfolioBalanceProps): JSX.Element { +export const PortfolioBalance = memo(function _PortfolioBalance({ + owner, + endText, +}: PortfolioBalanceProps): JSX.Element { const { data, loading, networkStatus, refetch } = usePortfolioTotalValue({ evmAddress: owner, // TransactionHistoryUpdater will refetch this query on new transaction. @@ -47,7 +51,7 @@ export const PortfolioBalance = memo(function _PortfolioBalance({ owner }: Portf const shouldFadePortfolioDecimals = (currency === FiatCurrency.UnitedStatesDollar || currency === FiatCurrency.Euro) && currencyComponents.symbolAtFront - const EndElement = useMemo(() => { + const RefreshButton = useMemo(() => { if (isWebPlatform) { return } @@ -65,19 +69,22 @@ export const PortfolioBalance = memo(function _PortfolioBalance({ owner }: Portf value={totalBalance} warmLoading={isWarmLoading} isRightToLeft={isRightToLeft} - EndElement={EndElement} + EndElement={RefreshButton} /> - - - + + + + + {endText} + ) }) diff --git a/packages/uniswap/src/features/portfolio/api.ts b/packages/uniswap/src/features/portfolio/api.ts index 8bbe8b0e570..910338e10cc 100644 --- a/packages/uniswap/src/features/portfolio/api.ts +++ b/packages/uniswap/src/features/portfolio/api.ts @@ -2,6 +2,7 @@ import { PublicKey } from '@solana/web3.js' import { skipToken, useQuery } from '@tanstack/react-query' import { Currency, CurrencyAmount, NativeCurrency as NativeCurrencyClass } from '@uniswap/sdk-core' import { SharedQueryClient } from '@universe/api' +import { DynamicConfigs, getDynamicConfigValue, SyncTransactionSubmissionChainIdsConfigKey } from '@universe/gating' import { Contract } from 'ethers/lib/ethers' import { useMemo } from 'react' import ERC20_ABI from 'uniswap/src/abis/erc20.json' @@ -14,8 +15,6 @@ import { import { getChainInfo } from 'uniswap/src/features/chains/chainInfo' import { UniverseChainId } from 'uniswap/src/features/chains/types' import { getPollingIntervalByBlocktime } from 'uniswap/src/features/chains/utils' -import { DynamicConfigs, SyncTransactionSubmissionChainIdsConfigKey } from 'uniswap/src/features/gating/configs' -import { getDynamicConfigValue } from 'uniswap/src/features/gating/hooks' import { Platform } from 'uniswap/src/features/platforms/types/Platform' import { chainIdToPlatform } from 'uniswap/src/features/platforms/utils/chains' import { createEthersProvider } from 'uniswap/src/features/providers/createEthersProvider' diff --git a/packages/uniswap/src/features/portfolio/portfolioUpdates/isInstantTokenBalanceUpdateEnabled.ts b/packages/uniswap/src/features/portfolio/portfolioUpdates/isInstantTokenBalanceUpdateEnabled.ts index f35ff5a208c..12424682f7f 100644 --- a/packages/uniswap/src/features/portfolio/portfolioUpdates/isInstantTokenBalanceUpdateEnabled.ts +++ b/packages/uniswap/src/features/portfolio/portfolioUpdates/isInstantTokenBalanceUpdateEnabled.ts @@ -1,5 +1,4 @@ -import { FeatureFlags, getFeatureFlagName } from 'uniswap/src/features/gating/flags' -import { getStatsigClient } from 'uniswap/src/features/gating/sdk/statsig' +import { FeatureFlags, getFeatureFlagName, getStatsigClient } from '@universe/gating' export function isInstantTokenBalanceUpdateEnabled(): boolean { return getStatsigClient().checkGate(getFeatureFlagName(FeatureFlags.InstantTokenBalanceUpdate)) diff --git a/packages/uniswap/src/features/portfolio/portfolioUpdates/rest/fetchOnChainBalancesRest.test.ts b/packages/uniswap/src/features/portfolio/portfolioUpdates/rest/fetchOnChainBalancesRest.test.ts index 40395fdc848..9054ae0f4e6 100644 --- a/packages/uniswap/src/features/portfolio/portfolioUpdates/rest/fetchOnChainBalancesRest.test.ts +++ b/packages/uniswap/src/features/portfolio/portfolioUpdates/rest/fetchOnChainBalancesRest.test.ts @@ -1,6 +1,6 @@ -import { ApolloClient, NormalizedCacheObject } from '@apollo/client' import { GetPortfolioResponse } from '@uniswap/client-data-api/dist/data/v1/api_pb.d' -import { GraphQLApi } from '@universe/api' +import { type Token as SearchToken } from '@uniswap/client-search/dist/search/v1/api_pb' +import * as searchTokensAndPools from 'uniswap/src/data/rest/searchTokensAndPools' import { UniverseChainId } from 'uniswap/src/features/chains/types' import { fetchOnChainCurrencyBalance } from 'uniswap/src/features/portfolio/api' import { fetchOnChainBalancesRest } from 'uniswap/src/features/portfolio/portfolioUpdates/rest/fetchOnChainBalancesRest' @@ -33,10 +33,19 @@ jest.mock('uniswap/src/features/portfolio/api', () => ({ fetchOnChainCurrencyBalance: jest.fn(), })) +jest.mock('uniswap/src/data/rest/searchTokensAndPools', () => ({ + ...jest.requireActual('uniswap/src/data/rest/searchTokensAndPools'), + fetchTokenByAddress: jest.fn(), +})) + const mockGetOnChainBalancesFetch = fetchOnChainCurrencyBalance as jest.MockedFunction< typeof fetchOnChainCurrencyBalance > +const mockFetchTokenByAddress = searchTokensAndPools.fetchTokenByAddress as jest.MockedFunction< + typeof searchTokensAndPools.fetchTokenByAddress +> + const TEST_ACCOUNT = '0x1234567890123456789012345678901234567890' const TEST_TOKEN_ADDRESS = '0xabcdef0123456789abcdef0123456789abcdef01' const TEST_CHAIN_ID = UniverseChainId.Mainnet @@ -83,10 +92,6 @@ const mockCachedPortfolio = { } as NonNullable describe('fetchOnChainBalancesRest', () => { - const mockApolloClient = { - query: jest.fn(), - } as unknown as ApolloClient - beforeEach(() => { jest.clearAllMocks() }) @@ -100,7 +105,6 @@ describe('fetchOnChainBalancesRest', () => { }) const result = await fetchOnChainBalancesRest({ - apolloClient: mockApolloClient, cachedPortfolio: mockCachedPortfolio, accountAddress: TEST_ACCOUNT, currencyIds: new Set([currencyId]), @@ -149,7 +153,6 @@ describe('fetchOnChainBalancesRest', () => { } as NonNullable const result = await fetchOnChainBalancesRest({ - apolloClient: mockApolloClient, cachedPortfolio: mockCachedPortfolioWithNative, accountAddress: TEST_ACCOUNT, currencyIds: new Set([currencyId]), @@ -172,7 +175,6 @@ describe('fetchOnChainBalancesRest', () => { const invalidCurrencyId = 'invalid-currency-id' const result = await fetchOnChainBalancesRest({ - apolloClient: mockApolloClient, cachedPortfolio: mockCachedPortfolio, accountAddress: TEST_ACCOUNT, currencyIds: new Set([invalidCurrencyId]), @@ -190,32 +192,28 @@ describe('fetchOnChainBalancesRest', () => { balance: mockBalance, }) - // Mock GraphQL query for new token - ;(mockApolloClient.query as jest.Mock).mockResolvedValueOnce({ - data: { - token: { - ...mockToken, - address: MOCK_TOKEN_ADDRESS_2, - symbol: 'NEW', - name: 'New Token', - }, - }, - }) + // Mock REST search for new token + mockFetchTokenByAddress.mockResolvedValueOnce({ + chainId: TEST_CHAIN_ID, + address: MOCK_TOKEN_ADDRESS_2, + symbol: 'NEW', + name: 'New Token', + decimals: 18, + logoUrl: '', + feeData: undefined, + safetyLevel: 0, + protectionInfo: undefined, + } as unknown as SearchToken) const result = await fetchOnChainBalancesRest({ - apolloClient: mockApolloClient, cachedPortfolio: mockCachedPortfolio, // doesn't contain new token accountAddress: TEST_ACCOUNT, currencyIds: new Set([currencyId]), }) - expect(mockApolloClient.query).toHaveBeenCalledWith({ - query: GraphQLApi.TokenDocument, - variables: { - chain: 'ETHEREUM', - address: MOCK_TOKEN_ADDRESS_2, - }, - fetchPolicy: 'cache-first', + expect(mockFetchTokenByAddress).toHaveBeenCalledWith({ + chainId: TEST_CHAIN_ID, + address: MOCK_TOKEN_ADDRESS_2, }) const balanceInfo = result.get(currencyId) @@ -225,7 +223,7 @@ describe('fetchOnChainBalancesRest', () => { expect(balanceInfo?.token?.symbol).toBe('NEW') }) - it('skips tokens when GraphQL query fails', async () => { + it('skips tokens when REST token search fails', async () => { const currencyId = buildCurrencyId(TEST_CHAIN_ID, MOCK_TOKEN_ADDRESS_3) const mockBalance = MOCK_BALANCE_1_ETH @@ -233,13 +231,10 @@ describe('fetchOnChainBalancesRest', () => { balance: mockBalance, }) - // Mock GraphQL query to return null token - ;(mockApolloClient.query as jest.Mock).mockResolvedValueOnce({ - data: { token: null }, - }) + // Mock REST search to return null (token not found) + mockFetchTokenByAddress.mockResolvedValueOnce(null) const result = await fetchOnChainBalancesRest({ - apolloClient: mockApolloClient, cachedPortfolio: mockCachedPortfolio, accountAddress: TEST_ACCOUNT, currencyIds: new Set([currencyId]), @@ -284,7 +279,6 @@ describe('fetchOnChainBalancesRest', () => { .mockResolvedValueOnce({ balance: MOCK_BALANCE_2_ETH }) const result = await fetchOnChainBalancesRest({ - apolloClient: mockApolloClient, cachedPortfolio: mockCachedPortfolioMultiple, accountAddress: TEST_ACCOUNT, currencyIds: new Set([currencyId1, currencyId2]), @@ -310,7 +304,6 @@ describe('fetchOnChainBalancesRest', () => { .mockRejectedValueOnce(new Error('Network error')) const result = await fetchOnChainBalancesRest({ - apolloClient: mockApolloClient, cachedPortfolio: mockCachedPortfolio, accountAddress: TEST_ACCOUNT, currencyIds: new Set([currencyId1, currencyId2]), @@ -332,7 +325,6 @@ describe('fetchOnChainBalancesRest', () => { // Cached portfolio has 1 token worth $100 const result = await fetchOnChainBalancesRest({ - apolloClient: mockApolloClient, cachedPortfolio: mockCachedPortfolio, accountAddress: TEST_ACCOUNT, currencyIds: new Set([currencyId]), diff --git a/packages/uniswap/src/features/portfolio/portfolioUpdates/rest/fetchOnChainBalancesRest.ts b/packages/uniswap/src/features/portfolio/portfolioUpdates/rest/fetchOnChainBalancesRest.ts index 98837526c9b..580ed5e508a 100644 --- a/packages/uniswap/src/features/portfolio/portfolioUpdates/rest/fetchOnChainBalancesRest.ts +++ b/packages/uniswap/src/features/portfolio/portfolioUpdates/rest/fetchOnChainBalancesRest.ts @@ -1,21 +1,20 @@ -import { ApolloClient, NormalizedCacheObject } from '@apollo/client' import { PartialMessage } from '@bufbuild/protobuf' import { GetPortfolioResponse } from '@uniswap/client-data-api/dist/data/v1/api_pb.d' import { Balance } from '@uniswap/client-data-api/dist/data/v1/types_pb' import { CurrencyAmount, NativeCurrency, Token } from '@uniswap/sdk-core' -import { GraphQLApi, TradingApi } from '@universe/api' +import { TradingApi } from '@universe/api' import { getNativeAddress } from 'uniswap/src/constants/addresses' +import { fetchTokenByAddress, searchTokenToCurrencyInfo } from 'uniswap/src/data/rest/searchTokensAndPools' import { UniverseChainId } from 'uniswap/src/features/chains/types' import { getPrimaryStablecoin } from 'uniswap/src/features/chains/utils' -import { currencyIdToContractInput } from 'uniswap/src/features/dataApi/utils/currencyIdToContractInput' -import { gqlTokenToCurrencyInfo } from 'uniswap/src/features/dataApi/utils/gqlTokenToCurrencyInfo' -import { Platform } from 'uniswap/src/features/platforms/types/Platform' +import { isSVMChain } from 'uniswap/src/features/platforms/utils/chains' import { fetchOnChainCurrencyBalance } from 'uniswap/src/features/portfolio/api' import { DenominatedValue, fetchIndicativeQuote, } from 'uniswap/src/features/portfolio/portfolioUpdates/fetchOnChainBalances' import { getCurrencyAmount, ValueType } from 'uniswap/src/features/tokens/getCurrencyAmount' +import { SolanaToken } from 'uniswap/src/features/tokens/SolanaToken' import { toTradingApiSupportedChainId } from 'uniswap/src/features/transactions/swap/utils/tradingApi' import { CurrencyId } from 'uniswap/src/types/currency' import { areAddressesEqual } from 'uniswap/src/utils/addresses' @@ -28,12 +27,10 @@ const FILE_NAME = 'fetchOnChainBalancesRest.ts' // Fetches real-time onchain balances for multiple currencies and converts them to Balance objects export async function fetchOnChainBalancesRest({ - apolloClient, cachedPortfolio, accountAddress, currencyIds, }: { - apolloClient: ApolloClient cachedPortfolio: NonNullable accountAddress: Address currencyIds: Set @@ -64,7 +61,7 @@ export async function fetchOnChainBalancesRest({ const cachedBalance = findCachedBalance({ cachedPortfolio, chainId, currencyAddress }) const token = cachedBalance?.token - const currencyResult = await resolveCurrency({ token, currencyId, apolloClient }) + const currencyResult = await resolveCurrency({ token, currencyId }) if (!currencyResult) { return @@ -259,49 +256,83 @@ function findCachedBalance({ } return areAddressesEqual({ - addressInput1: { address: balance.token.address, platform: Platform.EVM }, - addressInput2: { address: currencyAddress, platform: Platform.EVM }, + addressInput1: { address: balance.token.address, chainId: balance.token.chainId }, + addressInput2: { address: currencyAddress, chainId }, }) }) } -// Resolves currency metadata from cache or by fetching from GraphQL +function getCurrencyFromCache( + token: Balance['token'], + currencyId: CurrencyId, +): { currency: Token | SolanaToken; tokenInfo: null } | null { + if (!token) { + return null + } + + const currencyAddress = currencyIdToAddress(currencyId) + const chainId = currencyIdToChain(currencyId) + + if (!chainId) { + return null + } + + const currency = isSVMChain(chainId) + ? new SolanaToken(chainId, currencyAddress, token.decimals, token.symbol, token.name) + : new Token(chainId, currencyAddress, token.decimals, token.symbol, token.name) + + return { currency, tokenInfo: null } +} + +async function fetchTokenCurrencyInfo( + chainId: UniverseChainId, + address: string, +): Promise | null> { + const searchToken = await fetchTokenByAddress({ + chainId, + address, + }) + + return searchToken ? searchTokenToCurrencyInfo(searchToken) : null +} + +// Resolves `CurrencyInfo` either from cache or via REST search async function resolveCurrency({ token, currencyId, - apolloClient, }: { token?: Balance['token'] currencyId: CurrencyId - apolloClient: ApolloClient -}): Promise<{ currency: Token; tokenInfo: ReturnType | null } | null> { +}): Promise<{ currency: Token | SolanaToken; tokenInfo: ReturnType | null } | null> { const log = createLogger(FILE_NAME, 'resolveCurrency', '[REST-ITBU]') + // Try cache first if (token) { - const currencyAddress = currencyIdToAddress(currencyId) - const chainId = currencyIdToChain(currencyId) as UniverseChainId - const currency = new Token(chainId, currencyAddress, token.decimals, token.symbol, token.name) - return { currency, tokenInfo: null } + const cached = getCurrencyFromCache(token, currencyId) + if (cached) { + return cached + } } - // For new tokens not in cache, fetch token metadata from GraphQL - // TODO(WALL-7215): migrate this to REST once we have a tokens endpoint - const tokenQuery = await apolloClient.query({ - query: GraphQLApi.TokenDocument, - variables: currencyIdToContractInput(currencyId), - fetchPolicy: 'cache-first', - }) + // For new tokens not in cache, fetch token metadata via REST search + const chainId = currencyIdToChain(currencyId) + const currencyAddress = currencyIdToAddress(currencyId) + + if (!chainId || !currencyAddress) { + log.error(new Error('Invalid currencyId in `resolveCurrency`'), { currencyId }) + return null + } - const tokenInfo = tokenQuery.data.token ? gqlTokenToCurrencyInfo(tokenQuery.data.token) : null + const tokenInfo = await fetchTokenCurrencyInfo(chainId, currencyAddress) if (tokenInfo?.currency.isToken) { - log.debug('Fetched token metadata from GraphQL', { + log.debug('Fetched Token via REST Search', { currencyId, currency: tokenInfo.currency, }) return { currency: tokenInfo.currency, tokenInfo } } else { - log.warn('Could not fetch token metadata, skipping asset', { currencyId }) + log.warn('Failed to fetch Token via REST search', { currencyId }) return null } } diff --git a/packages/uniswap/src/features/portfolio/portfolioUpdates/rest/refetchRestQueriesViaOnchainOverrideVariantSaga.ts b/packages/uniswap/src/features/portfolio/portfolioUpdates/rest/refetchRestQueriesViaOnchainOverrideVariantSaga.ts index ca42115f47a..06d15b5e6b1 100644 --- a/packages/uniswap/src/features/portfolio/portfolioUpdates/rest/refetchRestQueriesViaOnchainOverrideVariantSaga.ts +++ b/packages/uniswap/src/features/portfolio/portfolioUpdates/rest/refetchRestQueriesViaOnchainOverrideVariantSaga.ts @@ -147,12 +147,10 @@ export function mergeOnChainBalances( } export async function fetchAndMergeOnchainBalances({ - apolloClient, cachedPortfolio, accountAddress, currencyIds, }: { - apolloClient: ApolloClient cachedPortfolio: Portfolio accountAddress: string currencyIds: Set @@ -165,7 +163,6 @@ export async function fetchAndMergeOnchainBalances({ try { const onchainBalancesByCurrencyId = await fetchOnChainBalancesRest({ - apolloClient, cachedPortfolio, accountAddress, currencyIds, @@ -206,7 +203,8 @@ export function* refetchRestQueriesViaOnchainOverrideVariant({ }: { transaction: TransactionDetails activeAddress: string | null - apolloClient: ApolloClient + // Only pass `null` for Solana where we don't need to refetch GQL queries + apolloClient: ApolloClient | null }): Generator { const currenciesWithBalanceToUpdate = getCurrenciesToUpdate(transaction, activeAddress) @@ -243,7 +241,6 @@ export function* refetchRestQueriesViaOnchainOverrideVariant({ yield* all( portfolioQueriesToUpdate.map((query) => call(updatePortfolioCache, { - apolloClient, ownerAddress: activeAddress, currencyIds: currenciesWithBalanceToUpdate, queryKey: query.queryKey, @@ -255,7 +252,11 @@ export function* refetchRestQueriesViaOnchainOverrideVariant({ yield* delay(REFETCH_DELAY) // Once NFTs are migrated to REST we won't need to do this - yield* call([apolloClient, apolloClient.refetchQueries], { include: [GQLQueries.NftsTab] }) + if (apolloClient) { + yield* call([apolloClient, apolloClient.refetchQueries], { include: [GQLQueries.NftsTab] }) + } else { + log.debug(`Ignoring NFT GQL refetch for ${platform} because apolloClient is null`) + } // Invalidate all portfolio queries that match this address yield* call([SharedQueryClient, SharedQueryClient.invalidateQueries], { @@ -265,12 +266,10 @@ export function* refetchRestQueriesViaOnchainOverrideVariant({ } function* updatePortfolioCache({ - apolloClient, ownerAddress, currencyIds, queryKey, }: { - apolloClient: ApolloClient ownerAddress: string currencyIds: Set queryKey: readonly unknown[] @@ -286,7 +285,6 @@ function* updatePortfolioCache({ } const mergedData = yield* call(fetchAndMergeOnchainBalances, { - apolloClient, cachedPortfolio: cachedPortfolioData.portfolio, accountAddress: ownerAddress, currencyIds, diff --git a/packages/uniswap/src/features/providers/rpcUrlSelector.ts b/packages/uniswap/src/features/providers/rpcUrlSelector.ts index 2a55c4f0abd..ec69efbbdb5 100644 --- a/packages/uniswap/src/features/providers/rpcUrlSelector.ts +++ b/packages/uniswap/src/features/providers/rpcUrlSelector.ts @@ -1,7 +1,6 @@ +import { Experiments, getExperimentValue, PrivateRpcProperties } from '@universe/gating' import { getChainInfo } from 'uniswap/src/features/chains/chainInfo' import { RPCType, UniverseChainId } from 'uniswap/src/features/chains/types' -import { Experiments, PrivateRpcProperties } from 'uniswap/src/features/gating/experiments' -import { getExperimentValue } from 'uniswap/src/features/gating/hooks' import { DEFAULT_FLASHBOTS_ENABLED, FLASHBOTS_DEFAULT_REFUND_PERCENT, diff --git a/packages/uniswap/src/features/search/SearchHistoryResult.ts b/packages/uniswap/src/features/search/SearchHistoryResult.ts index c1bf7bb3b4e..7c26f6bde7f 100644 --- a/packages/uniswap/src/features/search/SearchHistoryResult.ts +++ b/packages/uniswap/src/features/search/SearchHistoryResult.ts @@ -1,7 +1,7 @@ /* * Represents the search result types that are saved in Redux. */ -import { ProtocolVersion } from '@uniswap/client-pools/dist/pools/v1/types_pb' +import { ProtocolVersion } from '@uniswap/client-data-api/dist/data/v1/poolTypes_pb' import { UniverseChainId } from 'uniswap/src/features/chains/types' import { CurrencyId } from 'uniswap/src/types/currency' diff --git a/packages/uniswap/src/features/search/SearchModal/analytics/analytics.ts b/packages/uniswap/src/features/search/SearchModal/analytics/analytics.ts index f19b7f817d6..8af105061e1 100644 --- a/packages/uniswap/src/features/search/SearchModal/analytics/analytics.ts +++ b/packages/uniswap/src/features/search/SearchModal/analytics/analytics.ts @@ -1,4 +1,4 @@ -import { ProtocolVersion } from '@uniswap/client-pools/dist/pools/v1/types_pb' +import { ProtocolVersion } from '@uniswap/client-data-api/dist/data/v1/poolTypes_pb' import { OnchainItemListOptionType, SearchModalOption } from 'uniswap/src/components/lists/items/types' import { extractDomain } from 'uniswap/src/components/lists/items/wallets/utils' import { OnchainItemSection, OnchainItemSectionName } from 'uniswap/src/components/lists/OnchainItemList/types' diff --git a/packages/uniswap/src/features/search/SearchModal/hooks/useFilterCallbacks.ts b/packages/uniswap/src/features/search/SearchModal/hooks/useFilterCallbacks.ts index 40b45cb94fa..b27037d22b3 100644 --- a/packages/uniswap/src/features/search/SearchModal/hooks/useFilterCallbacks.ts +++ b/packages/uniswap/src/features/search/SearchModal/hooks/useFilterCallbacks.ts @@ -1,10 +1,9 @@ -import { useCallback, useEffect, useState } from 'react' -import { getChainInfo } from 'uniswap/src/features/chains/chainInfo' +import { useCallback, useEffect, useMemo, useState } from 'react' import { useEnabledChains } from 'uniswap/src/features/chains/hooks/useEnabledChains' import { UniverseChainId } from 'uniswap/src/features/chains/types' -import { isTestnetChain } from 'uniswap/src/features/chains/utils' import { ModalNameType, WalletEventName } from 'uniswap/src/features/telemetry/constants' import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { parseChainFromTokenSearchQuery } from 'uniswap/src/utils/search/parseChainFromTokenSearchQuery' export function useFilterCallbacks( chainId: UniverseChainId | null, @@ -19,9 +18,7 @@ export function useFilterCallbacks( onChangeText: (newSearchFilter: string) => void } { const [chainFilter, setChainFilter] = useState(chainId) - const [parsedChainFilter, setParsedChainFilter] = useState(null) const [searchFilter, setSearchFilter] = useState(null) - const [parsedSearchFilter, setParsedSearchFilter] = useState(null) const { chains: enabledChains } = useEnabledChains() @@ -29,44 +26,16 @@ export function useFilterCallbacks( // i.e "eth dai" or "dai eth" // parsedChainFilter: 1 // parsedSearchFilter: "dai" - useEffect(() => { - const sanitizedSearch = searchFilter?.trim().replace(' ', ' ') - const splitSearch = sanitizedSearch?.split(' ') - if (!splitSearch || splitSearch.length < 2) { - setParsedChainFilter(null) - setParsedSearchFilter(null) - return - } - - const firstWord = splitSearch[0]?.toLowerCase() - const lastWord = splitSearch[splitSearch.length - 1]?.toLowerCase() - - const firstWordChainMatch = firstWord ? getMatchingChainId(firstWord, enabledChains) : undefined - const lastWordChainMatch = lastWord ? getMatchingChainId(lastWord, enabledChains) : undefined - - if (!chainFilter && firstWordChainMatch) { - // First word is chain, rest is search term - const search = splitSearch.slice(1).join(' ') - if (search) { - setParsedChainFilter(firstWordChainMatch) - setParsedSearchFilter(search) - return + const { chainFilter: parsedChainFilter, searchTerm: parsedSearchFilter } = useMemo(() => { + // If there's already a chain filter set, don't parse chains from search text + if (chainFilter) { + return { + chainFilter: null, + searchTerm: null, } } - - if (!chainFilter && lastWordChainMatch && !firstWordChainMatch) { - // Last word is chain, preceding words are search term - const search = splitSearch.slice(0, -1).join(' ') - if (search) { - setParsedChainFilter(lastWordChainMatch) - setParsedSearchFilter(search) - return - } - } - - setParsedChainFilter(null) - setParsedSearchFilter(null) - }, [searchFilter, chainFilter, enabledChains]) + return parseChainFromTokenSearchQuery(searchFilter, enabledChains) + }, [chainFilter, searchFilter, enabledChains]) useEffect(() => { setChainFilter(chainId) @@ -99,38 +68,3 @@ export function useFilterCallbacks( onChangeText, } } - -/** - * Finds a matching chain ID based on the provided chain name. - * - * @param maybeChainName - The potential chain name to match against - * @param enabledChains - Array of enabled chain IDs to search within - * @returns The matching UniverseChainId or undefined if no match found - */ -const getMatchingChainId = (maybeChainName: string, enabledChains: UniverseChainId[]): UniverseChainId | undefined => { - const lowerCaseChainName = maybeChainName.toLowerCase() - - for (const chainId of enabledChains) { - if (isTestnetChain(chainId)) { - continue - } - - const chainInfo = getChainInfo(chainId) - - // Check against native currency name - const nativeCurrencyName = chainInfo.nativeCurrency.name.toLowerCase() - const firstWord = nativeCurrencyName.split(' ')[0] - - if (firstWord === lowerCaseChainName) { - return chainId - } - - // Check against interface name - const interfaceName = chainInfo.interfaceName.toLowerCase() - if (interfaceName === lowerCaseChainName) { - return chainId - } - } - - return undefined -} diff --git a/packages/uniswap/src/features/settings/hooks.test.ts b/packages/uniswap/src/features/settings/hooks.test.ts index b795e557ff8..0dc873ea549 100644 --- a/packages/uniswap/src/features/settings/hooks.test.ts +++ b/packages/uniswap/src/features/settings/hooks.test.ts @@ -12,7 +12,8 @@ jest.mock('utilities/src/platform', () => ({ ...jest.requireActual('utilities/src/platform'), })) -jest.mock('uniswap/src/features/gating/hooks', () => ({ +jest.mock('@universe/gating', () => ({ + ...jest.requireActual('@universe/gating'), useFeatureFlag: jest.fn(), })) diff --git a/packages/uniswap/src/features/smartWallet/mismatch/MismatchContext.tsx b/packages/uniswap/src/features/smartWallet/mismatch/MismatchContext.tsx index 3f74fc4fe33..f6b554d433c 100644 --- a/packages/uniswap/src/features/smartWallet/mismatch/MismatchContext.tsx +++ b/packages/uniswap/src/features/smartWallet/mismatch/MismatchContext.tsx @@ -1,7 +1,6 @@ +import { FeatureFlags, useFeatureFlag } from '@universe/gating' import React, { createContext, PropsWithChildren, useContext, useMemo } from 'react' import { UniverseChainId } from 'uniswap/src/features/chains/types' -import { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' import { MismatchAccountEffects } from 'uniswap/src/features/smartWallet/mismatch/MismatchAccountEffects' import type { HasMismatchInput, diff --git a/packages/uniswap/src/features/telemetry/constants/trace/element.ts b/packages/uniswap/src/features/telemetry/constants/trace/element.ts index 963e10245e8..24d4fb4f760 100644 --- a/packages/uniswap/src/features/telemetry/constants/trace/element.ts +++ b/packages/uniswap/src/features/telemetry/constants/trace/element.ts @@ -22,6 +22,7 @@ export enum ElementName { AutorouterVisualizationRow = 'expandable-autorouter-visualization-row', BackButton = 'back-button', BlogLink = 'blog-link', + BridgedAssetsBannerV2 = 'bridged-assets-banner-v2', BridgedAssetTDPSection = 'bridged-asset-tdp-section', BridgeNativeTokenButton = 'bridge-native-token-button', Buy = 'buy', @@ -136,6 +137,7 @@ export enum ElementName { OpenNftsList = 'open-nfts-list', PoolsTableRow = 'pools-table-row', PoolOutOfSyncError = 'pool-out-of-sync-error', + PortfolioNftItem = 'portfolio-nft-item', PreselectAsset = 'preselect-asset', PresetPercentage = 'preset-percentage', PriceUpdateAcceptButton = 'price-update-accept-button', diff --git a/packages/uniswap/src/features/telemetry/constants/trace/section.ts b/packages/uniswap/src/features/telemetry/constants/trace/section.ts index 9a3a749134a..2629576f2d2 100644 --- a/packages/uniswap/src/features/telemetry/constants/trace/section.ts +++ b/packages/uniswap/src/features/telemetry/constants/trace/section.ts @@ -16,6 +16,7 @@ export enum SectionName { ProfileActivityTab = 'profile-activity-tab', ProfileNftsTab = 'profile-nfts-tab', ProfileTokensTab = 'profile-tokens-tab', + PortfolioNftsTab = 'portfolio-nfts-tab', SwapCurrencyInput = 'swap-currency-input', SwapCurrencyOutput = 'swap-currency-output', SwapForm = 'swap-form', diff --git a/packages/uniswap/src/features/telemetry/constants/uniswap.ts b/packages/uniswap/src/features/telemetry/constants/uniswap.ts index 86e3ed35218..b1d890932eb 100644 --- a/packages/uniswap/src/features/telemetry/constants/uniswap.ts +++ b/packages/uniswap/src/features/telemetry/constants/uniswap.ts @@ -1,9 +1,10 @@ export enum UniswapEventName { BalancesReport = 'Balances Report', BalancesReportPerChain = 'Balances Report Per Chain', - TokenSelected = 'Token Selected', - ConversionEventSubmitted = 'Conversion Event Submitted', BlockaidFeesMismatch = 'Blockaid Fees Mismatch', + ConversionEventSubmitted = 'Conversion Event Submitted', + DelegationDetected = 'Delegation Detected', + ExperimentQualifyingEvent = 'Experiment Qualifying Event', LowNetworkTokenInfoModalOpened = 'Low Network Token Info Modal Opened', LpIncentiveCollectRewardsButtonClicked = 'LP Incentive Collect Rewards Button Clicked', LpIncentiveCollectRewardsErrorThrown = 'LP Incentive Collect Rewards Error Thrown', @@ -11,7 +12,7 @@ export enum UniswapEventName { LpIncentiveCollectRewardsSuccess = 'LP Incentive Collect Rewards Success', LpIncentiveLearnMoreCtaClicked = 'LP Incentive Learn More CTA Clicked', SmartWalletMismatchDetected = 'Smart Wallet Mismatch Detected', + TokenSelected = 'Token Selected', TooltipOpened = 'Tooltip Opened', - DelegationDetected = 'Delegation Detected', // alphabetize additional values. } diff --git a/packages/uniswap/src/features/telemetry/types.ts b/packages/uniswap/src/features/telemetry/types.ts index f7cc90ada26..a712650d81c 100644 --- a/packages/uniswap/src/features/telemetry/types.ts +++ b/packages/uniswap/src/features/telemetry/types.ts @@ -7,6 +7,7 @@ import { SharedEventName } from '@uniswap/analytics-events' import { OnChainStatus } from '@uniswap/client-trading/dist/trading/v1/api_pb' import { Currency, TradeType } from '@uniswap/sdk-core' import { TradingApi, UnitagClaimContext } from '@universe/api' +import { Experiments } from '@universe/gating' import type { PresetPercentage } from 'uniswap/src/components/CurrencyInputPanel/AmountInputPresets/types' import { OnchainItemSectionName } from 'uniswap/src/components/lists/OnchainItemList/types' import { UniverseChainId } from 'uniswap/src/features/chains/types' @@ -909,6 +910,9 @@ export type UniverseEventProperties = { delegationAddress: string isActiveChain?: boolean } + [UniswapEventName.ExperimentQualifyingEvent]: { + experiment: Experiments + } [UniswapEventName.BalancesReport]: { total_balances_usd: number wallets: string[] diff --git a/packages/uniswap/src/features/telemetry/utils/logExperimentQualifyingEvent.ts b/packages/uniswap/src/features/telemetry/utils/logExperimentQualifyingEvent.ts new file mode 100644 index 00000000000..983d478aee4 --- /dev/null +++ b/packages/uniswap/src/features/telemetry/utils/logExperimentQualifyingEvent.ts @@ -0,0 +1,9 @@ +import { Experiments } from '@universe/gating' +import { UniswapEventName } from 'uniswap/src/features/telemetry/constants' +import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' + +export function logExperimentQualifyingEvent({ experiment }: { experiment: Experiments }): void { + sendAnalyticsEvent(UniswapEventName.ExperimentQualifyingEvent, { + experiment, + }) +} diff --git a/packages/uniswap/src/features/tokens/hooks/useBlockaidFeeComparisonAnalytics.ts b/packages/uniswap/src/features/tokens/hooks/useBlockaidFeeComparisonAnalytics.ts index 03957097909..54e01673dd8 100644 --- a/packages/uniswap/src/features/tokens/hooks/useBlockaidFeeComparisonAnalytics.ts +++ b/packages/uniswap/src/features/tokens/hooks/useBlockaidFeeComparisonAnalytics.ts @@ -1,8 +1,7 @@ +import { FeatureFlags, useFeatureFlag } from '@universe/gating' import { useEffect, useRef } from 'react' import { getNativeAddress } from 'uniswap/src/constants/addresses' import { CurrencyInfo } from 'uniswap/src/features/dataApi/types' -import { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' import { UniswapEventName } from 'uniswap/src/features/telemetry/constants' import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send.web' import { getTokenProtectionFeeOnTransfer } from 'uniswap/src/features/tokens/safetyUtils' diff --git a/packages/uniswap/src/features/transactions/components/DecimalPadInput/DecimalPad.native.tsx b/packages/uniswap/src/features/transactions/components/DecimalPadInput/DecimalPad.native.tsx index 162c20ab5ed..d94a881af3e 100644 --- a/packages/uniswap/src/features/transactions/components/DecimalPadInput/DecimalPad.native.tsx +++ b/packages/uniswap/src/features/transactions/components/DecimalPadInput/DecimalPad.native.tsx @@ -228,7 +228,7 @@ const KeyButton = memo(function KeyButton({ onPress?.(label, action) scale.value = withSequence(withTiming(1.3, animationOptions), withTiming(1, animationOptions)) opacity.value = withSequence(withTiming(0.75, animationOptions), withTiming(1, animationOptions)) - }, [action, label, onPress, opacity, scale]) + }, [action, label, onPress]) const handleLongPressStart = useCallback((): void => { onLongPressStart?.(label, action) diff --git a/packages/uniswap/src/features/transactions/components/settings/TransactionSettingsModal/TransactionSettingsModalContent/TransactionSettingsRow.tsx b/packages/uniswap/src/features/transactions/components/settings/TransactionSettingsModal/TransactionSettingsModalContent/TransactionSettingsRow.tsx index 7114bd77958..15d71a412e3 100644 --- a/packages/uniswap/src/features/transactions/components/settings/TransactionSettingsModal/TransactionSettingsModalContent/TransactionSettingsRow.tsx +++ b/packages/uniswap/src/features/transactions/components/settings/TransactionSettingsModal/TransactionSettingsModalContent/TransactionSettingsRow.tsx @@ -1,3 +1,4 @@ +import { FeatureFlags, useFeatureFlag } from '@universe/gating' import { PropsWithChildren, ReactNode, useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import { Flex, Text, TouchableArea } from 'ui/src' @@ -5,8 +6,6 @@ import { InfoCircleFilled } from 'ui/src/components/icons/InfoCircleFilled' import { RotatableChevron } from 'ui/src/components/icons/RotatableChevron' import { iconSizes } from 'ui/src/theme' import { InfoTooltip } from 'uniswap/src/components/tooltip/InfoTooltip' -import { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' import type { TransactionSettingConfig } from 'uniswap/src/features/transactions/components/settings/types' interface TransactionSettingRowProps { diff --git a/packages/uniswap/src/features/transactions/components/settings/settingsConfigurations/slippage/useSlippageSettings.ts b/packages/uniswap/src/features/transactions/components/settings/settingsConfigurations/slippage/useSlippageSettings.ts index 8362cfd13cd..b8640e896fb 100644 --- a/packages/uniswap/src/features/transactions/components/settings/settingsConfigurations/slippage/useSlippageSettings.ts +++ b/packages/uniswap/src/features/transactions/components/settings/settingsConfigurations/slippage/useSlippageSettings.ts @@ -152,7 +152,7 @@ export function useSlippageSettings(params?: SlippageSettingsProps): { setCustomSlippageTolerance(parsedValue) } }, - [updateInputWarning, saveOnBlur, inputShakeX, setCustomSlippageTolerance], + [updateInputWarning, saveOnBlur, setCustomSlippageTolerance], ) const onFocusSlippageInput = useCallback((): void => { diff --git a/packages/uniswap/src/features/transactions/components/settings/types.ts b/packages/uniswap/src/features/transactions/components/settings/types.ts index 06239c3491f..146767f0360 100644 --- a/packages/uniswap/src/features/transactions/components/settings/types.ts +++ b/packages/uniswap/src/features/transactions/components/settings/types.ts @@ -1,5 +1,5 @@ +import type { FeatureFlags } from '@universe/gating' import type { AppTFunction } from 'ui/src/i18n/types' -import type { FeatureFlags } from 'uniswap/src/features/gating/flags' import type { Platform } from 'uniswap/src/features/platforms/types/Platform' import type { FrontendSupportedProtocol } from 'uniswap/src/features/transactions/swap/utils/protocols' diff --git a/packages/uniswap/src/features/transactions/hooks/useGetCanSignPermits.ts b/packages/uniswap/src/features/transactions/hooks/useGetCanSignPermits.ts index 085c09ad749..70292374ffc 100644 --- a/packages/uniswap/src/features/transactions/hooks/useGetCanSignPermits.ts +++ b/packages/uniswap/src/features/transactions/hooks/useGetCanSignPermits.ts @@ -1,6 +1,5 @@ +import { FeatureFlags, useFeatureFlag } from '@universe/gating' import { UniverseChainId } from 'uniswap/src/features/chains/types' -import { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' import { useHasAccountMismatchCallback } from 'uniswap/src/features/smartWallet/mismatch/hooks' import { useEvent } from 'utilities/src/react/hooks' diff --git a/packages/uniswap/src/features/transactions/hooks/useGetSwapDelegationAddress.ts b/packages/uniswap/src/features/transactions/hooks/useGetSwapDelegationAddress.ts index 296c287fa6f..24e77262eea 100644 --- a/packages/uniswap/src/features/transactions/hooks/useGetSwapDelegationAddress.ts +++ b/packages/uniswap/src/features/transactions/hooks/useGetSwapDelegationAddress.ts @@ -1,6 +1,5 @@ +import { FeatureFlags, useFeatureFlag } from '@universe/gating' import { UniverseChainId } from 'uniswap/src/features/chains/types' -import { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' import { useEvent } from 'utilities/src/react/hooks' export function useGetSwapDelegationAddress(): (chainId: UniverseChainId | undefined) => string | undefined { diff --git a/packages/uniswap/src/features/transactions/hooks/usePollingIntervalByChain.ts b/packages/uniswap/src/features/transactions/hooks/usePollingIntervalByChain.ts index d9be83dd85d..0a750000644 100644 --- a/packages/uniswap/src/features/transactions/hooks/usePollingIntervalByChain.ts +++ b/packages/uniswap/src/features/transactions/hooks/usePollingIntervalByChain.ts @@ -1,8 +1,6 @@ +import { DynamicConfigs, FeatureFlags, SwapConfigKey, useDynamicConfigValue, useFeatureFlag } from '@universe/gating' import { UniverseChainId } from 'uniswap/src/features/chains/types' import { isMainnetChainId } from 'uniswap/src/features/chains/utils' -import { DynamicConfigs, SwapConfigKey } from 'uniswap/src/features/gating/configs' -import { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { useDynamicConfigValue, useFeatureFlag } from 'uniswap/src/features/gating/hooks' import { ONE_SECOND_MS } from 'utilities/src/time/time' export const AVERAGE_L1_BLOCK_TIME_MS = 12 * ONE_SECOND_MS diff --git a/packages/uniswap/src/features/transactions/liquidity/types.ts b/packages/uniswap/src/features/transactions/liquidity/types.ts index ba9cf518a28..9ed54c94ef5 100644 --- a/packages/uniswap/src/features/transactions/liquidity/types.ts +++ b/packages/uniswap/src/features/transactions/liquidity/types.ts @@ -1,4 +1,4 @@ -import { ProtocolVersion } from '@uniswap/client-pools/dist/pools/v1/types_pb' +import { ProtocolVersion } from '@uniswap/client-data-api/dist/data/v1/poolTypes_pb' import { Currency, CurrencyAmount, Token } from '@uniswap/sdk-core' import { TradingApi } from '@universe/api' import { PermitTransaction, PermitTypedData } from 'uniswap/src/features/transactions/swap/types/swapTxAndGasInfo' diff --git a/packages/uniswap/src/features/transactions/steps/types.ts b/packages/uniswap/src/features/transactions/steps/types.ts index 3e3d8b56e5a..6e5ee03bf76 100644 --- a/packages/uniswap/src/features/transactions/steps/types.ts +++ b/packages/uniswap/src/features/transactions/steps/types.ts @@ -1,13 +1,23 @@ +import type { TransactionResponse } from '@ethersproject/abstract-provider' import type { CollectFeesSteps } from 'uniswap/src/features/transactions/liquidity/steps/collectFeesSteps' import type { CollectLpIncentiveRewardsSteps } from 'uniswap/src/features/transactions/liquidity/steps/collectIncentiveRewardsSteps' import type { DecreaseLiquiditySteps } from 'uniswap/src/features/transactions/liquidity/steps/decreaseLiquiditySteps' import type { IncreaseLiquiditySteps } from 'uniswap/src/features/transactions/liquidity/steps/increaseLiquiditySteps' import type { MigrationSteps } from 'uniswap/src/features/transactions/liquidity/steps/migrationSteps' +import { TokenApprovalTransactionStep } from 'uniswap/src/features/transactions/steps/approve' import type { SignTypedDataStepFields } from 'uniswap/src/features/transactions/steps/permit2Signature' +import type { Permit2TransactionStep } from 'uniswap/src/features/transactions/steps/permit2Transaction' +import { TokenRevocationTransactionStep } from 'uniswap/src/features/transactions/steps/revoke' import { WrapTransactionStep } from 'uniswap/src/features/transactions/steps/wrap' +import { ExtractedBaseTradeAnalyticsProperties } from 'uniswap/src/features/transactions/swap/analytics' import type { ClassicSwapSteps } from 'uniswap/src/features/transactions/swap/steps/classicSteps' +import { SwapTransactionStep, SwapTransactionStepAsync } from 'uniswap/src/features/transactions/swap/steps/swap' import type { UniswapXSwapSteps } from 'uniswap/src/features/transactions/swap/steps/uniswapxSteps' +import { SetCurrentStepFn } from 'uniswap/src/features/transactions/swap/types/swapCallback' +import { BridgeTrade, ChainedActionTrade, ClassicTrade } from 'uniswap/src/features/transactions/swap/types/trade' +import { TransactionTypeInfo } from 'uniswap/src/features/transactions/types/transactionDetails' import type { ValidatedTransactionRequest } from 'uniswap/src/features/transactions/types/transactionRequests' +import { AccountDetails } from 'uniswap/src/features/wallet/types/AccountDetails' export enum TransactionStepType { TokenApprovalTransaction = 'TokenApproval', @@ -49,3 +59,42 @@ export interface OnChainTransactionFields { export interface OnChainTransactionFieldsBatched { batchedTxRequests: ValidatedTransactionRequest[] } + +export interface HandleOnChainStepParams { + account: AccountDetails + info: TransactionTypeInfo + step: T + setCurrentStep: SetCurrentStepFn + /** Controls whether the function allow submitting a duplicate tx (a tx w/ identical `info` to another recent/pending tx). Defaults to false. */ + allowDuplicativeTx?: boolean + /** Controls whether the function should throw an error upon interrupt or not, defaults to `false`. */ + ignoreInterrupt?: boolean + /** Controls whether the function should wait to return until after the transaction has confirmed. Defaults to `true`. */ + shouldWaitForConfirmation?: boolean + /** Called when data returned from a submitted transaction differs from data originally sent to the wallet. */ + onModification?: ( + response: Pick, + ) => void | Generator +} + +export interface HandleSignatureStepParams { + account: AccountDetails + step: T + setCurrentStep: SetCurrentStepFn + ignoreInterrupt?: boolean +} + +export type HandleApprovalStepParams = Omit< + HandleOnChainStepParams, + 'info' +> + +export type HandleOnChainPermit2TransactionStep = Omit, 'info'> + +export interface HandleSwapStepParams extends Omit { + step: SwapTransactionStep | SwapTransactionStepAsync + signature?: string + trade: ClassicTrade | BridgeTrade | ChainedActionTrade + analytics: ExtractedBaseTradeAnalyticsProperties + onTransactionHash?: (hash: string) => void +} diff --git a/packages/uniswap/src/features/transactions/swap/components/MaxSlippageRow/SlippageInfo/SlippageInfoCaption.tsx b/packages/uniswap/src/features/transactions/swap/components/MaxSlippageRow/SlippageInfo/SlippageInfoCaption.tsx index 1b27e9022c3..31bc13b6cf9 100644 --- a/packages/uniswap/src/features/transactions/swap/components/MaxSlippageRow/SlippageInfo/SlippageInfoCaption.tsx +++ b/packages/uniswap/src/features/transactions/swap/components/MaxSlippageRow/SlippageInfo/SlippageInfoCaption.tsx @@ -75,7 +75,7 @@ export function SlippageInfoCaption({ : t('swap.settings.slippage.output.message')}{' '} {isWebPlatform && ( - + )} diff --git a/packages/uniswap/src/features/transactions/swap/components/SwapFormButton/hooks/useSwapFormButtonText.ts b/packages/uniswap/src/features/transactions/swap/components/SwapFormButton/hooks/useSwapFormButtonText.ts index 37794aa417e..292ec06cc5f 100644 --- a/packages/uniswap/src/features/transactions/swap/components/SwapFormButton/hooks/useSwapFormButtonText.ts +++ b/packages/uniswap/src/features/transactions/swap/components/SwapFormButton/hooks/useSwapFormButtonText.ts @@ -1,8 +1,7 @@ +import { FeatureFlags, useFeatureFlag } from '@universe/gating' import { useTranslation } from 'react-i18next' import { nativeOnChain } from 'uniswap/src/constants/tokens' import { useConnectionStatus } from 'uniswap/src/features/accounts/store/hooks' -import { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' import { isSVMChain } from 'uniswap/src/features/platforms/utils/chains' import { useIsWebFORNudgeEnabled } from 'uniswap/src/features/providers/webForNudgeProvider' import { useTransactionModalContext } from 'uniswap/src/features/transactions/components/TransactionModal/TransactionModalContext' diff --git a/packages/uniswap/src/features/transactions/swap/components/SwapFormSettings/settingsConfigurations/TradeRoutingPreference/TradeRoutingPreferenceScreen.tsx b/packages/uniswap/src/features/transactions/swap/components/SwapFormSettings/settingsConfigurations/TradeRoutingPreference/TradeRoutingPreferenceScreen.tsx index 96b0727a14e..6a68c8df6a3 100644 --- a/packages/uniswap/src/features/transactions/swap/components/SwapFormSettings/settingsConfigurations/TradeRoutingPreference/TradeRoutingPreferenceScreen.tsx +++ b/packages/uniswap/src/features/transactions/swap/components/SwapFormSettings/settingsConfigurations/TradeRoutingPreference/TradeRoutingPreferenceScreen.tsx @@ -1,4 +1,5 @@ import { TradingApi } from '@universe/api' +import { FeatureFlags, useFeatureFlag } from '@universe/gating' import type { TFunction } from 'i18next' import type { ReactNode } from 'react' import { useCallback, useState } from 'react' @@ -16,11 +17,8 @@ import { InfoTooltip } from 'uniswap/src/components/tooltip/InfoTooltip' import WarningIcon from 'uniswap/src/components/warnings/WarningIcon' import { uniswapUrls } from 'uniswap/src/constants/urls' import { useUniswapContextSelector } from 'uniswap/src/contexts/UniswapContext' - import { getChainInfo } from 'uniswap/src/features/chains/chainInfo' import { UniverseChainId } from 'uniswap/src/features/chains/types' -import { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' import { ElementName, ModalName } from 'uniswap/src/features/telemetry/constants' import Trace from 'uniswap/src/features/telemetry/Trace' import { TransactionSettingsModalId } from 'uniswap/src/features/transactions/components/settings/stores/TransactionSettingsModalStore/createTransactionSettingsModalStore' diff --git a/packages/uniswap/src/features/transactions/swap/components/UnichainInstantBalanceModal/AnimatedTokenFlip.tsx b/packages/uniswap/src/features/transactions/swap/components/UnichainInstantBalanceModal/AnimatedTokenFlip.tsx index 13fca286d05..21aa4e2fdbf 100644 --- a/packages/uniswap/src/features/transactions/swap/components/UnichainInstantBalanceModal/AnimatedTokenFlip.tsx +++ b/packages/uniswap/src/features/transactions/swap/components/UnichainInstantBalanceModal/AnimatedTokenFlip.tsx @@ -23,7 +23,7 @@ export function AnimatedTokenFlip({ duration: 600, easing: Easing.bezier(0.68, -0.3, 0.265, 1.3), }) - }, [processingState, flipAnimation]) + }, [processingState]) const handleTokenClick = (): void => { setProcessingState((prev) => (prev === 'complete' ? 'processing' : 'complete')) diff --git a/packages/uniswap/src/features/transactions/swap/components/UnichainInstantBalanceModal/GradientContainer.native.tsx b/packages/uniswap/src/features/transactions/swap/components/UnichainInstantBalanceModal/GradientContainer.native.tsx index 3bc29963a45..f477b40bbbc 100644 --- a/packages/uniswap/src/features/transactions/swap/components/UnichainInstantBalanceModal/GradientContainer.native.tsx +++ b/packages/uniswap/src/features/transactions/swap/components/UnichainInstantBalanceModal/GradientContainer.native.tsx @@ -33,7 +33,7 @@ export function GradientContainer({ toTokenColor, children }: GradientContainerP blobT1.value = withRepeat(withTiming(1, cfg), -1, true) blobT2.value = withRepeat(withTiming(1, { ...cfg, duration: 16000 }), -1, true) blobT3.value = withRepeat(withTiming(1, { ...cfg, duration: 7000 }), -1, true) - }, [blobT1, blobT2, blobT3]) + }, []) const blob1 = useAnimatedStyle(() => { const innerT = blobT1.value * Math.PI * 2 diff --git a/packages/uniswap/src/features/transactions/swap/components/UnichainInstantBalanceModal/GradientContainer.web.tsx b/packages/uniswap/src/features/transactions/swap/components/UnichainInstantBalanceModal/GradientContainer.web.tsx index 91e0f69d8a7..dfe2d111a64 100644 --- a/packages/uniswap/src/features/transactions/swap/components/UnichainInstantBalanceModal/GradientContainer.web.tsx +++ b/packages/uniswap/src/features/transactions/swap/components/UnichainInstantBalanceModal/GradientContainer.web.tsx @@ -27,7 +27,7 @@ export function GradientContainer({ toTokenColor, children }: GradientContainerP blobT1.value = withRepeat(withTiming(1, cfg), -1, true) blobT2.value = withRepeat(withTiming(1, { ...cfg, duration: 16000 }), -1, true) blobT3.value = withRepeat(withTiming(1, { ...cfg, duration: 7000 }), -1, true) - }, [blobT1, blobT2, blobT3]) + }, []) const blob1 = useAnimatedStyle(() => { const innerT = blobT1.value * Math.PI * 2 diff --git a/packages/uniswap/src/features/transactions/swap/components/UnichainInstantBalanceModal/hooks/receiptFetching/useReceiptSuccessHandler.ts b/packages/uniswap/src/features/transactions/swap/components/UnichainInstantBalanceModal/hooks/receiptFetching/useReceiptSuccessHandler.ts index df86321ffe6..ff2f988a4f3 100644 --- a/packages/uniswap/src/features/transactions/swap/components/UnichainInstantBalanceModal/hooks/receiptFetching/useReceiptSuccessHandler.ts +++ b/packages/uniswap/src/features/transactions/swap/components/UnichainInstantBalanceModal/hooks/receiptFetching/useReceiptSuccessHandler.ts @@ -3,9 +3,10 @@ import { JsonRpcProvider, TransactionReceipt } from '@ethersproject/providers' import { useCallback } from 'react' import { useDispatch } from 'react-redux' import { useTransactionModalContext } from 'uniswap/src/features/transactions/components/TransactionModal/TransactionModalContext' -import { updateTransaction } from 'uniswap/src/features/transactions/slice' +import { updateTransactionWithoutWatch } from 'uniswap/src/features/transactions/slice' import { getOutputAmountUsingSwapLogAndFormData } from 'uniswap/src/features/transactions/swap/components/UnichainInstantBalanceModal/getOutputAmountFromSwapLogAndFormData.ts/getOutputAmountFromSwapLogAndFormData' import { + logSwapTransactionCompleted, NO_OUTPUT_ERROR, reportOutputAmount, } from 'uniswap/src/features/transactions/swap/components/UnichainInstantBalanceModal/hooks/receiptFetching/utils' @@ -75,16 +76,19 @@ export function useReceiptSuccessHandler(): (params: ReceiptSuccessParams) => Pr // updates if the tx is successful so we know to fallback to the form value updateSwapForm({ instantReceiptFetchTime: methodFetchTime - methodRoundtripTime }) - // TODO(APPS-8546): move to a saga to avoid anti-pattern + // TODO(SWAP-407): move to a saga to avoid anti-pattern const parsedReceipt = receiptFromEthersReceipt(receipt, methodFetchTime) - dispatch( - updateTransaction({ - ...transaction, - receipt: parsedReceipt, - status: TransactionStatus.Success, - ...(isWebApp && { isFlashblockTxWithinThreshold }), - }), - ) + + const updatedTransaction = { + ...transaction, + receipt: parsedReceipt, + status: TransactionStatus.Success, + ...(isWebApp && { isFlashblockTxWithinThreshold }), + } + + dispatch(updateTransactionWithoutWatch(updatedTransaction)) + + logSwapTransactionCompleted(updatedTransaction) // Try to get output amount from transfer logs first const outputAmountFromOutputTransferLog = getOutputAmountUsingOutputTransferLog({ diff --git a/packages/uniswap/src/features/transactions/swap/components/UnichainInstantBalanceModal/hooks/receiptFetching/utils.ts b/packages/uniswap/src/features/transactions/swap/components/UnichainInstantBalanceModal/hooks/receiptFetching/utils.ts index 13e4794ad3d..fe449760247 100644 --- a/packages/uniswap/src/features/transactions/swap/components/UnichainInstantBalanceModal/hooks/receiptFetching/utils.ts +++ b/packages/uniswap/src/features/transactions/swap/components/UnichainInstantBalanceModal/hooks/receiptFetching/utils.ts @@ -1,6 +1,13 @@ import { BigNumber } from '@ethersproject/bignumber' +import { TradeType } from '@uniswap/sdk-core' +import { SwapEventName } 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 { getRouteAnalyticsData, tradeRoutingToFillType } from 'uniswap/src/features/transactions/swap/analytics' import { SwapFormState } from 'uniswap/src/features/transactions/swap/stores/swapFormStore/types' +import { SwapEventType, timestampTracker } from 'uniswap/src/features/transactions/swap/utils/SwapEventTimestampTracker' +import { TransactionDetails, TransactionType } from 'uniswap/src/features/transactions/types/transactionDetails' +import { isWebApp } from 'utilities/src/platform' export const NO_OUTPUT_ERROR = 'No output amount found in receipt logs' @@ -43,3 +50,74 @@ export function resetSwapFormAndReturnToForm({ updateSwapForm, setScreen }: Rese }) setScreen(TransactionScreen.Form) } + +/** + * TODO(SWAP-407): do NOT copy this logic when moving to a saga; we should restore the original watcher+logging logic once we make the switch + * Logs swap transaction completion analytics for web app + */ +export function logSwapTransactionCompleted(updatedTransaction: TransactionDetails): void { + if (updatedTransaction.typeInfo.type !== TransactionType.Swap || !updatedTransaction.hash || !isWebApp) { + return + } + + const { hash, chainId, addedTime, from, typeInfo, transactionOriginType, routing, id, receipt } = updatedTransaction + const gasUsed = receipt?.gasUsed + const effectiveGasPrice = receipt?.effectiveGasPrice + const confirmedTime = receipt?.confirmedTime + const includesDelegation = 'options' in updatedTransaction ? updatedTransaction.options.includesDelegation : undefined + const isSmartWalletTransaction = + 'options' in updatedTransaction ? updatedTransaction.options.isSmartWalletTransaction : undefined + + const { + quoteId, + gasUseEstimate, + inputCurrencyId, + outputCurrencyId, + transactedUSDValue, + tradeType, + slippageTolerance, + routeString, + protocol, + simulationFailureReasons, + } = typeInfo + + const baseProperties = { + routing: tradeRoutingToFillType({ routing, indicative: false }), + id, + hash, + transactionOriginType, + address: from, + chain_id: chainId, + added_time: addedTime, + confirmed_time: confirmedTime, + gas_used: gasUsed, + effective_gas_price: effectiveGasPrice, + inputCurrencyId, + outputCurrencyId, + gasUseEstimate, + quoteId, + submitViaPrivateRpc: + 'options' in updatedTransaction ? (updatedTransaction.options.submitViaPrivateRpc ?? false) : undefined, + transactedUSDValue, + tradeType: tradeType === TradeType.EXACT_INPUT ? 'EXACT_INPUT' : 'EXACT_OUTPUT', + slippageTolerance, + route: routeString, + protocol, + simulation_failure_reasons: simulationFailureReasons, + includes_delegation: includesDelegation, + is_smart_wallet_transaction: isSmartWalletTransaction, + ...getRouteAnalyticsData(updatedTransaction), + } + + // Log swap success with time-to-swap tracking + const hasSetSwapSuccess = timestampTracker.hasTimestamp(SwapEventType.FirstSwapSuccess) + const elapsedTime = timestampTracker.setElapsedTime(SwapEventType.FirstSwapSuccess) + + sendAnalyticsEvent(SwapEventName.SwapTransactionCompleted, { + ...baseProperties, + time_to_swap: hasSetSwapSuccess ? undefined : elapsedTime, + time_to_swap_since_first_input: hasSetSwapSuccess + ? undefined + : timestampTracker.getElapsedTime(SwapEventType.FirstSwapSuccess, SwapEventType.FirstSwapAction), + }) +} diff --git a/packages/uniswap/src/features/transactions/swap/form/SwapFormScreen/SwapFormDecimalPad/SwapFormDecimalPad.native.tsx b/packages/uniswap/src/features/transactions/swap/form/SwapFormScreen/SwapFormDecimalPad/SwapFormDecimalPad.native.tsx index db1ca5bebbb..b766c8c4cd8 100644 --- a/packages/uniswap/src/features/transactions/swap/form/SwapFormScreen/SwapFormDecimalPad/SwapFormDecimalPad.native.tsx +++ b/packages/uniswap/src/features/transactions/swap/form/SwapFormScreen/SwapFormDecimalPad/SwapFormDecimalPad.native.tsx @@ -1,10 +1,16 @@ import type { MutableRefObject, RefObject } from 'react' -import { useMemo, useState } from 'react' +import { useCallback, useMemo, useState } from 'react' import type { LayoutChangeEvent, TextInputProps } from 'react-native' import { type ButtonProps, Flex, type FlexProps } from 'ui/src' -import { AmountInputPresets } from 'uniswap/src/components/CurrencyInputPanel/AmountInputPresets/AmountInputPresets' +import { + AmountInputPresets, + PRESET_BUTTON_PROPS, +} from 'uniswap/src/components/CurrencyInputPanel/AmountInputPresets/AmountInputPresets' +import { PresetAmountButton } from 'uniswap/src/components/CurrencyInputPanel/AmountInputPresets/PresetAmountButton' import type { PresetPercentage } from 'uniswap/src/components/CurrencyInputPanel/AmountInputPresets/types' +import { PRESET_PERCENTAGES } from 'uniswap/src/components/CurrencyInputPanel/AmountInputPresets/utils' import { MAX_FIAT_INPUT_DECIMALS } from 'uniswap/src/constants/transactions' +import { ElementName } from 'uniswap/src/features/telemetry/constants' import type { DecimalPadInputRef } from 'uniswap/src/features/transactions/components/DecimalPadInput/DecimalPadInput' import { DecimalPadCalculatedSpaceId, @@ -122,6 +128,21 @@ function SwapFormDecimalPadContent({ setAdditionalElementsHeight(event.nativeEvent.layout.height) }) + const renderPreset = useCallback( + (preset: PresetPercentage) => ( + + ), + [currencyAmounts[CurrencyField.INPUT], currencyBalances[CurrencyField.INPUT], onSetPresetValue], + ) + return ( <> )} diff --git a/packages/uniswap/src/features/transactions/swap/hooks/useIsForFiltersEnabled.ts b/packages/uniswap/src/features/transactions/swap/hooks/useIsForFiltersEnabled.ts index a0c81fe6965..34b8e52f31c 100644 --- a/packages/uniswap/src/features/transactions/swap/hooks/useIsForFiltersEnabled.ts +++ b/packages/uniswap/src/features/transactions/swap/hooks/useIsForFiltersEnabled.ts @@ -1,5 +1,4 @@ -import { Experiments, ForFiltersProperties } from 'uniswap/src/features/gating/experiments' -import { useExperimentValue } from 'uniswap/src/features/gating/hooks' +import { Experiments, ForFiltersProperties, useExperimentValue } from '@universe/gating' /** * Hook to determine if ForFilters feature should be enabled diff --git a/packages/uniswap/src/features/transactions/swap/hooks/useIsUnichainFlashblocksEnabled.ts b/packages/uniswap/src/features/transactions/swap/hooks/useIsUnichainFlashblocksEnabled.ts index f222c7b8e74..aa88f876b33 100644 --- a/packages/uniswap/src/features/transactions/swap/hooks/useIsUnichainFlashblocksEnabled.ts +++ b/packages/uniswap/src/features/transactions/swap/hooks/useIsUnichainFlashblocksEnabled.ts @@ -1,24 +1,56 @@ -import { UniverseChainId } from 'uniswap/src/features/chains/types' -import { Experiments, Layers, UnichainFlashblocksProperties } from 'uniswap/src/features/gating/experiments' -import { FeatureFlags } from 'uniswap/src/features/gating/flags' +import { TradingApi } from '@universe/api' import { + Experiments, + FeatureFlags, getExperimentValueFromLayer, getFeatureFlag, + Layers, + UnichainFlashblocksProperties, useExperimentValueFromLayer, useFeatureFlag, -} from 'uniswap/src/features/gating/hooks' +} from '@universe/gating' +import { UniverseChainId } from 'uniswap/src/features/chains/types' +import { shouldShowFlashblocksUI } from 'uniswap/src/features/transactions/swap/components/UnichainInstantBalanceModal/utils' import { isWebApp } from 'utilities/src/platform' /** - * Hook to determine if Unichain flashblocks feature should be enabled + * Core logic to determine if Flashblocks modal should be enabled. * Returns true only when: * 1. The UnichainFlashblocks feature flag is enabled - * 2. The UnichainFlashblocksModal experiment is enabled (via SwapPage layer) - only on interface - * 3. The current chain is Unichain mainnet or Unichain sepolia + * 2. The user is allocated to the UnichainFlashblocksModal experiment in the SwapPage layer (web only) + * 3. The flashblocksModalEnabled parameter is true for that experiment + * 4. The current chain is Unichain mainnet or Unichain sepolia + */ +function isFlashblocksModalEnabledForChain({ + flashblocksFlagEnabled, + flashblocksModalEnabled, + chainId, +}: { + flashblocksFlagEnabled: boolean + flashblocksModalEnabled: boolean + chainId?: UniverseChainId +}): boolean { + // Check feature flag on all platforms + if (!flashblocksFlagEnabled) { + return false + } + + // Only check experiment on the web app + if (isWebApp && !flashblocksModalEnabled) { + return false + } + + return chainId === UniverseChainId.Unichain || chainId === UniverseChainId.UnichainSepolia +} + +/** + * Hook to determine if the Flashblocks modal should be enabled. + * Uses React hooks to read feature flags and experiments. */ export function useIsUnichainFlashblocksEnabled(chainId?: UniverseChainId): boolean { - const flashblocksFlag = useFeatureFlag(FeatureFlags.UnichainFlashblocks) - const flashblocksExperiment = useExperimentValueFromLayer< + const flashblocksFlagEnabled = useFeatureFlag(FeatureFlags.UnichainFlashblocks) + + const flashblocksModalEnabled = useExperimentValueFromLayer< Layers.SwapPage, Experiments.UnichainFlashblocksModal, boolean @@ -28,29 +60,17 @@ export function useIsUnichainFlashblocksEnabled(chainId?: UniverseChainId): bool defaultValue: false, }) - // Check feature flag on all platforms - if (!flashblocksFlag) { - return false - } - - // Only check experiment on interface platform - if (isWebApp && !flashblocksExperiment) { - return false - } - - return chainId === UniverseChainId.Unichain || chainId === UniverseChainId.UnichainSepolia + return isFlashblocksModalEnabledForChain({ flashblocksFlagEnabled, flashblocksModalEnabled, chainId }) } /** - * Sync function to check if Unichain flashblocks feature is enabled - * Returns true only when: - * 1. The UnichainFlashblocks feature flag is enabled - * 2. The UnichainFlashblocksModal experiment is enabled (via SwapPage layer) - only on interface - * 3. The current chain is Unichain mainnet or Unichain sepolia + * Sync function to determine if the Flashblocks modal should be enabled. + * Uses direct getters to read feature flags and experiments. */ export function getIsFlashblocksEnabled(chainId?: UniverseChainId): boolean { - const flashblocksFlag = getFeatureFlag(FeatureFlags.UnichainFlashblocks) - const flashblocksExperiment = getExperimentValueFromLayer< + const flashblocksFlagEnabled = getFeatureFlag(FeatureFlags.UnichainFlashblocks) + + const flashblocksModalEnabled = getExperimentValueFromLayer< Layers.SwapPage, Experiments.UnichainFlashblocksModal, boolean @@ -60,15 +80,56 @@ export function getIsFlashblocksEnabled(chainId?: UniverseChainId): boolean { defaultValue: false, }) - // Check feature flag on all platforms - if (!flashblocksFlag) { - return false + return isFlashblocksModalEnabledForChain({ flashblocksFlagEnabled, flashblocksModalEnabled, chainId }) +} + +export function getFlashblocksExperimentStatus({ + chainId, + routing, +}: { + chainId?: UniverseChainId + routing?: TradingApi.Routing +}): { + /** Whether to log a qualifying event (swap is eligible) */ + shouldLogQualifyingEvent: boolean + /** Whether to show the flashblocks modal (treatment variant) */ + shouldShowModal: boolean +} { + // Skip routes are not part of the experiment + if (!shouldShowFlashblocksUI(routing)) { + return { shouldLogQualifyingEvent: false, shouldShowModal: false } } - // Only check experiment on interface platform - if (isWebApp && !flashblocksExperiment) { - return false + const flashblocksFlagEnabled = getFeatureFlag(FeatureFlags.UnichainFlashblocks) + const isUnichainChain = chainId === UniverseChainId.Unichain || chainId === UniverseChainId.UnichainSepolia + + if (!flashblocksFlagEnabled || !isUnichainChain) { + return { shouldLogQualifyingEvent: false, shouldShowModal: false } } - return chainId === UniverseChainId.Unichain || chainId === UniverseChainId.UnichainSepolia + // Mobile/Extension: no experiment, feature flag controls behavior + if (!isWebApp) { + return { shouldLogQualifyingEvent: false, shouldShowModal: true } + } + + // Web: experiment controls behavior + const flashblocksModalEnabled = getExperimentValueFromLayer< + Layers.SwapPage, + Experiments.UnichainFlashblocksModal, + boolean + >({ + layerName: Layers.SwapPage, + param: UnichainFlashblocksProperties.FlashblocksModalEnabled, + defaultValue: false, + }) + + return { + // TRUE for all users that reach this point, even if they're not part of the experiment. + // Statsig will later filter out non-allocated users because it applies the auto-exposure filter first, + // and then filters by users that triggered this event *after* being exposed to the experiment. + // More info: https://docs.statsig.com/statsig-warehouse-native/configuration/qualifying-events + shouldLogQualifyingEvent: true, + // TRUE for treatment variant or forced override + shouldShowModal: flashblocksModalEnabled === true, + } } diff --git a/packages/uniswap/src/features/transactions/swap/hooks/useNeedsBridgedAssetWarning.ts b/packages/uniswap/src/features/transactions/swap/hooks/useNeedsBridgedAssetWarning.ts index 1738fa4984d..9569aea3b84 100644 --- a/packages/uniswap/src/features/transactions/swap/hooks/useNeedsBridgedAssetWarning.ts +++ b/packages/uniswap/src/features/transactions/swap/hooks/useNeedsBridgedAssetWarning.ts @@ -1,5 +1,4 @@ import { useMemo } from 'react' -import { checkIsBridgedAsset } from 'uniswap/src/components/BridgedAsset/utils' import { TradeableAsset } from 'uniswap/src/entities/assets' import { CurrencyInfo } from 'uniswap/src/features/dataApi/types' import { useDismissedBridgedAssetWarnings } from 'uniswap/src/features/tokens/slice/hooks' @@ -38,19 +37,14 @@ export function useNeedsBridgedAssetWarning( outputCurrencyId && prefilledCurrencies?.some((currency) => currencyId(currency).toLowerCase() === outputCurrencyId.toLowerCase()) - if ( - inputCurrencyInfo && - !inputTokenWarningPreviouslyDismissed && - isInputPrefilled && - checkIsBridgedAsset(inputCurrencyInfo) - ) { + if (inputCurrencyInfo && !inputTokenWarningPreviouslyDismissed && isInputPrefilled && inputCurrencyInfo.isBridged) { tokens.push(inputCurrencyInfo) } if ( outputCurrencyInfo && !outputTokenWarningPreviouslyDismissed && isOutputPrefilled && - checkIsBridgedAsset(outputCurrencyInfo) + outputCurrencyInfo.isBridged ) { tokens.push(outputCurrencyInfo) } diff --git a/packages/uniswap/src/features/transactions/swap/hooks/usePriceUXEnabled.ts b/packages/uniswap/src/features/transactions/swap/hooks/usePriceUXEnabled.ts index a6ae0d406b3..cd339b827d5 100644 --- a/packages/uniswap/src/features/transactions/swap/hooks/usePriceUXEnabled.ts +++ b/packages/uniswap/src/features/transactions/swap/hooks/usePriceUXEnabled.ts @@ -1,5 +1,4 @@ -import { Experiments, Layers, PriceUxUpdateProperties } from 'uniswap/src/features/gating/experiments' -import { useExperimentValueFromLayer } from 'uniswap/src/features/gating/hooks' +import { Experiments, Layers, PriceUxUpdateProperties, useExperimentValueFromLayer } from '@universe/gating' export function usePriceUXEnabled(): boolean { const expValueFromLayer = useExperimentValueFromLayer({ diff --git a/packages/uniswap/src/features/transactions/swap/plan/planSaga.ts b/packages/uniswap/src/features/transactions/swap/plan/planSaga.ts new file mode 100644 index 00000000000..c3c8351dbcf --- /dev/null +++ b/packages/uniswap/src/features/transactions/swap/plan/planSaga.ts @@ -0,0 +1,243 @@ +import { Currency, CurrencyAmount } from '@uniswap/sdk-core' +import { PlanStepStatus, TradingApi } from '@universe/api' +import { call, delay, SagaGenerator } from 'typed-redux-saga' +import { TradingApiClient } from 'uniswap/src/data/apiClients/tradingApi/TradingApiClient' +import { getChainInfo } from 'uniswap/src/features/chains/chainInfo' +import { UnexpectedTransactionStateError } from 'uniswap/src/features/transactions/errors' +import type { + HandleApprovalStepParams, + HandleSignatureStepParams, + HandleSwapStepParams, +} from 'uniswap/src/features/transactions/steps/types' +import { TransactionStep, TransactionStepType } from 'uniswap/src/features/transactions/steps/types' +import { ExtractedBaseTradeAnalyticsProperties } from 'uniswap/src/features/transactions/swap/analytics' +import { TransactionAndPlanStep, transformSteps } from 'uniswap/src/features/transactions/swap/plan/planStepTransformer' +import { findFirstActionableStep, stepHasFinalized } from 'uniswap/src/features/transactions/swap/plan/utils' +import { SetCurrentStepFn } from 'uniswap/src/features/transactions/swap/types/swapCallback' +import { ValidatedSwapTxContext } from 'uniswap/src/features/transactions/swap/types/swapTxAndGasInfo' +import { isChained, requireRouting } from 'uniswap/src/features/transactions/swap/utils/routing' +import { SignerMnemonicAccountDetails } from 'uniswap/src/features/wallet/types/AccountDetails' +import { createSaga } from 'uniswap/src/utils/saga' +import { logger } from 'utilities/src/logger/logger' + +type SwapParams = { + selectChain: (chainId: number) => Promise + startChainId?: number + account: SignerMnemonicAccountDetails + analytics: ExtractedBaseTradeAnalyticsProperties + swapTxContext: ValidatedSwapTxContext + setCurrentStep: SetCurrentStepFn + setSteps: (steps: TransactionStep[]) => void + getOnPressRetry: (error: Error | undefined) => (() => void) | undefined + disableOneClickSwap: () => void + onSuccess: () => void + onFailure: (error?: Error, onPressRetry?: () => void) => void + onTransactionHash?: (hash: string) => void + v4Enabled: boolean +} + +type PlanCalls = { + handleApprovalTransactionStep: (params: HandleApprovalStepParams) => SagaGenerator + handleSwapTransactionStep: (params: HandleSwapStepParams) => SagaGenerator + handleSignatureStep: (params: HandleSignatureStepParams) => SagaGenerator + getDisplayableError: ({ + error, + step, + flow, + }: { + error: Error + step?: TransactionStep + flow?: string + }) => Error | undefined +} + +const MAX_ATTEMPTS = 60 + +/** + * Waits for a the target step to complete by polling the plan for the given planId and targetStepId. + * + * @returns The updated steps or no steps + */ +function* waitForStepCompletion(params: { + chainId: number + tradeId: string + targetStepId: string + currentStepIndex: number + inputAmount: CurrencyAmount +}): SagaGenerator { + const { chainId, tradeId, targetStepId, currentStepIndex, inputAmount } = params + + const pollingInterval = getChainInfo(chainId).tradingApiPollingIntervalMs + let attempt = 0 + + try { + while (attempt < MAX_ATTEMPTS) { + logger.debug('planSaga', 'waitForStepCompletion', 'waiting for step completion', { + currentStepIndex, + attempt, + maxAttempts: MAX_ATTEMPTS, + }) + + const tradeStatusResponse = yield* call(TradingApiClient.getExistingTrade, { tradeId }) + const latestTargetStep = tradeStatusResponse.steps.find((_step) => _step.stepId === targetStepId) + if (!latestTargetStep) { + throw new Error(`Target stepId=${targetStepId} not found in latest plan.`) + } + if (stepHasFinalized(latestTargetStep)) { + return transformSteps(tradeStatusResponse.steps, inputAmount) + } + attempt++ + yield* delay(pollingInterval) + } + throw new Error(`Exceeded ${MAX_ATTEMPTS} attempts waiting for step completion`) + } catch (error) { + logger.error(error, { tags: { file: 'planSaga', function: 'waitForStepCompletion' } }) + throw error + } +} + +/** + * Saga for executing a plan returned from the Trading API. This plan + * includes a list of steps to be executed in sequence in order to execute + * various actions such as a signature, approval, or swap. + * + * If a inputTradeId exists, it will use that existing plan and refresh the + * plan before beginning execution. As steps are executed, the proofs are sent + * to the TAPI to update the plan. As the steps are executed, the plan continues + * to execute the next step until all last step is confirmed. + */ +function* plan(params: SwapParams & PlanCalls) { + const { + account, + setCurrentStep, + setSteps, + swapTxContext, + analytics, + onSuccess, + onFailure, + selectChain, + handleApprovalTransactionStep, + handleSwapTransactionStep, + handleSignatureStep, + getDisplayableError, + } = params + + logger.debug('planSaga', 'plan', '🚨 plan saga started', swapTxContext) + if (!isChained(swapTxContext)) { + onFailure(new Error('Route not enabled for the plan saga')) + return + } + + const { trade, tradeId: inputTradeId } = swapTxContext + + let response + if (!inputTradeId) { + response = yield* call(TradingApiClient.fetchNewTrade, { + quote: swapTxContext.trade.quote.quote, + }) + } else { + response = yield* call(TradingApiClient.updateExistingTrade, { tradeId: inputTradeId, steps: [] }) + } + let steps: TransactionAndPlanStep[] = transformSteps(response.steps, swapTxContext.trade.inputAmount) + const tradeId = response.tradeId + + let currentStepIndex = steps.findIndex((step) => step.status !== PlanStepStatus.COMPLETE) + let currentStep = steps[currentStepIndex] + setSteps(steps) + if (currentStep) { + setCurrentStep({ step: currentStep, accepted: false }) + } + + try { + while (currentStepIndex < steps.length) { + let signature: string | undefined + let hash: string | undefined + + currentStep = steps[currentStepIndex] + const isLastStep = currentStepIndex === steps.length - 1 + + logger.debug('planSaga', 'plan', '🚨 Starting step', currentStep) + + // @ts-expect-error TODO: SWAP-458 - Temporary fix for chainId until fromChainId is finalized + const swapChainId = currentStep?.chainId || currentStep?.fromChainId || currentStep?.txRequest?.chainId + if (swapChainId) { + yield* call(selectChain, swapChainId) + } + + switch (currentStep?.type) { + case TransactionStepType.TokenRevocationTransaction: + case TransactionStepType.TokenApprovalTransaction: { + hash = yield* call(handleApprovalTransactionStep, { account, step: currentStep, setCurrentStep }) + break + } + case TransactionStepType.Permit2Signature: { + signature = yield* call(handleSignatureStep, { account, step: currentStep, setCurrentStep }) + break + } + case TransactionStepType.SwapTransaction: + case TransactionStepType.SwapTransactionAsync: { + requireRouting(trade, [TradingApi.Routing.CLASSIC, TradingApi.Routing.BRIDGE, TradingApi.Routing.CHAINED]) + hash = yield* call(handleSwapTransactionStep, { + account, + signature, + step: currentStep, + setCurrentStep, + trade, + analytics, + allowDuplicativeTx: true, + }) + break + } + default: { + throw new UnexpectedTransactionStateError(`Unexpected step type: ${currentStep?.type}`) + } + } + + if (hash || signature) { + logger.debug('planSaga', 'plan', '🚨 updating existing trade', tradeId, hash, signature) + yield* call(TradingApiClient.updateExistingTrade, { + tradeId, + steps: [{ stepId: currentStep.stepId, proof: { txHash: hash, signature } }], + }) + } else { + throw new Error('No hash or signature found.') + } + + if (isLastStep) { + yield* call(onSuccess) + return + } + + const updatedSteps: TransactionAndPlanStep[] = yield* call(waitForStepCompletion, { + chainId: swapChainId, + tradeId, + targetStepId: currentStep.stepId, + currentStepIndex, + inputAmount: swapTxContext.trade.inputAmount, + }) + logger.debug('planSaga', 'plan', '🚨 updated steps', updatedSteps) + const nextStep = findFirstActionableStep(updatedSteps) + if (nextStep) { + steps = updatedSteps + setSteps(steps) + setCurrentStep({ step: nextStep, accepted: false }) + currentStepIndex = steps.findIndex((s) => s.stepId === nextStep.stepId) + } else { + throw new Error('No next step found') + } + } + } catch (error) { + const displayableError = getDisplayableError({ + error: error instanceof Error ? error : new Error('Unknown error'), + step: currentStep, + }) + if (displayableError) { + logger.error(displayableError, { tags: { file: 'planSaga', function: 'plan' } }) + } + const onPressRetry = params.getOnPressRetry(displayableError) + onFailure(displayableError, onPressRetry) + return + } +} + +export const planSaga = createSaga(plan, 'planSaga') diff --git a/packages/uniswap/src/features/transactions/swap/plan/planStepTransformer.ts b/packages/uniswap/src/features/transactions/swap/plan/planStepTransformer.ts new file mode 100644 index 00000000000..a9b1a620b78 --- /dev/null +++ b/packages/uniswap/src/features/transactions/swap/plan/planStepTransformer.ts @@ -0,0 +1,62 @@ +import { Currency, CurrencyAmount } from '@uniswap/sdk-core' +import { Method, PlanStep } from '@universe/api' +import { createApprovalTransactionStep } from 'uniswap/src/features/transactions/steps/approve' +import { createPermit2SignatureStep } from 'uniswap/src/features/transactions/steps/permit2Signature' +import { TransactionStep } from 'uniswap/src/features/transactions/steps/types' +import { createSwapTransactionStep } from 'uniswap/src/features/transactions/swap/steps/swap' +import { + validatePermitTypeGuard, + validateTransactionRequestTypeGuard, +} from 'uniswap/src/features/transactions/swap/utils/trade' + +const ERC20_APPROVE_TX_PREFIX = '0x095ea7b3' + +export type TransactionAndPlanStep = TransactionStep & PlanStep + +export const transformStep = ( + step: PlanStep, + inputAmount: CurrencyAmount, +): TransactionAndPlanStep | undefined => { + switch (step.method) { + case Method.SIGN_MSG: + if (!validatePermitTypeGuard(step.payload)) { + return undefined + } + return { + ...step, + ...createPermit2SignatureStep(step.payload, inputAmount.currency), + } + case Method.SEND_TX: + if (!validateTransactionRequestTypeGuard(step.payload)) { + return undefined + } + if (step.payload.data?.toString().startsWith(ERC20_APPROVE_TX_PREFIX)) { + const approvalStep = createApprovalTransactionStep({ + txRequest: step.payload, + amountIn: inputAmount, + }) + if (!approvalStep) { + return undefined + } + return { + ...step, + ...approvalStep, + } + } else { + return { + ...step, + ...createSwapTransactionStep(step.payload), + } + } + // TODO: SWAP-433 - Handle send smart wallet transactions + case Method.SEND_CALLS: + default: + return undefined + } +} + +export const transformSteps = (steps: PlanStep[], inputAmount: CurrencyAmount): TransactionAndPlanStep[] => { + return steps + .map((step) => transformStep(step, inputAmount)) + .filter((step): step is TransactionAndPlanStep => step !== undefined) +} diff --git a/packages/uniswap/src/features/transactions/swap/plan/utils.ts b/packages/uniswap/src/features/transactions/swap/plan/utils.ts new file mode 100644 index 00000000000..967bcb6ba6a --- /dev/null +++ b/packages/uniswap/src/features/transactions/swap/plan/utils.ts @@ -0,0 +1,35 @@ +import { PlanStep, PlanStepStatus } from '@universe/api' +import { TransactionAndPlanStep } from 'uniswap/src/features/transactions/swap/plan/planStepTransformer' +import { ValidatedSwapTxContext } from 'uniswap/src/features/transactions/swap/types/swapTxAndGasInfo' +import { isJupiter } from 'uniswap/src/features/transactions/swap/utils/routing' + +/** Switches to the proper chain, if needed. If a chain switch is necessary and it fails, returns success=false. */ +export async function handleSwitchChains(params: { + selectChain: (chainId: number) => Promise + startChainId?: number + swapTxContext: ValidatedSwapTxContext +}): Promise<{ chainSwitchFailed: boolean }> { + const { selectChain, startChainId, swapTxContext } = params + + const swapChainId = swapTxContext.trade.inputAmount.currency.chainId + + if (isJupiter(swapTxContext) || swapChainId === startChainId) { + return { chainSwitchFailed: false } + } + + const chainSwitched = await selectChain(swapChainId) + + return { chainSwitchFailed: !chainSwitched } +} + +export function stepHasFinalized(step: PlanStep): boolean { + return step.status === PlanStepStatus.COMPLETE || step.status === PlanStepStatus.STEP_ERROR +} + +export function findFirstActionableStep(steps: T[]): T | undefined { + return steps.find((step) => step.status === PlanStepStatus.AWAITING_ACTION) +} + +export function allStepsComplete(steps: PlanStep[]): boolean { + return steps.every((step) => step.status === PlanStepStatus.COMPLETE) +} diff --git a/packages/uniswap/src/features/transactions/swap/review/hooks/useCreateSwapReviewCallbacks.tsx b/packages/uniswap/src/features/transactions/swap/review/hooks/useCreateSwapReviewCallbacks.tsx index 000086bee42..92de3528f62 100644 --- a/packages/uniswap/src/features/transactions/swap/review/hooks/useCreateSwapReviewCallbacks.tsx +++ b/packages/uniswap/src/features/transactions/swap/review/hooks/useCreateSwapReviewCallbacks.tsx @@ -95,6 +95,7 @@ export function useCreateSwapReviewCallbacks(ctx: { const onSuccess = useCallback(() => { // For Unichain networks, trigger confirmation and branch to stall+fetch logic (ie handle in component) if (isFlashblocksEnabled && shouldShowConfirmedState) { + resetCurrentStep() updateSwapForm({ isConfirmed: true, isSubmitting: false, @@ -125,7 +126,7 @@ export function useCreateSwapReviewCallbacks(ctx: { setScreen(TransactionScreen.Form) } onClose() - }, [setScreen, updateSwapForm, onClose, isFlashblocksEnabled, shouldShowConfirmedState]) + }, [setScreen, updateSwapForm, onClose, isFlashblocksEnabled, shouldShowConfirmedState, resetCurrentStep]) const onPending = useCallback(() => { // Skip pending UI only for Unichain networks with flashblocks-compatible routes diff --git a/packages/uniswap/src/features/transactions/swap/review/services/swapTxAndGasInfoService/hooks.ts b/packages/uniswap/src/features/transactions/swap/review/services/swapTxAndGasInfoService/hooks.ts index 12ba0034d82..5defe3a6ac2 100644 --- a/packages/uniswap/src/features/transactions/swap/review/services/swapTxAndGasInfoService/hooks.ts +++ b/packages/uniswap/src/features/transactions/swap/review/services/swapTxAndGasInfoService/hooks.ts @@ -1,12 +1,11 @@ import type { UseQueryResult } from '@tanstack/react-query' import { queryOptions, useQuery } from '@tanstack/react-query' import { GasStrategy, TradingApi } from '@universe/api' +import { DynamicConfigs, SwapConfigKey, useDynamicConfigValue } from '@universe/gating' import { useMemo } from 'react' import { useUniswapContext } from 'uniswap/src/contexts/UniswapContext' import type { UniverseChainId } from 'uniswap/src/features/chains/types' import { useActiveGasStrategy } from 'uniswap/src/features/gas/hooks' -import { DynamicConfigs, SwapConfigKey } from 'uniswap/src/features/gating/configs' -import { useDynamicConfigValue } from 'uniswap/src/features/gating/hooks' import type { SwapDelegationInfo } from 'uniswap/src/features/smartWallet/delegation/types' import { useAllTransactionSettings } from 'uniswap/src/features/transactions/components/settings/stores/transactionSettingsStore/useTransactionSettingsStore' import { useV4SwapEnabled } from 'uniswap/src/features/transactions/swap/hooks/useV4SwapEnabled' diff --git a/packages/uniswap/src/features/transactions/swap/stores/swapFormStore/hooks/useDerivedSwapInfo.ts b/packages/uniswap/src/features/transactions/swap/stores/swapFormStore/hooks/useDerivedSwapInfo.ts index a739bf55eda..7339325a376 100644 --- a/packages/uniswap/src/features/transactions/swap/stores/swapFormStore/hooks/useDerivedSwapInfo.ts +++ b/packages/uniswap/src/features/transactions/swap/stores/swapFormStore/hooks/useDerivedSwapInfo.ts @@ -1,10 +1,9 @@ import { TradeType } from '@uniswap/sdk-core' +import { FeatureFlags, useFeatureFlag } from '@universe/gating' import { useMemo } from 'react' import { useUniswapContextSelector } from 'uniswap/src/contexts/UniswapContext' import { useEnabledChains } from 'uniswap/src/features/chains/hooks/useEnabledChains' import { UniverseChainId } from 'uniswap/src/features/chains/types' -import { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' import { useOnChainCurrencyBalance } from 'uniswap/src/features/portfolio/api' import { getCurrencyAmount, ValueType } from 'uniswap/src/features/tokens/getCurrencyAmount' import { useCurrencyInfo } from 'uniswap/src/features/tokens/useCurrencyInfo' diff --git a/packages/uniswap/src/features/transactions/swap/stores/swapTxStore/SwapTxStoreContextProvider.tsx b/packages/uniswap/src/features/transactions/swap/stores/swapTxStore/SwapTxStoreContextProvider.tsx index 839d41380c7..601393df715 100644 --- a/packages/uniswap/src/features/transactions/swap/stores/swapTxStore/SwapTxStoreContextProvider.tsx +++ b/packages/uniswap/src/features/transactions/swap/stores/swapTxStore/SwapTxStoreContextProvider.tsx @@ -1,6 +1,5 @@ +import { FeatureFlags, useFeatureFlag } from '@universe/gating' import { useEffect, useState } from 'react' -import { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' import { useSwapTxAndGasInfo as useServiceBasedSwapTxAndGasInfo } from 'uniswap/src/features/transactions/swap/review/services/swapTxAndGasInfoService/hooks' import { useSwapFormStore } from 'uniswap/src/features/transactions/swap/stores/swapFormStore/useSwapFormStore' import { createSwapTxStore } from 'uniswap/src/features/transactions/swap/stores/swapTxStore/createSwapTxStore' diff --git a/packages/uniswap/src/features/transactions/swap/stores/swapTxStore/hooks/useTransactionRequestInfo.test.ts b/packages/uniswap/src/features/transactions/swap/stores/swapTxStore/hooks/useTransactionRequestInfo.test.ts index ca576871813..f640823efd8 100644 --- a/packages/uniswap/src/features/transactions/swap/stores/swapTxStore/hooks/useTransactionRequestInfo.test.ts +++ b/packages/uniswap/src/features/transactions/swap/stores/swapTxStore/hooks/useTransactionRequestInfo.test.ts @@ -25,9 +25,9 @@ jest.mock( useAllTransactionSettings: jest.fn(), }), ) -jest.mock('uniswap/src/features/gating/hooks', () => { +jest.mock('@universe/gating', () => { return { - ...jest.requireActual('uniswap/src/features/gating/hooks'), + ...jest.requireActual('@universe/gating'), useDynamicConfigValue: jest .fn() .mockImplementation(({ defaultValue }: { config: unknown; key: unknown; defaultValue: unknown }) => { diff --git a/packages/uniswap/src/features/transactions/swap/stores/swapTxStore/hooks/useTransactionRequestInfo.ts b/packages/uniswap/src/features/transactions/swap/stores/swapTxStore/hooks/useTransactionRequestInfo.ts index 7649e9a3f63..78f27648f34 100644 --- a/packages/uniswap/src/features/transactions/swap/stores/swapTxStore/hooks/useTransactionRequestInfo.ts +++ b/packages/uniswap/src/features/transactions/swap/stores/swapTxStore/hooks/useTransactionRequestInfo.ts @@ -1,11 +1,9 @@ import { TradingApi } from '@universe/api' +import { DynamicConfigs, SwapConfigKey, useDynamicConfigValue } from '@universe/gating' import { useEffect, useMemo, useRef } from 'react' import { useUniswapContextSelector } from 'uniswap/src/contexts/UniswapContext' import { useTradingApiSwapQuery } from 'uniswap/src/data/apiClients/tradingApi/useTradingApiSwapQuery' - import { useActiveGasStrategy } from 'uniswap/src/features/gas/hooks' -import { DynamicConfigs, SwapConfigKey } from 'uniswap/src/features/gating/configs' -import { useDynamicConfigValue } from 'uniswap/src/features/gating/hooks' import { useAllTransactionSettings } from 'uniswap/src/features/transactions/components/settings/stores/transactionSettingsStore/useTransactionSettingsStore' import { FALLBACK_SWAP_REQUEST_POLL_INTERVAL_MS } from 'uniswap/src/features/transactions/swap/review/services/swapTxAndGasInfoService/constants' import { processUniswapXResponse } from 'uniswap/src/features/transactions/swap/review/services/swapTxAndGasInfoService/uniswapx/utils' diff --git a/packages/uniswap/src/features/transactions/swap/types/trade.ts b/packages/uniswap/src/features/transactions/swap/types/trade.ts index f2c8f0910ef..6433e8dd70b 100644 --- a/packages/uniswap/src/features/transactions/swap/types/trade.ts +++ b/packages/uniswap/src/features/transactions/swap/types/trade.ts @@ -848,6 +848,7 @@ export class ChainedActionTrade { readonly indicative = false readonly tradeType: TradeType = TradeType.EXACT_INPUT readonly deadline: undefined + readonly priceImpact: undefined // depends on trade type. since exact input, max amount in is the input amount readonly maxAmountIn: CurrencyAmount diff --git a/packages/uniswap/src/features/transactions/swap/utils/getIsWebForNudgeEnabled.ts b/packages/uniswap/src/features/transactions/swap/utils/getIsWebForNudgeEnabled.ts index b83f8a7cd6a..7cb7c6fe964 100644 --- a/packages/uniswap/src/features/transactions/swap/utils/getIsWebForNudgeEnabled.ts +++ b/packages/uniswap/src/features/transactions/swap/utils/getIsWebForNudgeEnabled.ts @@ -1,5 +1,4 @@ -import { Experiments, WebFORNudgesProperties } from 'uniswap/src/features/gating/experiments' -import { getExperimentValue } from 'uniswap/src/features/gating/hooks' +import { Experiments, getExperimentValue, WebFORNudgesProperties } from '@universe/gating' import { isWebApp } from 'utilities/src/platform' export function getIsWebFORNudgeEnabled(): boolean { diff --git a/packages/uniswap/src/features/transactions/swap/utils/protocols.test.ts b/packages/uniswap/src/features/transactions/swap/utils/protocols.test.ts index f6e395ff91c..5911aa798f9 100644 --- a/packages/uniswap/src/features/transactions/swap/utils/protocols.test.ts +++ b/packages/uniswap/src/features/transactions/swap/utils/protocols.test.ts @@ -1,8 +1,7 @@ import { TradingApi } from '@universe/api' +import { FeatureFlags, getFeatureFlag } from '@universe/gating' import { createGetSupportedChainId } from 'uniswap/src/features/chains/hooks/useSupportedChainId' import { UniverseChainId } from 'uniswap/src/features/chains/types' -import { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { getFeatureFlag } from 'uniswap/src/features/gating/hooks' import { createGetV4SwapEnabled } from 'uniswap/src/features/transactions/swap/hooks/useV4SwapEnabled' import { createGetProtocolsForChain, @@ -11,7 +10,8 @@ import { FrontendSupportedProtocol, } from 'uniswap/src/features/transactions/swap/utils/protocols' -jest.mock('uniswap/src/features/gating/hooks', () => ({ +jest.mock('@universe/gating', () => ({ + ...jest.requireActual('@universe/gating'), useFeatureFlag: jest.fn(), getFeatureFlag: jest.fn(), })) diff --git a/packages/uniswap/src/features/transactions/swap/utils/protocols.ts b/packages/uniswap/src/features/transactions/swap/utils/protocols.ts index 75006c824f9..8f0fa23647f 100644 --- a/packages/uniswap/src/features/transactions/swap/utils/protocols.ts +++ b/packages/uniswap/src/features/transactions/swap/utils/protocols.ts @@ -1,11 +1,9 @@ import { TradingApi } from '@universe/api' +import { FeatureFlags, getFeatureFlag, useFeatureFlag } from '@universe/gating' import { useMemo } from 'react' import { useUniswapContextSelector } from 'uniswap/src/contexts/UniswapContext' - import { createGetSupportedChainId } from 'uniswap/src/features/chains/hooks/useSupportedChainId' import { UniverseChainId } from 'uniswap/src/features/chains/types' -import { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { getFeatureFlag, useFeatureFlag } from 'uniswap/src/features/gating/hooks' import { createGetV4SwapEnabled, useV4SwapEnabled } from 'uniswap/src/features/transactions/swap/hooks/useV4SwapEnabled' export const DEFAULT_PROTOCOL_OPTIONS = [ diff --git a/packages/uniswap/src/features/transactions/swap/utils/routing.ts b/packages/uniswap/src/features/transactions/swap/utils/routing.ts index 6ffc30ac3cd..7fb00f6f68c 100644 --- a/packages/uniswap/src/features/transactions/swap/utils/routing.ts +++ b/packages/uniswap/src/features/transactions/swap/utils/routing.ts @@ -1,6 +1,6 @@ import { ADDRESS_ZERO } from '@uniswap/v3-sdk' import { TradingApi } from '@universe/api' - +import { UnexpectedTransactionStateError } from 'uniswap/src/features/transactions/errors' import { type SwapTxAndGasInfo } from 'uniswap/src/features/transactions/swap/types/swapTxAndGasInfo' import { type ValidatedTransactionRequest } from 'uniswap/src/features/transactions/types/transactionRequests' @@ -56,6 +56,16 @@ export function getEVMTxRequest(swapTxContext: SwapTxAndGasInfo): ValidatedTrans return swapTxContext.txRequests?.[0] } +/** Asserts that a given object fits a given routing variant. */ +export function requireRouting( + val: V, + routing: readonly T[], +): asserts val is V & { routing: T } { + if (!routing.includes(val.routing as T)) { + throw new UnexpectedTransactionStateError(`Expected routing ${routing}, got ${val.routing}`) + } +} + export const ACROSS_DAPP_INFO = { name: 'Across API', address: ADDRESS_ZERO, diff --git a/packages/uniswap/src/features/transactions/swap/utils/trade.ts b/packages/uniswap/src/features/transactions/swap/utils/trade.ts index 322130dd229..83958b8a754 100644 --- a/packages/uniswap/src/features/transactions/swap/utils/trade.ts +++ b/packages/uniswap/src/features/transactions/swap/utils/trade.ts @@ -208,6 +208,12 @@ export function validateTransactionRequest( return undefined } +export function validateTransactionRequestTypeGuard( + request?: providers.TransactionRequest | null, +): request is ValidatedTransactionRequest { + return !!request?.to && !!request.chainId +} + export function validateTransactionRequests( requests?: providers.TransactionRequest[] | null, ): PopulatedTransactionRequestArray | undefined { @@ -243,6 +249,10 @@ export function validatePermit(permit: TradingApi.NullablePermit | undefined): V return undefined } +export function validatePermitTypeGuard(permit: TradingApi.NullablePermit | undefined): permit is ValidatedPermit { + return !!permit && !!permit.domain && !!permit.types && !!permit.values +} + export function hasTradeType( typeInfo: TransactionTypeInfo, ): typeInfo is ExactInputSwapTransactionInfo | ExactOutputSwapTransactionInfo { diff --git a/packages/uniswap/src/features/transactions/swap/utils/tradingApi.test.ts b/packages/uniswap/src/features/transactions/swap/utils/tradingApi.test.ts index ea189390fa8..4eeb93197d6 100644 --- a/packages/uniswap/src/features/transactions/swap/utils/tradingApi.test.ts +++ b/packages/uniswap/src/features/transactions/swap/utils/tradingApi.test.ts @@ -1,12 +1,13 @@ import { TradingApi } from '@universe/api' +import { useFeatureFlag } from '@universe/gating' import { UniverseChainId } from 'uniswap/src/features/chains/types' -import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' import type { FrontendSupportedProtocol } from 'uniswap/src/features/transactions/swap/utils/protocols' import { useProtocolsForChain } from 'uniswap/src/features/transactions/swap/utils/protocols' import { useQuoteRoutingParams } from 'uniswap/src/features/transactions/swap/utils/tradingApi' import { renderHook } from 'uniswap/src/test/test-utils' -jest.mock('uniswap/src/features/gating/hooks', () => ({ +jest.mock('@universe/gating', () => ({ + ...jest.requireActual('@universe/gating'), useFeatureFlag: jest.fn(), })) jest.mock('uniswap/src/features/transactions/swap/utils/protocols', () => ({ diff --git a/packages/uniswap/src/features/transactions/swap/utils/tradingApi.ts b/packages/uniswap/src/features/transactions/swap/utils/tradingApi.ts index 42610ca00ab..c4e0bd27bc0 100644 --- a/packages/uniswap/src/features/transactions/swap/utils/tradingApi.ts +++ b/packages/uniswap/src/features/transactions/swap/utils/tradingApi.ts @@ -8,12 +8,11 @@ import type { FeeAmount } from '@uniswap/v3-sdk' import { Pool as V3Pool, Route as V3Route } from '@uniswap/v3-sdk' import { Pool as V4Pool, Route as V4Route } from '@uniswap/v4-sdk' import { type ClassicQuoteResponse, type DiscriminatedQuoteResponse, TradingApi } from '@universe/api' +import { DynamicConfigs, getDynamicConfigValue, SwapConfigKey } from '@universe/gating' import { nativeOnChain } from 'uniswap/src/constants/tokens' import { getChainInfo } from 'uniswap/src/features/chains/chainInfo' import type { UniverseChainId } from 'uniswap/src/features/chains/types' import { isUniverseChainId } from 'uniswap/src/features/chains/utils' -import { DynamicConfigs, SwapConfigKey } from 'uniswap/src/features/gating/configs' -import { getDynamicConfigValue } from 'uniswap/src/features/gating/hooks' import { getCurrencyAmount, ValueType } from 'uniswap/src/features/tokens/getCurrencyAmount' import type { Trade } from 'uniswap/src/features/transactions/swap/types/trade' import { diff --git a/packages/uniswap/src/features/unitags/ClaimUnitagContent.tsx b/packages/uniswap/src/features/unitags/ClaimUnitagContent.tsx index a170222c2c2..86e557aceb0 100644 --- a/packages/uniswap/src/features/unitags/ClaimUnitagContent.tsx +++ b/packages/uniswap/src/features/unitags/ClaimUnitagContent.tsx @@ -131,13 +131,7 @@ export function ClaimUnitagContent({ }) return unsubscribe - }, [ - navigationEventConsumer, - showTextInputView, - addressViewOpacity, - unitagInputContainerTranslateY, - focusUnitagTextInput, - ]) + }, [navigationEventConsumer, showTextInputView, focusUnitagTextInput]) const onChangeTextInput = useCallback( (text: string): void => { @@ -196,15 +190,7 @@ export function ClaimUnitagContent({ } }, initialDelay + translateYDuration) }, - [ - onComplete, - onNavigateContinue, - addressViewOpacity, - entryPoint, - unitagAddress, - unitagInputContainerTranslateY, - fontSize, - ], + [onComplete, onNavigateContinue, entryPoint, unitagAddress, fontSize], ) useEffect(() => { diff --git a/packages/uniswap/src/i18n/locales/source/en-US.json b/packages/uniswap/src/i18n/locales/source/en-US.json index aaa13e3aee8..21e6ace68d0 100644 --- a/packages/uniswap/src/i18n/locales/source/en-US.json +++ b/packages/uniswap/src/i18n/locales/source/en-US.json @@ -144,14 +144,15 @@ "bridgedAsset.send.warning.description": "You’re sending a wrapped version of {{currencySymbol}} on {{chainName}}. Sending it to a centralized exchange will result in a permanent loss of funds.", "bridgedAsset.send.warning.title": "Make sure you’re sending to a compatible address", "bridgedAsset.tdp.description": "This is a bridged version of {{currencySymbol}} that is 1:1 backed by native {{currencySymbol}}.", - "bridgedAsset.wormhole.button": "Continue to Wormhole", - "bridgedAsset.wormhole.description": "Continue to the Wormhole portal to bridge your {{currencySymbol}} from {{chainName}} to {{nativeChainName}}.", + "bridgedAsset.wormhole.button": "Continue to {{provider}}", + "bridgedAsset.wormhole.description": "Continue to the {{provider}} portal to bridge your {{currencySymbol}} from {{chainName}} to {{nativeChainName}}.", "bridgedAsset.wormhole.title": "Withdraw {{currencySymbol}} to {{nativeChainName}}", "bridgedAsset.wormhole.toNativeChain": "to {{nativeChainName}}", "bridgedAsset.wormhole.withdrawToNativeChain": "Withdraw to {{nativeChainName}}", "bridging.estimatedTime.minutesAndSeconds": "~{{minutes}}min {{seconds}}s", "bridging.estimatedTime.minutesOnly": "~{{minutes}}min", "bridging.estimatedTime.secondsOnly": "~{{seconds}}s", + "bridgingPopularTokens.banner.description": "DOGE, XRP, XLP — now available on Unichain.", "chart.candlestick": "Candlestick", "chart.error.pools": "Unable to display historical data for the current pool.", "chart.error.tokens": "Unable to display historical data for the current token.", @@ -1437,17 +1438,32 @@ "poolFinder.availablePools.notFound.description": "No matching v2 pools found. Double-check your token selection and ensure you’re connected to the correct wallet.", "pools.explore": "Explore pools", "portfolio.activity.filters.timePeriod.all": "All time", + "portfolio.activity.filters.transactionType.addLiquidity": "Add Liquidity", "portfolio.activity.filters.transactionType.all": "All types", - "portfolio.activity.filters.transactionType.deposits": "Deposits", - "portfolio.activity.filters.transactionType.staking": "Staking", + "portfolio.activity.filters.transactionType.approvals": "Approvals", + "portfolio.activity.filters.transactionType.claimFees": "Claim Fees", + "portfolio.activity.filters.transactionType.createPool": "Create Pool", + "portfolio.activity.filters.transactionType.mints": "Mints", + "portfolio.activity.filters.transactionType.removeLiquidity": "Remove Liquidity", "portfolio.activity.filters.transactionType.swaps": "Swaps", + "portfolio.activity.filters.transactionType.wraps": "Wraps", + "portfolio.activity.table.column.address": "Address", + "portfolio.activity.table.column.amount": "Amount", + "portfolio.activity.table.column.time": "Time", + "portfolio.activity.table.column.type": "Type", "portfolio.activity.title": "Activity", - "portfolio.connectWallet.summary": "Track tokens, pools, and more across {{amount}}+ networks", "portfolio.defi.title": "DeFi", "portfolio.description": "Track your crypto portfolio across all chains and protocols", + "portfolio.disconnected.connectWallet.cta": "Track your portfolio", + "portfolio.disconnected.cta.description": "Track tokens, positions, NFTs and more across {{numNetworks}}+ networks", + "portfolio.disconnected.demoWallet.description": "This is a view-only example. Connect your wallet to manage your portfolio.", + "portfolio.disconnected.demoWallet.title": "Demo wallet", + "portfolio.disconnected.viewYourPortfolio.cta": "to view your portfolio", "portfolio.nfts.title": "NFTs", "portfolio.overview.title": "Overview", "portfolio.title": "Portfolio", + "portfolio.tokens.balance.totalTokens": "{{numTokens}} tokens", + "portfolio.tokens.emptyState": "No tokens", "portfolio.tokens.table.column.allocation": "Allocation", "portfolio.tokens.table.column.balance": "Balance", "portfolio.tokens.table.column.change1d": "1D Change", diff --git a/packages/uniswap/src/i18n/locales/translations/af-ZA.json b/packages/uniswap/src/i18n/locales/translations/af-ZA.json index 40119ecf73d..d714754e9dc 100644 --- a/packages/uniswap/src/i18n/locales/translations/af-ZA.json +++ b/packages/uniswap/src/i18n/locales/translations/af-ZA.json @@ -1675,6 +1675,7 @@ "settings.action.lock": "Sluit beursie", "settings.action.privacy": "Privaatheidsbeleid", "settings.action.terms": "Diensbepalings", + "settings.connectWalletPlatform.warning": "Om Uniswap op {{platform}}te gebruik, koppel aan 'n beursie wat {{platform}}ondersteun.", "settings.footer": "Met liefde gemaak, \nUniswap-span 🦄", "settings.hideSmallBalances": "Versteek klein saldo's", "settings.hideSmallBalances.subtitle": "Saldo's onder 1 USD sal van jou portefeulje weggesteek word.", @@ -1845,7 +1846,7 @@ "smartWallets.postSwapNudge.title.dapp": "Hierdie toepassing ondersteun slim beursies", "smartWallets.unavailableModal.description": "'n Ander beursieverskaffer bestuur nou slim beursie-instellings vir {{displayName}}. Jy kan Uniswap soos normaalweg voortgaan om te gebruik.", "smartWallets.unavailableModal.title": "Slim beursie-kenmerke is nie beskikbaar nie", - "solanaPromo.banner.description": "Ruil Solana-tokens direk op die Uniswap-webtoepassing.", + "solanaPromo.banner.description": "Ruil Solana-tokens direk op Uniswap.", "solanaPromo.banner.title": "Solana is nou beskikbaar", "solanaPromo.modal.connectWallet": "Koppel jou gunsteling Solana-beursie", "solanaPromo.modal.startSwapping.button": "Begin omruil op Solana", diff --git a/packages/uniswap/src/i18n/locales/translations/ar-SA.json b/packages/uniswap/src/i18n/locales/translations/ar-SA.json index 3e628872b83..a10e2e0aef2 100644 --- a/packages/uniswap/src/i18n/locales/translations/ar-SA.json +++ b/packages/uniswap/src/i18n/locales/translations/ar-SA.json @@ -1675,6 +1675,7 @@ "settings.action.lock": "قفل المحفظة", "settings.action.privacy": "سياسة الخصوصية", "settings.action.terms": "شروط الخدمة", + "settings.connectWalletPlatform.warning": "لاستخدام Uniswap على {{platform}}، قم بالاتصال بمحفظة تدعم {{platform}}.", "settings.footer": "صُنع بكل حب، \nفريق Uniswap 🦄", "settings.hideSmallBalances": "إخفاء الأرصدة الصغيرة", "settings.hideSmallBalances.subtitle": "سيتم إخفاء الأرصدة التي تقل عن 1 دولار أمريكي من محفظتك.", @@ -1845,7 +1846,7 @@ "smartWallets.postSwapNudge.title.dapp": "يدعم هذا التطبيق المحافظ الذكية", "smartWallets.unavailableModal.description": "يُدير موفر محفظة آخر إعدادات المحفظة الذكية لـ {{displayName}}. يمكنك الاستمرار في استخدام Uniswap كالمعتاد.", "smartWallets.unavailableModal.title": "ميزات المحفظة الذكية غير متوفرة", - "solanaPromo.banner.description": "قم بتداول رموز Solana مباشرة على تطبيق Uniswap Web App.", + "solanaPromo.banner.description": "قم بتداول رموز Solana مباشرة على Uniswap.", "solanaPromo.banner.title": "سولانا متاحة الآن", "solanaPromo.modal.connectWallet": "قم بتوصيل محفظة Solana المفضلة لديك", "solanaPromo.modal.startSwapping.button": "ابدأ بالتبديل على سولانا", diff --git a/packages/uniswap/src/i18n/locales/translations/ca-ES.json b/packages/uniswap/src/i18n/locales/translations/ca-ES.json index 7bd08ae2841..3fecdb8ce51 100644 --- a/packages/uniswap/src/i18n/locales/translations/ca-ES.json +++ b/packages/uniswap/src/i18n/locales/translations/ca-ES.json @@ -1675,6 +1675,7 @@ "settings.action.lock": "Bloqueja la cartera", "settings.action.privacy": "Política de privacitat", "settings.action.terms": "Termes del servei", + "settings.connectWalletPlatform.warning": "Per utilitzar Uniswap a {{platform}}, connecteu-vos a un moneder que admeti {{platform}}.", "settings.footer": "Fet amb amor, \nUniswap Team 🦄", "settings.hideSmallBalances": "Amaga petits saldos", "settings.hideSmallBalances.subtitle": "Els saldos inferiors a 1 USD s'amagaran de la vostra cartera.", @@ -1845,7 +1846,7 @@ "smartWallets.postSwapNudge.title.dapp": "Aquesta aplicació admet moneders intel·ligents", "smartWallets.unavailableModal.description": "Un proveïdor de moneders diferent ara gestiona la configuració del moneder intel·ligent per a {{displayName}}. Podeu continuar utilitzant Uniswap com sempre.", "smartWallets.unavailableModal.title": "Les funcions de la cartera intel·ligent no estan disponibles", - "solanaPromo.banner.description": "Intercanvia tokens de Solana directament a l'aplicació web Uniswap.", + "solanaPromo.banner.description": "Intercanvia fitxes de Solana directament a Uniswap.", "solanaPromo.banner.title": "Solana ja està disponible", "solanaPromo.modal.connectWallet": "Connecta la teva cartera Solana preferida", "solanaPromo.modal.startSwapping.button": "Comença a intercanviar a Solana", diff --git a/packages/uniswap/src/i18n/locales/translations/da-DK.json b/packages/uniswap/src/i18n/locales/translations/da-DK.json index 6b105874916..f084b805e68 100644 --- a/packages/uniswap/src/i18n/locales/translations/da-DK.json +++ b/packages/uniswap/src/i18n/locales/translations/da-DK.json @@ -1675,6 +1675,7 @@ "settings.action.lock": "Lås pung", "settings.action.privacy": "Fortrolighedspolitik", "settings.action.terms": "Servicevilkår", + "settings.connectWalletPlatform.warning": "For at bruge Uniswap på {{platform}}skal du oprette forbindelse til en tegnebog, der understøtter {{platform}}.", "settings.footer": "Lavet med kærlighed, \nUniswap Team 🦄", "settings.hideSmallBalances": "Skjul små saldi", "settings.hideSmallBalances.subtitle": "Saldi under 1 USD vil blive skjult fra din portefølje.", @@ -1845,7 +1846,7 @@ "smartWallets.postSwapNudge.title.dapp": "Denne app understøtter smarte tegnebøger", "smartWallets.unavailableModal.description": "En anden wallet-udbyder administrerer nu smart wallet-indstillinger for {{displayName}}. Du kan fortsætte med at bruge Uniswap som normalt.", "smartWallets.unavailableModal.title": "Smart wallet-funktioner er ikke tilgængelige", - "solanaPromo.banner.description": "Handl Solana-tokens direkte på Uniswap-webappen.", + "solanaPromo.banner.description": "Handl Solana-tokens direkte på Uniswap.", "solanaPromo.banner.title": "Solana er nu tilgængelig", "solanaPromo.modal.connectWallet": "Tilslut din foretrukne Solana-pung", "solanaPromo.modal.startSwapping.button": "Begynd at bytte på Solana", diff --git a/packages/uniswap/src/i18n/locales/translations/el-GR.json b/packages/uniswap/src/i18n/locales/translations/el-GR.json index 54df5b70ffa..2bae3774d7b 100644 --- a/packages/uniswap/src/i18n/locales/translations/el-GR.json +++ b/packages/uniswap/src/i18n/locales/translations/el-GR.json @@ -1675,6 +1675,7 @@ "settings.action.lock": "Κλείδωμα πορτοφολιού", "settings.action.privacy": "Πολιτική απορρήτου", "settings.action.terms": "Οροι χρήσης", + "settings.connectWalletPlatform.warning": "Για να χρησιμοποιήσετε το Uniswap στο {{platform}}, συνδεθείτε σε ένα πορτοφόλι που υποστηρίζει το {{platform}}.", "settings.footer": "Φτιαγμένο με αγάπη, \nUniswap Team 🦄", "settings.hideSmallBalances": "Απόκρυψη μικρών υπολοίπων", "settings.hideSmallBalances.subtitle": "Υπόλοιπα κάτω του 1 USD θα κρυφτούν από το χαρτοφυλάκιό σας.", @@ -1845,7 +1846,7 @@ "smartWallets.postSwapNudge.title.dapp": "Αυτή η εφαρμογή υποστηρίζει έξυπνα πορτοφόλια", "smartWallets.unavailableModal.description": "Ένας διαφορετικός πάροχος πορτοφολιού διαχειρίζεται πλέον τις ρυθμίσεις έξυπνου πορτοφολιού για το {{displayName}}. Μπορείτε να συνεχίσετε να χρησιμοποιείτε το Uniswap κανονικά.", "smartWallets.unavailableModal.title": "Οι λειτουργίες έξυπνου πορτοφολιού δεν είναι διαθέσιμες", - "solanaPromo.banner.description": "Ανταλλάξτε μάρκες Solana απευθείας στην εφαρμογή Uniswap Web.", + "solanaPromo.banner.description": "Ανταλλάξτε μάρκες Solana απευθείας στο Uniswap.", "solanaPromo.banner.title": "Η Σολάνα είναι τώρα διαθέσιμη", "solanaPromo.modal.connectWallet": "Συνδέστε το αγαπημένο σας πορτοφόλι Solana", "solanaPromo.modal.startSwapping.button": "Ξεκινήστε την ανταλλαγή στο Solana", diff --git a/packages/uniswap/src/i18n/locales/translations/es-ES.json b/packages/uniswap/src/i18n/locales/translations/es-ES.json index 6cd7dbb7db4..d7c2473c9d3 100644 --- a/packages/uniswap/src/i18n/locales/translations/es-ES.json +++ b/packages/uniswap/src/i18n/locales/translations/es-ES.json @@ -152,6 +152,7 @@ "bridging.estimatedTime.minutesAndSeconds": "~{{minutes}} min {{seconds}} s", "bridging.estimatedTime.minutesOnly": "~{{minutes}} min", "bridging.estimatedTime.secondsOnly": "~{{seconds}} s", + "bridgingPopularTokens.banner.description": "DOGE, XRP, XLP — now available on Unichain.", "chart.candlestick": "Gráfico de velas", "chart.error.pools": "No se pueden mostrar los datos históricos del fondo actual.", "chart.error.tokens": "No se pueden mostrar los datos históricos del token actual.", @@ -1437,17 +1438,32 @@ "poolFinder.availablePools.notFound.description": "No se encontraron fondos v2 que coincidan. Vuelve a verificar la selección de tokens y asegúrate de estar conectado a la billetera correcta.", "pools.explore": "Explorar los fondos", "portfolio.activity.filters.timePeriod.all": "Historial", + "portfolio.activity.filters.transactionType.addLiquidity": "Add Liquidity", "portfolio.activity.filters.transactionType.all": "Todos los tipos", - "portfolio.activity.filters.transactionType.deposits": "Depósitos", - "portfolio.activity.filters.transactionType.staking": "Staking", + "portfolio.activity.filters.transactionType.approvals": "Approvals", + "portfolio.activity.filters.transactionType.claimFees": "Claim Fees", + "portfolio.activity.filters.transactionType.createPool": "Create Pool", + "portfolio.activity.filters.transactionType.mints": "Mints", + "portfolio.activity.filters.transactionType.removeLiquidity": "Remove Liquidity", "portfolio.activity.filters.transactionType.swaps": "Intercambios", + "portfolio.activity.filters.transactionType.wraps": "Wraps", + "portfolio.activity.table.column.address": "Address", + "portfolio.activity.table.column.amount": "Amount", + "portfolio.activity.table.column.time": "Time", + "portfolio.activity.table.column.type": "Type", "portfolio.activity.title": "Actividad", - "portfolio.connectWallet.summary": "Track tokens, pools, and more across {{amount}}+ networks", "portfolio.defi.title": "DeFi", "portfolio.description": "Rastrea tu cartera de criptomonedas en todas las cadenas y protocolos", + "portfolio.disconnected.connectWallet.cta": "Track your portfolio", + "portfolio.disconnected.cta.description": "Track tokens, positions, NFTs and more across {{numNetworks}}+ networks", + "portfolio.disconnected.demoWallet.description": "This is a view-only example. Connect your wallet to manage your portfolio.", + "portfolio.disconnected.demoWallet.title": "Demo wallet", + "portfolio.disconnected.viewYourPortfolio.cta": "to view your portfolio", "portfolio.nfts.title": "NFT", "portfolio.overview.title": "Resumen", "portfolio.title": "Cartera", + "portfolio.tokens.balance.totalTokens": "{{numTokens}} tokens", + "portfolio.tokens.emptyState": "No tokens", "portfolio.tokens.table.column.allocation": "Asignación", "portfolio.tokens.table.column.balance": "Saldo", "portfolio.tokens.table.column.change1d": "Variación en las últimas 24 horas", @@ -2505,6 +2521,12 @@ "uwulink.error.insufficientTokens": "No hay suficientes {{tokenSymbol}} en {{chain}}", "v2.notAvailable": "Uniswap V2 no está disponible en esta red.", "wallet.appSignIn": "Ingresar con la app", + "wallet.connecting.description": "Complete connection in your wallet", + "wallet.connecting.solanaPrompt": "Use Solana on Uniswap", + "wallet.connecting.solanaPrompt.button": "Use Solana", + "wallet.connecting.solanaPrompt.description": "You’ll be asked to connect again in MetaMask", + "wallet.connecting.title.evm": "Connect to {{walletName}}", + "wallet.connecting.title.svm": "Connect to {{walletName}} on Solana", "wallet.connectingAgreement": "Si conectas una billetera, aceptas las Condiciones del servicio de Uniswap Labs y consientes en su Política de privacidad.", "wallet.connectionFailed.message": "Connection attempt failed. Please try again, following the steps to connect in your wallet.", "wallet.mismatch.popup.description": "La billetera que tienes conectada no es compatible con algunas funciones.", diff --git a/packages/uniswap/src/i18n/locales/translations/fi-FI.json b/packages/uniswap/src/i18n/locales/translations/fi-FI.json index 659ba918272..9dd03a9a09d 100644 --- a/packages/uniswap/src/i18n/locales/translations/fi-FI.json +++ b/packages/uniswap/src/i18n/locales/translations/fi-FI.json @@ -1675,6 +1675,7 @@ "settings.action.lock": "Lukittava lompakko", "settings.action.privacy": "Tietosuojakäytäntö", "settings.action.terms": "Käyttöehdot", + "settings.connectWalletPlatform.warning": "Käyttääksesi Uniswapia {{platform}}:ssä, muodosta yhteys lompakkoon, joka tukee {{platform}}:ää.", "settings.footer": "Tehty rakkaudella, \nUniswap Team 🦄", "settings.hideSmallBalances": "Piilota pienet saldot", "settings.hideSmallBalances.subtitle": "Alle 1 USD:n saldot piilotetaan salkustasi.", @@ -1845,7 +1846,7 @@ "smartWallets.postSwapNudge.title.dapp": "Tämä sovellus tukee älykkäitä lompakoita", "smartWallets.unavailableModal.description": "Eri lompakkopalveluntarjoaja hallinnoi nyt {{displayName}}:n älylompakkoasetuksia. Voit jatkaa Uniswapin käyttöä normaalisti.", "smartWallets.unavailableModal.title": "Älykäs lompakon ominaisuudet eivät ole käytettävissä", - "solanaPromo.banner.description": "Vaihda Solana-tokeneita suoraan Uniswap-verkkosovelluksessa.", + "solanaPromo.banner.description": "Vaihda Solana-tokeneita suoraan Uniswapissa.", "solanaPromo.banner.title": "Solana on nyt saatavilla", "solanaPromo.modal.connectWallet": "Yhdistä suosikki Solana-lompakkosi", "solanaPromo.modal.startSwapping.button": "Aloita vaihtaminen Solanan kanssa", diff --git a/packages/uniswap/src/i18n/locales/translations/fil-PH.json b/packages/uniswap/src/i18n/locales/translations/fil-PH.json index 323e3c414ee..047dae48f21 100644 --- a/packages/uniswap/src/i18n/locales/translations/fil-PH.json +++ b/packages/uniswap/src/i18n/locales/translations/fil-PH.json @@ -152,6 +152,7 @@ "bridging.estimatedTime.minutesAndSeconds": "~{{minutes}}min {{seconds}}s", "bridging.estimatedTime.minutesOnly": "~{{minutes}}min", "bridging.estimatedTime.secondsOnly": "~{{seconds}}s", + "bridgingPopularTokens.banner.description": "DOGE, XRP, XLP — now available on Unichain.", "chart.candlestick": "Candlestick", "chart.error.pools": "Hindi maipakita ang dating data para sa kasalukuyang pool.", "chart.error.tokens": "Hindi maipakita ang dating data para sa kasalukuyang token.", @@ -1437,17 +1438,32 @@ "poolFinder.availablePools.notFound.description": "Walang nakitang v2 pool na tumutugma. I-double check ang iyong napiling token at tiyaking nakakonekta ka sa tamang wallet.", "pools.explore": "I-explore ang mga pool", "portfolio.activity.filters.timePeriod.all": "Lahat ng oras", + "portfolio.activity.filters.transactionType.addLiquidity": "Add Liquidity", "portfolio.activity.filters.transactionType.all": "Lahat ng uri", - "portfolio.activity.filters.transactionType.deposits": "Mga Deposito", - "portfolio.activity.filters.transactionType.staking": "Staking", + "portfolio.activity.filters.transactionType.approvals": "Approvals", + "portfolio.activity.filters.transactionType.claimFees": "Claim Fees", + "portfolio.activity.filters.transactionType.createPool": "Create Pool", + "portfolio.activity.filters.transactionType.mints": "Mints", + "portfolio.activity.filters.transactionType.removeLiquidity": "Remove Liquidity", "portfolio.activity.filters.transactionType.swaps": "Mga Swap", + "portfolio.activity.filters.transactionType.wraps": "Wraps", + "portfolio.activity.table.column.address": "Address", + "portfolio.activity.table.column.amount": "Amount", + "portfolio.activity.table.column.time": "Time", + "portfolio.activity.table.column.type": "Type", "portfolio.activity.title": "Aktibidad", - "portfolio.connectWallet.summary": "Track tokens, pools, and more across {{amount}}+ networks", "portfolio.defi.title": "DeFi", "portfolio.description": "I-track ang iyong crypto portfolio sa lahat ng chain at protocol", + "portfolio.disconnected.connectWallet.cta": "Track your portfolio", + "portfolio.disconnected.cta.description": "Track tokens, positions, NFTs and more across {{numNetworks}}+ networks", + "portfolio.disconnected.demoWallet.description": "This is a view-only example. Connect your wallet to manage your portfolio.", + "portfolio.disconnected.demoWallet.title": "Demo wallet", + "portfolio.disconnected.viewYourPortfolio.cta": "to view your portfolio", "portfolio.nfts.title": "Mga NFT", "portfolio.overview.title": "Pangkalahatang-ideya", "portfolio.title": "Portfolio", + "portfolio.tokens.balance.totalTokens": "{{numTokens}} tokens", + "portfolio.tokens.emptyState": "No tokens", "portfolio.tokens.table.column.allocation": "Alokasyon", "portfolio.tokens.table.column.balance": "Balanse", "portfolio.tokens.table.column.change1d": "Pagbabago sa 1D", @@ -2505,6 +2521,12 @@ "uwulink.error.insufficientTokens": "Hindi sapat ang {{tokenSymbol}} sa {{chain}}", "v2.notAvailable": "Hindi available ang Uniswap V2 sa network na ito.", "wallet.appSignIn": "Mag-sign in gamit ang app", + "wallet.connecting.description": "Complete connection in your wallet", + "wallet.connecting.solanaPrompt": "Use Solana on Uniswap", + "wallet.connecting.solanaPrompt.button": "Use Solana", + "wallet.connecting.solanaPrompt.description": "You’ll be asked to connect again in MetaMask", + "wallet.connecting.title.evm": "Connect to {{walletName}}", + "wallet.connecting.title.svm": "Connect to {{walletName}} on Solana", "wallet.connectingAgreement": "Sa pamamagitan ng pagkonekta ng wallet, sumasang-ayon ka sa Mga Tuntunin ng Serbisyo ng Uniswap Labs at pumapayag ka sa Patakaran sa Privacy nito.", "wallet.connectionFailed.message": "Connection attempt failed. Please try again, following the steps to connect in your wallet.", "wallet.mismatch.popup.description": "Ang ilang feature ay hindi sinusuportahan ng iyong nakakonektang wallet.", diff --git a/packages/uniswap/src/i18n/locales/translations/fr-FR.json b/packages/uniswap/src/i18n/locales/translations/fr-FR.json index 0110021626c..87a016d4648 100644 --- a/packages/uniswap/src/i18n/locales/translations/fr-FR.json +++ b/packages/uniswap/src/i18n/locales/translations/fr-FR.json @@ -152,6 +152,7 @@ "bridging.estimatedTime.minutesAndSeconds": "Env. {{minutes}} min {{seconds}} s", "bridging.estimatedTime.minutesOnly": "Env. {{minutes}} min", "bridging.estimatedTime.secondsOnly": "Env. {{seconds}} s", + "bridgingPopularTokens.banner.description": "DOGE, XRP, XLP — now available on Unichain.", "chart.candlestick": "Chandelier", "chart.error.pools": "Impossible d'afficher les données historiques du pool actuel.", "chart.error.tokens": "Impossible d'afficher les données historiques du token actuel.", @@ -1437,17 +1438,32 @@ "poolFinder.availablePools.notFound.description": "Aucun pool V2 ne correspond à votre sélection. Vérifiez le(s) token(s) sélectionné(s) et assurez-vous d’être connecté au bon wallet.", "pools.explore": "Découvrir les pools", "portfolio.activity.filters.timePeriod.all": "Toujours", + "portfolio.activity.filters.transactionType.addLiquidity": "Add Liquidity", "portfolio.activity.filters.transactionType.all": "Tous les types", - "portfolio.activity.filters.transactionType.deposits": "Dépôts", - "portfolio.activity.filters.transactionType.staking": "Staking", + "portfolio.activity.filters.transactionType.approvals": "Approvals", + "portfolio.activity.filters.transactionType.claimFees": "Claim Fees", + "portfolio.activity.filters.transactionType.createPool": "Create Pool", + "portfolio.activity.filters.transactionType.mints": "Mints", + "portfolio.activity.filters.transactionType.removeLiquidity": "Remove Liquidity", "portfolio.activity.filters.transactionType.swaps": "Échanges", + "portfolio.activity.filters.transactionType.wraps": "Wraps", + "portfolio.activity.table.column.address": "Address", + "portfolio.activity.table.column.amount": "Amount", + "portfolio.activity.table.column.time": "Time", + "portfolio.activity.table.column.type": "Type", "portfolio.activity.title": "Activité", - "portfolio.connectWallet.summary": "Track tokens, pools, and more across {{amount}}+ networks", "portfolio.defi.title": "DeFi", "portfolio.description": "Suivez votre portefeuille de crypto à travers toutes les chaînes et protocoles", + "portfolio.disconnected.connectWallet.cta": "Track your portfolio", + "portfolio.disconnected.cta.description": "Track tokens, positions, NFTs and more across {{numNetworks}}+ networks", + "portfolio.disconnected.demoWallet.description": "This is a view-only example. Connect your wallet to manage your portfolio.", + "portfolio.disconnected.demoWallet.title": "Demo wallet", + "portfolio.disconnected.viewYourPortfolio.cta": "to view your portfolio", "portfolio.nfts.title": "NFT", "portfolio.overview.title": "Aperçu", "portfolio.title": "Portefeuille", + "portfolio.tokens.balance.totalTokens": "{{numTokens}} tokens", + "portfolio.tokens.emptyState": "No tokens", "portfolio.tokens.table.column.allocation": "Allocation", "portfolio.tokens.table.column.balance": "Solde", "portfolio.tokens.table.column.change1d": "Évolution sur 1 j", @@ -2505,6 +2521,12 @@ "uwulink.error.insufficientTokens": "Pas assez de {{tokenSymbol}} sur {{chain}}", "v2.notAvailable": "Uniswap V2 n'est pas disponible sur ce réseau.", "wallet.appSignIn": "Se connecter avec l'app", + "wallet.connecting.description": "Complete connection in your wallet", + "wallet.connecting.solanaPrompt": "Use Solana on Uniswap", + "wallet.connecting.solanaPrompt.button": "Use Solana", + "wallet.connecting.solanaPrompt.description": "You’ll be asked to connect again in MetaMask", + "wallet.connecting.title.evm": "Connect to {{walletName}}", + "wallet.connecting.title.svm": "Connect to {{walletName}} on Solana", "wallet.connectingAgreement": "En connectant un wallet, vous acceptez les Conditions d'utilisation d'Uniswap Labs et vous consentez à sa Politique de confidentialité.", "wallet.connectionFailed.message": "Connection attempt failed. Please try again, following the steps to connect in your wallet.", "wallet.mismatch.popup.description": "Certaines fonctionnalités ne sont pas prises en charge par votre wallet connecté.", diff --git a/packages/uniswap/src/i18n/locales/translations/he-IL.json b/packages/uniswap/src/i18n/locales/translations/he-IL.json index 5c183c131d7..ece09b33c76 100644 --- a/packages/uniswap/src/i18n/locales/translations/he-IL.json +++ b/packages/uniswap/src/i18n/locales/translations/he-IL.json @@ -1675,6 +1675,7 @@ "settings.action.lock": "מנעול ארנק", "settings.action.privacy": "מדיניות הפרטיות", "settings.action.terms": "תנאי השירות", + "settings.connectWalletPlatform.warning": "כדי להשתמש ב-Uniswap ב- {{platform}}, התחבר לארנק שתומך ב- {{platform}}.", "settings.footer": "מיוצר באהבה, \nצוות Uniswap 🦄", "settings.hideSmallBalances": "הסתר יתרות קטנות", "settings.hideSmallBalances.subtitle": "יתרות מתחת ל-1 USD יוסתרו מהתיק שלך.", @@ -1845,7 +1846,7 @@ "smartWallets.postSwapNudge.title.dapp": "אפליקציה זו תומכת בארנקים חכמים", "smartWallets.unavailableModal.description": "ספק ארנק אחר מנהל כעת את הגדרות הארנק החכם עבור {{displayName}}. ניתן להמשיך להשתמש ב-Uniswap כרגיל.", "smartWallets.unavailableModal.title": "תכונות הארנק החכם אינן זמינות", - "solanaPromo.banner.description": "סחרו באסימוני סולאנה ישירות באפליקציית האינטרנט של Uniswap.", + "solanaPromo.banner.description": "סחרו באסימוני סולאנה ישירות ב-Uniswap.", "solanaPromo.banner.title": "סולאנה זמינה כעת", "solanaPromo.modal.connectWallet": "חבר את ארנק סולאנה המועדף עליך", "solanaPromo.modal.startSwapping.button": "התחל להחליף על סולאנה", diff --git a/packages/uniswap/src/i18n/locales/translations/hi-IN.json b/packages/uniswap/src/i18n/locales/translations/hi-IN.json index ca00c6fb653..8ceab52826a 100644 --- a/packages/uniswap/src/i18n/locales/translations/hi-IN.json +++ b/packages/uniswap/src/i18n/locales/translations/hi-IN.json @@ -1675,6 +1675,7 @@ "settings.action.lock": "बटुआ बंद करो", "settings.action.privacy": "गोपनीयता नीति", "settings.action.terms": "सेवा की शर्तें", + "settings.connectWalletPlatform.warning": "{{platform}}पर Uniswap का उपयोग करने के लिए, {{platform}}का समर्थन करने वाले वॉलेट से कनेक्ट करें।", "settings.footer": "प्यार से बनाया गया, \nUniswap टीम 🦄", "settings.hideSmallBalances": "छोटे-छोटे शेष छिपाएँ", "settings.hideSmallBalances.subtitle": "1 USD से कम शेष राशि आपके पोर्टफोलियो से छिपा दी जाएगी।", @@ -1845,7 +1846,7 @@ "smartWallets.postSwapNudge.title.dapp": "यह ऐप स्मार्ट वॉलेट को सपोर्ट करता है", "smartWallets.unavailableModal.description": "एक अलग वॉलेट प्रदाता अब {{displayName}}के लिए स्मार्ट वॉलेट सेटिंग्स का प्रबंधन कर रहा है। आप सामान्य रूप से Uniswap का उपयोग जारी रख सकते हैं।", "smartWallets.unavailableModal.title": "स्मार्ट वॉलेट सुविधाएँ उपलब्ध नहीं हैं", - "solanaPromo.banner.description": "यूनिस्वैप वेब ऐप पर सीधे सोलाना टोकन का व्यापार करें।", + "solanaPromo.banner.description": "यूनिस्वैप पर सीधे सोलाना टोकन का व्यापार करें।", "solanaPromo.banner.title": "सोलाना अब उपलब्ध है", "solanaPromo.modal.connectWallet": "अपने पसंदीदा सोलाना वॉलेट को कनेक्ट करें", "solanaPromo.modal.startSwapping.button": "सोलाना पर स्वैपिंग शुरू करें", diff --git a/packages/uniswap/src/i18n/locales/translations/hu-HU.json b/packages/uniswap/src/i18n/locales/translations/hu-HU.json index 827d0da4825..3265b8f67ee 100644 --- a/packages/uniswap/src/i18n/locales/translations/hu-HU.json +++ b/packages/uniswap/src/i18n/locales/translations/hu-HU.json @@ -1675,6 +1675,7 @@ "settings.action.lock": "Zárható pénztárca", "settings.action.privacy": "Adatvédelmi irányelvek", "settings.action.terms": "Szolgáltatás feltételei", + "settings.connectWalletPlatform.warning": "A Uniswap {{platform}}tárcán való használatához csatlakozz egy olyan tárcához, amely támogatja a {{platform}}tárcát.", "settings.footer": "Szeretettel készült, \nUniswap Team 🦄", "settings.hideSmallBalances": "Kis egyenlegek elrejtése", "settings.hideSmallBalances.subtitle": "Az 1 USD alatti egyenlegek el lesznek rejtve a portfóliójában.", @@ -1845,7 +1846,7 @@ "smartWallets.postSwapNudge.title.dapp": "Ez az alkalmazás támogatja az intelligens pénztárcákat", "smartWallets.unavailableModal.description": "Egy másik pénztárca-szolgáltató kezeli a {{displayName}}intelligens pénztárca beállításait. A Uniswap szolgáltatást a szokásos módon használhatod.", "smartWallets.unavailableModal.title": "Az intelligens pénztárca funkciói nem érhetők el", - "solanaPromo.banner.description": "Kereskedjen Solana tokenekkel közvetlenül az Uniswap webes alkalmazásban.", + "solanaPromo.banner.description": "Cserélj Solana tokeneket közvetlenül az Uniswap-on.", "solanaPromo.banner.title": "A Solana már elérhető", "solanaPromo.modal.connectWallet": "Csatlakoztassa kedvenc Solana pénztárcáját", "solanaPromo.modal.startSwapping.button": "Kezdj el cserélgetni a Solanán", diff --git a/packages/uniswap/src/i18n/locales/translations/id-ID.json b/packages/uniswap/src/i18n/locales/translations/id-ID.json index 7eb5c98e8ea..61ac7b19cc6 100644 --- a/packages/uniswap/src/i18n/locales/translations/id-ID.json +++ b/packages/uniswap/src/i18n/locales/translations/id-ID.json @@ -152,6 +152,7 @@ "bridging.estimatedTime.minutesAndSeconds": "~{{minutes}} mnt {{seconds}} dtk", "bridging.estimatedTime.minutesOnly": "~{{minutes}} mnt", "bridging.estimatedTime.secondsOnly": "~{{seconds}} dtk", + "bridgingPopularTokens.banner.description": "DOGE, XRP, XLP — now available on Unichain.", "chart.candlestick": "Kandil", "chart.error.pools": "Tidak dapat menampilkan data historis untuk pool saat ini.", "chart.error.tokens": "Tidak dapat menampilkan data historis untuk token saat ini.", @@ -1437,17 +1438,32 @@ "poolFinder.availablePools.notFound.description": "Cadangan aset v2 yang sesuai tidak ditemukan. Periksa kembali pilihan tokenmu dan pastikan kamu telah terhubung dengan dompet yang benar.", "pools.explore": "Jelajahi pool", "portfolio.activity.filters.timePeriod.all": "Sepanjang periode", + "portfolio.activity.filters.transactionType.addLiquidity": "Add Liquidity", "portfolio.activity.filters.transactionType.all": "Semua jenis", - "portfolio.activity.filters.transactionType.deposits": "Setoran", - "portfolio.activity.filters.transactionType.staking": "Staking", + "portfolio.activity.filters.transactionType.approvals": "Approvals", + "portfolio.activity.filters.transactionType.claimFees": "Claim Fees", + "portfolio.activity.filters.transactionType.createPool": "Create Pool", + "portfolio.activity.filters.transactionType.mints": "Mints", + "portfolio.activity.filters.transactionType.removeLiquidity": "Remove Liquidity", "portfolio.activity.filters.transactionType.swaps": "Pertukaran", + "portfolio.activity.filters.transactionType.wraps": "Wraps", + "portfolio.activity.table.column.address": "Address", + "portfolio.activity.table.column.amount": "Amount", + "portfolio.activity.table.column.time": "Time", + "portfolio.activity.table.column.type": "Type", "portfolio.activity.title": "Aktivitas", - "portfolio.connectWallet.summary": "Track tokens, pools, and more across {{amount}}+ networks", "portfolio.defi.title": "DeFi", "portfolio.description": "Melacak portofolio kripto di semua chain dan protokol", + "portfolio.disconnected.connectWallet.cta": "Track your portfolio", + "portfolio.disconnected.cta.description": "Track tokens, positions, NFTs and more across {{numNetworks}}+ networks", + "portfolio.disconnected.demoWallet.description": "This is a view-only example. Connect your wallet to manage your portfolio.", + "portfolio.disconnected.demoWallet.title": "Demo wallet", + "portfolio.disconnected.viewYourPortfolio.cta": "to view your portfolio", "portfolio.nfts.title": "NFT", "portfolio.overview.title": "Ikhtisar", "portfolio.title": "Portofolio", + "portfolio.tokens.balance.totalTokens": "{{numTokens}} tokens", + "portfolio.tokens.emptyState": "No tokens", "portfolio.tokens.table.column.allocation": "Alokasi", "portfolio.tokens.table.column.balance": "Saldo", "portfolio.tokens.table.column.change1d": "Perubahan 1 Hari", @@ -2505,6 +2521,12 @@ "uwulink.error.insufficientTokens": "Tidak cukup {{tokenSymbol}} di {{chain}}", "v2.notAvailable": "Uniswap V2 tidak tersedia di jaringan ini.", "wallet.appSignIn": "Masuk dengan aplikasi", + "wallet.connecting.description": "Complete connection in your wallet", + "wallet.connecting.solanaPrompt": "Use Solana on Uniswap", + "wallet.connecting.solanaPrompt.button": "Use Solana", + "wallet.connecting.solanaPrompt.description": "You’ll be asked to connect again in MetaMask", + "wallet.connecting.title.evm": "Connect to {{walletName}}", + "wallet.connecting.title.svm": "Connect to {{walletName}} on Solana", "wallet.connectingAgreement": "Dengan menghubungkan dompet, kamu menyetujui Ketentuan Layanan Uniswap Labs dan menyetujui Kebijakan Privasi.", "wallet.connectionFailed.message": "Upaya koneksi gagal. Silakan coba lagi dan ikuti langkah-langkah untuk menghubungkan di dompetmu.", "wallet.mismatch.popup.description": "Dompet terhubungmu tidak mendukung beberapa fitur.", diff --git a/packages/uniswap/src/i18n/locales/translations/it-IT.json b/packages/uniswap/src/i18n/locales/translations/it-IT.json index fe13f5ece22..275f8c80b31 100644 --- a/packages/uniswap/src/i18n/locales/translations/it-IT.json +++ b/packages/uniswap/src/i18n/locales/translations/it-IT.json @@ -1675,6 +1675,7 @@ "settings.action.lock": "Blocca il portafoglio", "settings.action.privacy": "Politica sulla riservatezza", "settings.action.terms": "Termini di servizio", + "settings.connectWalletPlatform.warning": "Per utilizzare Uniswap su {{platform}}, connettiti a un portafoglio che supporti {{platform}}.", "settings.footer": "Fatto con amore, \nUniswap Team 🦄", "settings.hideSmallBalances": "Nascondi piccoli saldi", "settings.hideSmallBalances.subtitle": "I saldi inferiori a 1 USD saranno nascosti dal tuo portafoglio.", @@ -1845,7 +1846,7 @@ "smartWallets.postSwapNudge.title.dapp": "Questa app supporta i portafogli intelligenti", "smartWallets.unavailableModal.description": "Un altro fornitore di wallet gestisce ora le impostazioni del wallet intelligente per {{displayName}}. Puoi continuare a utilizzare Uniswap normalmente.", "smartWallets.unavailableModal.title": "Funzionalità del portafoglio intelligente non disponibili", - "solanaPromo.banner.description": "Scambia i token Solana direttamente sulla Web App Uniswap.", + "solanaPromo.banner.description": "Scambia i token Solana direttamente su Uniswap.", "solanaPromo.banner.title": "Solana è ora disponibile", "solanaPromo.modal.connectWallet": "Collega il tuo portafoglio Solana preferito", "solanaPromo.modal.startSwapping.button": "Inizia a scambiare su Solana", diff --git a/packages/uniswap/src/i18n/locales/translations/ja-JP.json b/packages/uniswap/src/i18n/locales/translations/ja-JP.json index 3d432bde27d..2add2c812a9 100644 --- a/packages/uniswap/src/i18n/locales/translations/ja-JP.json +++ b/packages/uniswap/src/i18n/locales/translations/ja-JP.json @@ -152,6 +152,7 @@ "bridging.estimatedTime.minutesAndSeconds": "約 {{minutes}} 分 {{seconds}} 秒", "bridging.estimatedTime.minutesOnly": "約 {{minutes}} 分", "bridging.estimatedTime.secondsOnly": "約 {{seconds}} 秒", + "bridgingPopularTokens.banner.description": "DOGE, XRP, XLP — now available on Unichain.", "chart.candlestick": "ローソク足", "chart.error.pools": "現在のプールの履歴データを表示できません。", "chart.error.tokens": "現在のトークンの履歴データを表示できません。", @@ -1437,17 +1438,32 @@ "poolFinder.availablePools.notFound.description": "一致する v2 プールが見つかりませんでした。選択したトークンを再確認し、正しいウォレットに接続されていることを確認してください。", "pools.explore": "プールを探索", "portfolio.activity.filters.timePeriod.all": "全期間", + "portfolio.activity.filters.transactionType.addLiquidity": "Add Liquidity", "portfolio.activity.filters.transactionType.all": "全種類", - "portfolio.activity.filters.transactionType.deposits": "預け入れ", - "portfolio.activity.filters.transactionType.staking": "ステーキング", + "portfolio.activity.filters.transactionType.approvals": "Approvals", + "portfolio.activity.filters.transactionType.claimFees": "Claim Fees", + "portfolio.activity.filters.transactionType.createPool": "Create Pool", + "portfolio.activity.filters.transactionType.mints": "Mints", + "portfolio.activity.filters.transactionType.removeLiquidity": "Remove Liquidity", "portfolio.activity.filters.transactionType.swaps": "スワップ", + "portfolio.activity.filters.transactionType.wraps": "Wraps", + "portfolio.activity.table.column.address": "Address", + "portfolio.activity.table.column.amount": "Amount", + "portfolio.activity.table.column.time": "Time", + "portfolio.activity.table.column.type": "Type", "portfolio.activity.title": "アクティビティ", - "portfolio.connectWallet.summary": "Track tokens, pools, and more across {{amount}}+ networks", "portfolio.defi.title": "DeFi", "portfolio.description": "チェーンとプロトコルすべてにわたって暗号資産ポートフォリオを追跡します", + "portfolio.disconnected.connectWallet.cta": "Track your portfolio", + "portfolio.disconnected.cta.description": "Track tokens, positions, NFTs and more across {{numNetworks}}+ networks", + "portfolio.disconnected.demoWallet.description": "This is a view-only example. Connect your wallet to manage your portfolio.", + "portfolio.disconnected.demoWallet.title": "Demo wallet", + "portfolio.disconnected.viewYourPortfolio.cta": "to view your portfolio", "portfolio.nfts.title": "NFT", "portfolio.overview.title": "概要", "portfolio.title": "ポートフォリオ", + "portfolio.tokens.balance.totalTokens": "{{numTokens}} tokens", + "portfolio.tokens.emptyState": "No tokens", "portfolio.tokens.table.column.allocation": "割り当て", "portfolio.tokens.table.column.balance": "残高", "portfolio.tokens.table.column.change1d": "1 日の変更", @@ -2505,6 +2521,12 @@ "uwulink.error.insufficientTokens": "{{chain}} の {{tokenSymbol}} が十分ではありません", "v2.notAvailable": "Uniswap V2 はこのネットワークでは利用できません。", "wallet.appSignIn": "アプリでログイン", + "wallet.connecting.description": "Complete connection in your wallet", + "wallet.connecting.solanaPrompt": "Use Solana on Uniswap", + "wallet.connecting.solanaPrompt.button": "Use Solana", + "wallet.connecting.solanaPrompt.description": "You’ll be asked to connect again in MetaMask", + "wallet.connecting.title.evm": "Connect to {{walletName}}", + "wallet.connecting.title.svm": "Connect to {{walletName}} on Solana", "wallet.connectingAgreement": "ウォレットを接続すると、Uniswap Labs の利用規約に同意し、プライバシー ポリシーに同意したことになります。", "wallet.connectionFailed.message": "Connection attempt failed. Please try again, following the steps to connect in your wallet.", "wallet.mismatch.popup.description": "接続中のウォレットでは一部の機能がサポートされていません。", diff --git a/packages/uniswap/src/i18n/locales/translations/ko-KR.json b/packages/uniswap/src/i18n/locales/translations/ko-KR.json index 92215e92477..8e7df5d1fcf 100644 --- a/packages/uniswap/src/i18n/locales/translations/ko-KR.json +++ b/packages/uniswap/src/i18n/locales/translations/ko-KR.json @@ -152,6 +152,7 @@ "bridging.estimatedTime.minutesAndSeconds": "~{{minutes}}분 {{seconds}}초", "bridging.estimatedTime.minutesOnly": "~{{minutes}}분", "bridging.estimatedTime.secondsOnly": "~{{seconds}}초", + "bridgingPopularTokens.banner.description": "DOGE, XRP, XLP — now available on Unichain.", "chart.candlestick": "촛대", "chart.error.pools": "현재 풀에 대한 기록 데이터를 표시할 수 없습니다.", "chart.error.tokens": "현재 토큰에 대한 기록 데이터를 표시할 수 없습니다.", @@ -1437,17 +1438,32 @@ "poolFinder.availablePools.notFound.description": "일치하는 v2 풀을 찾지 못했습니다. 토큰 선택을 다시 한번 확인하고 올바른 지갑에 연결되어 있는지 확인하세요.", "pools.explore": "풀 탐색", "portfolio.activity.filters.timePeriod.all": "누적", + "portfolio.activity.filters.transactionType.addLiquidity": "Add Liquidity", "portfolio.activity.filters.transactionType.all": "모든 유형", - "portfolio.activity.filters.transactionType.deposits": "입금", - "portfolio.activity.filters.transactionType.staking": "스테이킹", + "portfolio.activity.filters.transactionType.approvals": "Approvals", + "portfolio.activity.filters.transactionType.claimFees": "Claim Fees", + "portfolio.activity.filters.transactionType.createPool": "Create Pool", + "portfolio.activity.filters.transactionType.mints": "Mints", + "portfolio.activity.filters.transactionType.removeLiquidity": "Remove Liquidity", "portfolio.activity.filters.transactionType.swaps": "스왑", + "portfolio.activity.filters.transactionType.wraps": "Wraps", + "portfolio.activity.table.column.address": "Address", + "portfolio.activity.table.column.amount": "Amount", + "portfolio.activity.table.column.time": "Time", + "portfolio.activity.table.column.type": "Type", "portfolio.activity.title": "활동", - "portfolio.connectWallet.summary": "Track tokens, pools, and more across {{amount}}+ networks", "portfolio.defi.title": "DeFi", "portfolio.description": "모든 체인과 프로토콜을 아우르는 암호화폐 포트폴리오 추적", + "portfolio.disconnected.connectWallet.cta": "Track your portfolio", + "portfolio.disconnected.cta.description": "Track tokens, positions, NFTs and more across {{numNetworks}}+ networks", + "portfolio.disconnected.demoWallet.description": "This is a view-only example. Connect your wallet to manage your portfolio.", + "portfolio.disconnected.demoWallet.title": "Demo wallet", + "portfolio.disconnected.viewYourPortfolio.cta": "to view your portfolio", "portfolio.nfts.title": "NFT", "portfolio.overview.title": "개요", "portfolio.title": "포트폴리오", + "portfolio.tokens.balance.totalTokens": "{{numTokens}} tokens", + "portfolio.tokens.emptyState": "No tokens", "portfolio.tokens.table.column.allocation": "할당", "portfolio.tokens.table.column.balance": "잔액", "portfolio.tokens.table.column.change1d": "1일 변동", @@ -2505,6 +2521,12 @@ "uwulink.error.insufficientTokens": "{{chain}}에 {{tokenSymbol}}이 충분하지 않습니다.", "v2.notAvailable": "이 네트워크에서는 Uniswap V2를 사용할 수 없습니다.", "wallet.appSignIn": "앱으로 로그인", + "wallet.connecting.description": "Complete connection in your wallet", + "wallet.connecting.solanaPrompt": "Use Solana on Uniswap", + "wallet.connecting.solanaPrompt.button": "Use Solana", + "wallet.connecting.solanaPrompt.description": "You’ll be asked to connect again in MetaMask", + "wallet.connecting.title.evm": "Connect to {{walletName}}", + "wallet.connecting.title.svm": "Connect to {{walletName}} on Solana", "wallet.connectingAgreement": "지갑을 연결하면 Uniswap Labs의 서비스 약관개인정보 보호정책에 동의하는 것으로 간주됩니다.", "wallet.connectionFailed.message": "Connection attempt failed. Please try again, following the steps to connect in your wallet.", "wallet.mismatch.popup.description": "연결된 지갑에서 일부 기능을 지원하지 않습니다.", diff --git a/packages/uniswap/src/i18n/locales/translations/ms-MY.json b/packages/uniswap/src/i18n/locales/translations/ms-MY.json index f2a08e7fe70..dd31542a088 100644 --- a/packages/uniswap/src/i18n/locales/translations/ms-MY.json +++ b/packages/uniswap/src/i18n/locales/translations/ms-MY.json @@ -1675,6 +1675,7 @@ "settings.action.lock": "Kunci dompet", "settings.action.privacy": "Dasar privasi", "settings.action.terms": "Syarat perkhidmatan", + "settings.connectWalletPlatform.warning": "Untuk menggunakan Uniswap pada {{platform}}, sambung ke dompet yang menyokong {{platform}}.", "settings.footer": "Dibuat dengan penuh kasih sayang, \nPasukan Uniswap 🦄", "settings.hideSmallBalances": "Sembunyikan baki kecil", "settings.hideSmallBalances.subtitle": "Baki di bawah 1 USD akan disembunyikan daripada portfolio anda.", @@ -1845,7 +1846,7 @@ "smartWallets.postSwapNudge.title.dapp": "Aplikasi ini menyokong dompet pintar", "smartWallets.unavailableModal.description": "Pembekal dompet yang berbeza kini menguruskan tetapan dompet pintar untuk {{displayName}}. Anda boleh terus menggunakan Uniswap seperti biasa.", "smartWallets.unavailableModal.title": "Ciri dompet pintar tidak tersedia", - "solanaPromo.banner.description": "Berdagang token Solana terus pada Apl Web Uniswap.", + "solanaPromo.banner.description": "Berdagang token Solana terus pada Uniswap.", "solanaPromo.banner.title": "Solana kini tersedia", "solanaPromo.modal.connectWallet": "Sambungkan dompet Solana kegemaran anda", "solanaPromo.modal.startSwapping.button": "Mula bertukar pada Solana", diff --git a/packages/uniswap/src/i18n/locales/translations/nl-NL.json b/packages/uniswap/src/i18n/locales/translations/nl-NL.json index 8b8d74c3d7b..81f0d03cc5d 100644 --- a/packages/uniswap/src/i18n/locales/translations/nl-NL.json +++ b/packages/uniswap/src/i18n/locales/translations/nl-NL.json @@ -152,6 +152,7 @@ "bridging.estimatedTime.minutesAndSeconds": "~{{minutes}}min {{seconds}}s", "bridging.estimatedTime.minutesOnly": "~{{minutes}}min", "bridging.estimatedTime.secondsOnly": "~{{seconds}}s", + "bridgingPopularTokens.banner.description": "DOGE, XRP, XLP — now available on Unichain.", "chart.candlestick": "Candlestick", "chart.error.pools": "Kon de historische data voor de huidige pool niet weergeven.", "chart.error.tokens": "Kon de historische data voor het huidige token niet weergeven.", @@ -1437,17 +1438,32 @@ "poolFinder.availablePools.notFound.description": "Geen overeenkomende v2-pools gevonden. Controleer je tokenselectie nogmaals en zorg ervoor dat je verbonden bent met de juiste wallet.", "pools.explore": "Pools verkennen", "portfolio.activity.filters.timePeriod.all": "Altijd", + "portfolio.activity.filters.transactionType.addLiquidity": "Add Liquidity", "portfolio.activity.filters.transactionType.all": "Alle typen", - "portfolio.activity.filters.transactionType.deposits": "Stortingen", - "portfolio.activity.filters.transactionType.staking": "Staking", + "portfolio.activity.filters.transactionType.approvals": "Approvals", + "portfolio.activity.filters.transactionType.claimFees": "Claim Fees", + "portfolio.activity.filters.transactionType.createPool": "Create Pool", + "portfolio.activity.filters.transactionType.mints": "Mints", + "portfolio.activity.filters.transactionType.removeLiquidity": "Remove Liquidity", "portfolio.activity.filters.transactionType.swaps": "Swaps", + "portfolio.activity.filters.transactionType.wraps": "Wraps", + "portfolio.activity.table.column.address": "Address", + "portfolio.activity.table.column.amount": "Amount", + "portfolio.activity.table.column.time": "Time", + "portfolio.activity.table.column.type": "Type", "portfolio.activity.title": "Activiteit", - "portfolio.connectWallet.summary": "Track tokens, pools, and more across {{amount}}+ networks", "portfolio.defi.title": "DeFi", "portfolio.description": "Volg je cryptoportefeuille in alle chains en protocollen", + "portfolio.disconnected.connectWallet.cta": "Track your portfolio", + "portfolio.disconnected.cta.description": "Track tokens, positions, NFTs and more across {{numNetworks}}+ networks", + "portfolio.disconnected.demoWallet.description": "This is a view-only example. Connect your wallet to manage your portfolio.", + "portfolio.disconnected.demoWallet.title": "Demo wallet", + "portfolio.disconnected.viewYourPortfolio.cta": "to view your portfolio", "portfolio.nfts.title": "NFT's", "portfolio.overview.title": "Overzicht", "portfolio.title": "Portefeuille", + "portfolio.tokens.balance.totalTokens": "{{numTokens}} tokens", + "portfolio.tokens.emptyState": "No tokens", "portfolio.tokens.table.column.allocation": "Toewijzing", "portfolio.tokens.table.column.balance": "Saldo", "portfolio.tokens.table.column.change1d": "1d-wijziging", @@ -2505,6 +2521,12 @@ "uwulink.error.insufficientTokens": "Onvoldoende {{tokenSymbol}} op {{chain}}", "v2.notAvailable": "Uniswap V2 is niet beschikbaar op dit netwerk.", "wallet.appSignIn": "Aanmelden met de app", + "wallet.connecting.description": "Complete connection in your wallet", + "wallet.connecting.solanaPrompt": "Use Solana on Uniswap", + "wallet.connecting.solanaPrompt.button": "Use Solana", + "wallet.connecting.solanaPrompt.description": "You’ll be asked to connect again in MetaMask", + "wallet.connecting.title.evm": "Connect to {{walletName}}", + "wallet.connecting.title.svm": "Connect to {{walletName}} on Solana", "wallet.connectingAgreement": "Door een wallet te verbinden, ga je akkoord met de Servicevoorwaarden van Uniswap Labs en geef je toestemming voor het Privacybeleid.", "wallet.connectionFailed.message": "Verbindingspoging mislukt. Probeer het opnieuw en volg de stappen om verbinding te maken in je wallet.", "wallet.mismatch.popup.description": "Sommige functies worden niet ondersteund door je verbonden wallet.", diff --git a/packages/uniswap/src/i18n/locales/translations/pl-PL.json b/packages/uniswap/src/i18n/locales/translations/pl-PL.json index 2d44ff150af..29ec82c2163 100644 --- a/packages/uniswap/src/i18n/locales/translations/pl-PL.json +++ b/packages/uniswap/src/i18n/locales/translations/pl-PL.json @@ -1675,6 +1675,7 @@ "settings.action.lock": "Zablokuj portfel", "settings.action.privacy": "Polityka prywatności", "settings.action.terms": "Warunki usługi", + "settings.connectWalletPlatform.warning": "Aby użyć Uniswap na {{platform}}, połącz się z portfelem, który obsługuje {{platform}}.", "settings.footer": "Wykonane z miłością, \nZespół Uniswap 🦄", "settings.hideSmallBalances": "Ukryj małe salda", "settings.hideSmallBalances.subtitle": "Salda poniżej 1 USD nie będą widoczne w Twoim portfelu.", @@ -1845,7 +1846,7 @@ "smartWallets.postSwapNudge.title.dapp": "Ta aplikacja obsługuje inteligentne portfele", "smartWallets.unavailableModal.description": "Inny dostawca portfela zarządza teraz ustawieniami inteligentnego portfela dla {{displayName}}. Możesz nadal używać Uniswap jak zwykle.", "smartWallets.unavailableModal.title": "Funkcje inteligentnego portfela są niedostępne", - "solanaPromo.banner.description": "Handluj tokenami Solana bezpośrednio w aplikacji internetowej Uniswap.", + "solanaPromo.banner.description": "Handluj tokenami Solana bezpośrednio na platformie Uniswap.", "solanaPromo.banner.title": "Solana jest już dostępna", "solanaPromo.modal.connectWallet": "Podłącz swój ulubiony portfel Solana", "solanaPromo.modal.startSwapping.button": "Rozpocznij wymianę na Solanie", diff --git a/packages/uniswap/src/i18n/locales/translations/pt-PT.json b/packages/uniswap/src/i18n/locales/translations/pt-PT.json index fb1b0588fb6..cb8e48d7a01 100644 --- a/packages/uniswap/src/i18n/locales/translations/pt-PT.json +++ b/packages/uniswap/src/i18n/locales/translations/pt-PT.json @@ -152,6 +152,7 @@ "bridging.estimatedTime.minutesAndSeconds": "~{{minutes}} min {{seconds}} s", "bridging.estimatedTime.minutesOnly": "~{{minutes}} min", "bridging.estimatedTime.secondsOnly": "~{{seconds}} s", + "bridgingPopularTokens.banner.description": "DOGE, XRP, XLP — now available on Unichain.", "chart.candlestick": "Vela", "chart.error.pools": "Não foi possível exibir dados históricos do pool atual.", "chart.error.tokens": "Não foi possível exibir dados históricos do token atual.", @@ -1437,17 +1438,32 @@ "poolFinder.availablePools.notFound.description": "Não foram encontrados pools v2 correspondentes. Verifique novamente sua seleção de tokens e se está acessando a carteira certa.", "pools.explore": "Explorar pools", "portfolio.activity.filters.timePeriod.all": "Todo o período", + "portfolio.activity.filters.transactionType.addLiquidity": "Add Liquidity", "portfolio.activity.filters.transactionType.all": "Todos os tipos", - "portfolio.activity.filters.transactionType.deposits": "Depósitos", - "portfolio.activity.filters.transactionType.staking": "Staking", + "portfolio.activity.filters.transactionType.approvals": "Approvals", + "portfolio.activity.filters.transactionType.claimFees": "Claim Fees", + "portfolio.activity.filters.transactionType.createPool": "Create Pool", + "portfolio.activity.filters.transactionType.mints": "Mints", + "portfolio.activity.filters.transactionType.removeLiquidity": "Remove Liquidity", "portfolio.activity.filters.transactionType.swaps": "Swaps", + "portfolio.activity.filters.transactionType.wraps": "Wraps", + "portfolio.activity.table.column.address": "Address", + "portfolio.activity.table.column.amount": "Amount", + "portfolio.activity.table.column.time": "Time", + "portfolio.activity.table.column.type": "Type", "portfolio.activity.title": "Atividade", - "portfolio.connectWallet.summary": "Track tokens, pools, and more across {{amount}}+ networks", "portfolio.defi.title": "DeFi", "portfolio.description": "Acompanhe seu portfólio de criptos em todas as redes e protocolos", + "portfolio.disconnected.connectWallet.cta": "Track your portfolio", + "portfolio.disconnected.cta.description": "Track tokens, positions, NFTs and more across {{numNetworks}}+ networks", + "portfolio.disconnected.demoWallet.description": "This is a view-only example. Connect your wallet to manage your portfolio.", + "portfolio.disconnected.demoWallet.title": "Demo wallet", + "portfolio.disconnected.viewYourPortfolio.cta": "to view your portfolio", "portfolio.nfts.title": "NFTs", "portfolio.overview.title": "Visão geral", "portfolio.title": "Portfólio", + "portfolio.tokens.balance.totalTokens": "{{numTokens}} tokens", + "portfolio.tokens.emptyState": "No tokens", "portfolio.tokens.table.column.allocation": "Alocação", "portfolio.tokens.table.column.balance": "Saldo", "portfolio.tokens.table.column.change1d": "Alteração de 1 dia", @@ -2505,6 +2521,12 @@ "uwulink.error.insufficientTokens": "Não há {{tokenSymbol}} suficiente em {{chain}}", "v2.notAvailable": "A Uniswap V2 não está disponível nesta rede.", "wallet.appSignIn": "Entrar com o aplicativo", + "wallet.connecting.description": "Complete connection in your wallet", + "wallet.connecting.solanaPrompt": "Use Solana on Uniswap", + "wallet.connecting.solanaPrompt.button": "Use Solana", + "wallet.connecting.solanaPrompt.description": "You’ll be asked to connect again in MetaMask", + "wallet.connecting.title.evm": "Connect to {{walletName}}", + "wallet.connecting.title.svm": "Connect to {{walletName}} on Solana", "wallet.connectingAgreement": "Ao conectar uma carteira, você concorda com os Termos de serviço e a Política de privacidade da Uniswap Labs.", "wallet.connectionFailed.message": "Connection attempt failed. Please try again, following the steps to connect in your wallet.", "wallet.mismatch.popup.description": "Sua carteira não é compatível com alguns recursos.", diff --git a/packages/uniswap/src/i18n/locales/translations/ru-RU.json b/packages/uniswap/src/i18n/locales/translations/ru-RU.json index a4e2f3b7bf7..0e9fe2c606c 100644 --- a/packages/uniswap/src/i18n/locales/translations/ru-RU.json +++ b/packages/uniswap/src/i18n/locales/translations/ru-RU.json @@ -152,6 +152,7 @@ "bridging.estimatedTime.minutesAndSeconds": "прибл. {{minutes}} мин. {{seconds}} с.", "bridging.estimatedTime.minutesOnly": "прибл. {{minutes}} мин.", "bridging.estimatedTime.secondsOnly": "прибл. {{seconds}} с.", + "bridgingPopularTokens.banner.description": "DOGE, XRP, XLP — now available on Unichain.", "chart.candlestick": "Свечной график", "chart.error.pools": "Невозможно отобразить исторические данные для текущего пула.", "chart.error.tokens": "Невозможно отобразить исторические данные для текущего токена.", @@ -1437,17 +1438,32 @@ "poolFinder.availablePools.notFound.description": "Соответствующие пулы v2 не найдены. Еще раз проверьте выбранные токены и убедитесь, что подключились к правильному кошельку.", "pools.explore": "Исследование пулов", "portfolio.activity.filters.timePeriod.all": "Все время", + "portfolio.activity.filters.transactionType.addLiquidity": "Add Liquidity", "portfolio.activity.filters.transactionType.all": "Все типы", - "portfolio.activity.filters.transactionType.deposits": "Депозиты", - "portfolio.activity.filters.transactionType.staking": "Стейкинг", + "portfolio.activity.filters.transactionType.approvals": "Approvals", + "portfolio.activity.filters.transactionType.claimFees": "Claim Fees", + "portfolio.activity.filters.transactionType.createPool": "Create Pool", + "portfolio.activity.filters.transactionType.mints": "Mints", + "portfolio.activity.filters.transactionType.removeLiquidity": "Remove Liquidity", "portfolio.activity.filters.transactionType.swaps": "Свопы", + "portfolio.activity.filters.transactionType.wraps": "Wraps", + "portfolio.activity.table.column.address": "Address", + "portfolio.activity.table.column.amount": "Amount", + "portfolio.activity.table.column.time": "Time", + "portfolio.activity.table.column.type": "Type", "portfolio.activity.title": "Активность", - "portfolio.connectWallet.summary": "Track tokens, pools, and more across {{amount}}+ networks", "portfolio.defi.title": "DeFi", "portfolio.description": "Отслеживайте свои криптовалюты во всех блокчейнах и протоколах", + "portfolio.disconnected.connectWallet.cta": "Track your portfolio", + "portfolio.disconnected.cta.description": "Track tokens, positions, NFTs and more across {{numNetworks}}+ networks", + "portfolio.disconnected.demoWallet.description": "This is a view-only example. Connect your wallet to manage your portfolio.", + "portfolio.disconnected.demoWallet.title": "Demo wallet", + "portfolio.disconnected.viewYourPortfolio.cta": "to view your portfolio", "portfolio.nfts.title": "NFT", "portfolio.overview.title": "Обзор", "portfolio.title": "Портфель", + "portfolio.tokens.balance.totalTokens": "{{numTokens}} tokens", + "portfolio.tokens.emptyState": "No tokens", "portfolio.tokens.table.column.allocation": "Распределение", "portfolio.tokens.table.column.balance": "Баланс", "portfolio.tokens.table.column.change1d": "Изменения за 1 дн.", @@ -2505,6 +2521,12 @@ "uwulink.error.insufficientTokens": "Недостаточно {{tokenSymbol}} в {{chain}}", "v2.notAvailable": "Протокол Uniswap V2 недоступен в этой сети.", "wallet.appSignIn": "Войти через приложение", + "wallet.connecting.description": "Complete connection in your wallet", + "wallet.connecting.solanaPrompt": "Use Solana on Uniswap", + "wallet.connecting.solanaPrompt.button": "Use Solana", + "wallet.connecting.solanaPrompt.description": "You’ll be asked to connect again in MetaMask", + "wallet.connecting.title.evm": "Connect to {{walletName}}", + "wallet.connecting.title.svm": "Connect to {{walletName}} on Solana", "wallet.connectingAgreement": "Подключая кошелек, вы принимаете Условия обслуживания и Политику конфиденциальности Uniswap Labs.", "wallet.connectionFailed.message": "Connection attempt failed. Please try again, following the steps to connect in your wallet.", "wallet.mismatch.popup.description": "В подключенном кошельке не поддерживаются некоторые функции.", diff --git a/packages/uniswap/src/i18n/locales/translations/sl-SI.json b/packages/uniswap/src/i18n/locales/translations/sl-SI.json index 7953e1a00d8..223da00c4b1 100644 --- a/packages/uniswap/src/i18n/locales/translations/sl-SI.json +++ b/packages/uniswap/src/i18n/locales/translations/sl-SI.json @@ -1675,6 +1675,7 @@ "settings.action.lock": "Zakleni denarnico", "settings.action.privacy": "Politika zasebnosti", "settings.action.terms": "Pogoji storitve", + "settings.connectWalletPlatform.warning": "Za uporabo Uniswapa na {{platform}}se povežite z denarnico, ki podpira {{platform}}.", "settings.footer": "Narejeno z ljubeznijo, \nekipa Uniswap 🦄", "settings.hideSmallBalances": "Skrij majhna stanja", "settings.hideSmallBalances.subtitle": "Stanja pod 1 USD bodo skrita v vašem portfelju.", @@ -1845,7 +1846,7 @@ "smartWallets.postSwapNudge.title.dapp": "Ta aplikacija podpira pametne denarnice", "smartWallets.unavailableModal.description": "Nastavitve pametne denarnice za {{displayName}}zdaj upravlja drug ponudnik denarnic. Uniswap lahko še naprej uporabljate kot običajno.", "smartWallets.unavailableModal.title": "Funkcije pametne denarnice niso na voljo", - "solanaPromo.banner.description": "Trgujte z žetoni Solana neposredno v spletni aplikaciji Uniswap.", + "solanaPromo.banner.description": "Trgujte z žetoni Solana neposredno na Uniswapu.", "solanaPromo.banner.title": "Solana je zdaj na voljo", "solanaPromo.modal.connectWallet": "Povežite svojo najljubšo denarnico Solana", "solanaPromo.modal.startSwapping.button": "Začnite menjati na Solani", diff --git a/packages/uniswap/src/i18n/locales/translations/sr-SP.json b/packages/uniswap/src/i18n/locales/translations/sr-SP.json index a65b21f9ec0..afaef20798b 100644 --- a/packages/uniswap/src/i18n/locales/translations/sr-SP.json +++ b/packages/uniswap/src/i18n/locales/translations/sr-SP.json @@ -1675,6 +1675,7 @@ "settings.action.lock": "Закључајте новчаник", "settings.action.privacy": "Правила о приватности", "settings.action.terms": "Услови коришћења", + "settings.connectWalletPlatform.warning": "To use Uniswap on {{platform}}, connect to a wallet that supports {{platform}}.", "settings.footer": "Направљен с љубављу, \nУнисвап тим 🦄", "settings.hideSmallBalances": "Сакријте мале биланце", "settings.hideSmallBalances.subtitle": "Balances under 1 USD will be hidden from your portfolio.", @@ -1845,7 +1846,7 @@ "smartWallets.postSwapNudge.title.dapp": "This app supports smart wallets", "smartWallets.unavailableModal.description": "A different wallet provider is now managing smart wallet settings for {{displayName}}. You can continue using Uniswap as normal.", "smartWallets.unavailableModal.title": "Smart wallet features unavailable", - "solanaPromo.banner.description": "Trade Solana tokens directly on the Uniswap Web App.", + "solanaPromo.banner.description": "Trade Solana tokens directly on Uniswap.", "solanaPromo.banner.title": "Solana is now available", "solanaPromo.modal.connectWallet": "Connect your favorite Solana wallet", "solanaPromo.modal.startSwapping.button": "Start swapping on Solana", diff --git a/packages/uniswap/src/i18n/locales/translations/sv-SE.json b/packages/uniswap/src/i18n/locales/translations/sv-SE.json index 87294b31017..dd01404d9fc 100644 --- a/packages/uniswap/src/i18n/locales/translations/sv-SE.json +++ b/packages/uniswap/src/i18n/locales/translations/sv-SE.json @@ -1675,6 +1675,7 @@ "settings.action.lock": "Lås plånbok", "settings.action.privacy": "Integritetspolicy", "settings.action.terms": "Användarvillkor", + "settings.connectWalletPlatform.warning": "För att använda Uniswap på {{platform}}, anslut till en plånbok som stöder {{platform}}.", "settings.footer": "Tillverkad med kärlek, \nUniswap Team 🦄", "settings.hideSmallBalances": "Dölj små saldon", "settings.hideSmallBalances.subtitle": "Saldon under 1 USD kommer att döljas från din portfölj.", @@ -1845,7 +1846,7 @@ "smartWallets.postSwapNudge.title.dapp": "Den här appen stöder smarta plånböcker", "smartWallets.unavailableModal.description": "En annan plånboksleverantör hanterar nu smarta plånboksinställningar för {{displayName}}. Du kan fortsätta använda Uniswap som vanligt.", "smartWallets.unavailableModal.title": "Smarta plånboksfunktioner är inte tillgängliga", - "solanaPromo.banner.description": "Handla Solana-tokens direkt i Uniswap-webbappen.", + "solanaPromo.banner.description": "Handla Solana-tokens direkt på Uniswap.", "solanaPromo.banner.title": "Solana är nu tillgänglig", "solanaPromo.modal.connectWallet": "Anslut din favorit Solana-plånbok", "solanaPromo.modal.startSwapping.button": "Börja byta på Solana", diff --git a/packages/uniswap/src/i18n/locales/translations/sw-TZ.json b/packages/uniswap/src/i18n/locales/translations/sw-TZ.json index 8015c9a4637..7e2958541b6 100644 --- a/packages/uniswap/src/i18n/locales/translations/sw-TZ.json +++ b/packages/uniswap/src/i18n/locales/translations/sw-TZ.json @@ -1675,6 +1675,7 @@ "settings.action.lock": "Funga mkoba", "settings.action.privacy": "Sera ya faragha", "settings.action.terms": "Masharti ya huduma", + "settings.connectWalletPlatform.warning": "To use Uniswap on {{platform}}, connect to a wallet that supports {{platform}}.", "settings.footer": "Imetengenezwa kwa upendo, \nTimu ya Uniswap 🦄", "settings.hideSmallBalances": "Ficha mizani ndogo", "settings.hideSmallBalances.subtitle": "Balances under 1 USD will be hidden from your portfolio.", @@ -1845,7 +1846,7 @@ "smartWallets.postSwapNudge.title.dapp": "This app supports smart wallets", "smartWallets.unavailableModal.description": "A different wallet provider is now managing smart wallet settings for {{displayName}}. You can continue using Uniswap as normal.", "smartWallets.unavailableModal.title": "Smart wallet features unavailable", - "solanaPromo.banner.description": "Trade Solana tokens directly on the Uniswap Web App.", + "solanaPromo.banner.description": "Trade Solana tokens directly on Uniswap.", "solanaPromo.banner.title": "Solana is now available", "solanaPromo.modal.connectWallet": "Connect your favorite Solana wallet", "solanaPromo.modal.startSwapping.button": "Start swapping on Solana", diff --git a/packages/uniswap/src/i18n/locales/translations/tr-TR.json b/packages/uniswap/src/i18n/locales/translations/tr-TR.json index 5b508028083..8282c8779ad 100644 --- a/packages/uniswap/src/i18n/locales/translations/tr-TR.json +++ b/packages/uniswap/src/i18n/locales/translations/tr-TR.json @@ -152,6 +152,7 @@ "bridging.estimatedTime.minutesAndSeconds": "~{{minutes}} dk {{seconds}} sn", "bridging.estimatedTime.minutesOnly": "~{{minutes}} dk", "bridging.estimatedTime.secondsOnly": "~{{seconds}} sn", + "bridgingPopularTokens.banner.description": "DOGE, XRP, XLP — now available on Unichain.", "chart.candlestick": "Mum grafiği", "chart.error.pools": "Geçerli havuza ilişkin geçmiş veriler görüntülenemiyor.", "chart.error.tokens": "Geçerli token'ın geçmiş verileri görüntülenemiyor.", @@ -1437,17 +1438,32 @@ "poolFinder.availablePools.notFound.description": "Eşleşen v2 havuzları bulunamadı. Token seçimini bir kez daha kontrol et ve doğru cüzdana bağlı olduğundan emin ol.", "pools.explore": "Havuzları keşfet", "portfolio.activity.filters.timePeriod.all": "Her zaman", + "portfolio.activity.filters.transactionType.addLiquidity": "Add Liquidity", "portfolio.activity.filters.transactionType.all": "Tüm türler", - "portfolio.activity.filters.transactionType.deposits": "Yatırılan", - "portfolio.activity.filters.transactionType.staking": "Staking", + "portfolio.activity.filters.transactionType.approvals": "Approvals", + "portfolio.activity.filters.transactionType.claimFees": "Claim Fees", + "portfolio.activity.filters.transactionType.createPool": "Create Pool", + "portfolio.activity.filters.transactionType.mints": "Mints", + "portfolio.activity.filters.transactionType.removeLiquidity": "Remove Liquidity", "portfolio.activity.filters.transactionType.swaps": "Swap'lar", + "portfolio.activity.filters.transactionType.wraps": "Wraps", + "portfolio.activity.table.column.address": "Address", + "portfolio.activity.table.column.amount": "Amount", + "portfolio.activity.table.column.time": "Time", + "portfolio.activity.table.column.type": "Type", "portfolio.activity.title": "Etkinlik", - "portfolio.connectWallet.summary": "Track tokens, pools, and more across {{amount}}+ networks", "portfolio.defi.title": "DeFi", "portfolio.description": "Kripto para portföyünü tüm zincirlerde ve protokollerde takip et", + "portfolio.disconnected.connectWallet.cta": "Track your portfolio", + "portfolio.disconnected.cta.description": "Track tokens, positions, NFTs and more across {{numNetworks}}+ networks", + "portfolio.disconnected.demoWallet.description": "This is a view-only example. Connect your wallet to manage your portfolio.", + "portfolio.disconnected.demoWallet.title": "Demo wallet", + "portfolio.disconnected.viewYourPortfolio.cta": "to view your portfolio", "portfolio.nfts.title": "NFT'ler", "portfolio.overview.title": "Genel Bakış", "portfolio.title": "Portföy", + "portfolio.tokens.balance.totalTokens": "{{numTokens}} tokens", + "portfolio.tokens.emptyState": "No tokens", "portfolio.tokens.table.column.allocation": "Dağıtım", "portfolio.tokens.table.column.balance": "Bakiye", "portfolio.tokens.table.column.change1d": "1 Günlük Değişim", @@ -2505,6 +2521,12 @@ "uwulink.error.insufficientTokens": "{{chain}} zincirinde yeterli {{tokenSymbol}} yok", "v2.notAvailable": "Uniswap V2 bu ağda mevcut değil.", "wallet.appSignIn": "Uygulama ile giriş yap", + "wallet.connecting.description": "Complete connection in your wallet", + "wallet.connecting.solanaPrompt": "Use Solana on Uniswap", + "wallet.connecting.solanaPrompt.button": "Use Solana", + "wallet.connecting.solanaPrompt.description": "You’ll be asked to connect again in MetaMask", + "wallet.connecting.title.evm": "Connect to {{walletName}}", + "wallet.connecting.title.svm": "Connect to {{walletName}} on Solana", "wallet.connectingAgreement": "Bir cüzdan bağlayarak Uniswap Labs'ın Hizmet Şartlarını ve Gizlilik Politikasını kabul etmiş olursun.", "wallet.connectionFailed.message": "Connection attempt failed. Please try again, following the steps to connect in your wallet.", "wallet.mismatch.popup.description": "Bağlı cüzdanın, bazı özellikleri desteklemiyor.", diff --git a/packages/uniswap/src/i18n/locales/translations/uk-UA.json b/packages/uniswap/src/i18n/locales/translations/uk-UA.json index 147135f6657..2cd48cba7bb 100644 --- a/packages/uniswap/src/i18n/locales/translations/uk-UA.json +++ b/packages/uniswap/src/i18n/locales/translations/uk-UA.json @@ -1675,6 +1675,7 @@ "settings.action.lock": "Заблокувати гаманець", "settings.action.privacy": "Політика конфіденційності", "settings.action.terms": "Умови використання", + "settings.connectWalletPlatform.warning": "Щоб використовувати Uniswap на {{platform}}, підключіться до гаманця, який підтримує {{platform}}.", "settings.footer": "Зроблено з любов’ю, \nкоманда Uniswap 🦄", "settings.hideSmallBalances": "Приховайте невеликі залишки", "settings.hideSmallBalances.subtitle": "Залишки менше 1 долара США будуть приховані з вашого портфеля.", @@ -1845,7 +1846,7 @@ "smartWallets.postSwapNudge.title.dapp": "Цей додаток підтримує розумні гаманці", "smartWallets.unavailableModal.description": "Інший постачальник гаманців тепер керує налаштуваннями смарт-гаманця для {{displayName}}. Ви можете продовжувати користуватися Uniswap як завжди.", "smartWallets.unavailableModal.title": "Функції розумного гаманця недоступні", - "solanaPromo.banner.description": "Торгуйте токенами Solana безпосередньо у веб-додатку Uniswap.", + "solanaPromo.banner.description": "Торгуйте токенами Solana безпосередньо на Uniswap.", "solanaPromo.banner.title": "Солана вже доступна", "solanaPromo.modal.connectWallet": "Підключіть свій улюблений гаманець Solana", "solanaPromo.modal.startSwapping.button": "Почніть обмін на Solana", diff --git a/packages/uniswap/src/i18n/locales/translations/ur-PK.json b/packages/uniswap/src/i18n/locales/translations/ur-PK.json index af9efc42fb9..184ada9065d 100644 --- a/packages/uniswap/src/i18n/locales/translations/ur-PK.json +++ b/packages/uniswap/src/i18n/locales/translations/ur-PK.json @@ -1675,6 +1675,7 @@ "settings.action.lock": "پرس مقفل کریں۔", "settings.action.privacy": "رازداری کی پالیسی", "settings.action.terms": "سروس کی شرائط", + "settings.connectWalletPlatform.warning": "{{platform}}پر Uniswap استعمال کرنے کے لیے، ایک ایسے والیٹ سے جڑیں جو {{platform}}کو سپورٹ کرتا ہو۔", "settings.footer": "محبت کے ساتھ بنایا گیا، \nUnswap ٹیم 🦄", "settings.hideSmallBalances": "چھوٹے بیلنس چھپائیں۔", "settings.hideSmallBalances.subtitle": "1 USD سے کم بیلنس آپ کے پورٹ فولیو سے چھپائے جائیں گے۔", @@ -1845,7 +1846,7 @@ "smartWallets.postSwapNudge.title.dapp": "یہ ایپ سمارٹ بٹوے کو سپورٹ کرتی ہے۔", "smartWallets.unavailableModal.description": "ایک مختلف والیٹ فراہم کنندہ اب {{displayName}}کے لیے سمارٹ والیٹ کی ترتیبات کا انتظام کر رہا ہے۔ آپ یونی سویپ کا استعمال معمول کے مطابق جاری رکھ سکتے ہیں۔", "smartWallets.unavailableModal.title": "اسمارٹ والیٹ کی خصوصیات دستیاب نہیں ہیں۔", - "solanaPromo.banner.description": "سولانا ٹوکنز کو براہ راست Uniswap ویب ایپ پر تجارت کریں۔", + "solanaPromo.banner.description": "سولانا ٹوکنز کو براہ راست Uniswap پر تجارت کریں۔", "solanaPromo.banner.title": "سولانا اب دستیاب ہے۔", "solanaPromo.modal.connectWallet": "اپنے پسندیدہ سولانا والیٹ کو جوڑیں۔", "solanaPromo.modal.startSwapping.button": "سولانا پر تبادلہ کرنا شروع کریں۔", diff --git a/packages/uniswap/src/i18n/locales/translations/vi-VN.json b/packages/uniswap/src/i18n/locales/translations/vi-VN.json index dd72bb83886..ea0d816cc7e 100644 --- a/packages/uniswap/src/i18n/locales/translations/vi-VN.json +++ b/packages/uniswap/src/i18n/locales/translations/vi-VN.json @@ -152,6 +152,7 @@ "bridging.estimatedTime.minutesAndSeconds": "~{{minutes}} phút {{seconds}} giây", "bridging.estimatedTime.minutesOnly": "~{{minutes}} phút", "bridging.estimatedTime.secondsOnly": "~{{seconds}} giây", + "bridgingPopularTokens.banner.description": "DOGE, XRP, XLP — now available on Unichain.", "chart.candlestick": "Biểu đồ nến", "chart.error.pools": "Không thể hiển thị dữ liệu lịch sử cho pool hiện tại.", "chart.error.tokens": "Không thể hiển thị dữ liệu lịch sử cho token hiện tại.", @@ -1437,17 +1438,32 @@ "poolFinder.availablePools.notFound.description": "Không tìm thấy pool v2 trùng khớp. Hãy kiểm tra lại lựa chọn token của bạn và đảm bảo bạn đã kết nối với đúng ví.", "pools.explore": "Khám phá pool", "portfolio.activity.filters.timePeriod.all": "Mọi thời điểm", + "portfolio.activity.filters.transactionType.addLiquidity": "Add Liquidity", "portfolio.activity.filters.transactionType.all": "Mọi loại", - "portfolio.activity.filters.transactionType.deposits": "Các giao dịch nạp", - "portfolio.activity.filters.transactionType.staking": "Staking", + "portfolio.activity.filters.transactionType.approvals": "Approvals", + "portfolio.activity.filters.transactionType.claimFees": "Claim Fees", + "portfolio.activity.filters.transactionType.createPool": "Create Pool", + "portfolio.activity.filters.transactionType.mints": "Mints", + "portfolio.activity.filters.transactionType.removeLiquidity": "Remove Liquidity", "portfolio.activity.filters.transactionType.swaps": "Các giao dịch hoán đổi", + "portfolio.activity.filters.transactionType.wraps": "Wraps", + "portfolio.activity.table.column.address": "Address", + "portfolio.activity.table.column.amount": "Amount", + "portfolio.activity.table.column.time": "Time", + "portfolio.activity.table.column.type": "Type", "portfolio.activity.title": "Hoạt động", - "portfolio.connectWallet.summary": "Track tokens, pools, and more across {{amount}}+ networks", "portfolio.defi.title": "DeFi", "portfolio.description": "Theo dõi danh mục đầu tư crypto của bạn trên tất cả các blockchain và giao thức", + "portfolio.disconnected.connectWallet.cta": "Track your portfolio", + "portfolio.disconnected.cta.description": "Track tokens, positions, NFTs and more across {{numNetworks}}+ networks", + "portfolio.disconnected.demoWallet.description": "This is a view-only example. Connect your wallet to manage your portfolio.", + "portfolio.disconnected.demoWallet.title": "Demo wallet", + "portfolio.disconnected.viewYourPortfolio.cta": "to view your portfolio", "portfolio.nfts.title": "NFT", "portfolio.overview.title": "Tổng quan", "portfolio.title": "Danh mục đầu tư", + "portfolio.tokens.balance.totalTokens": "{{numTokens}} tokens", + "portfolio.tokens.emptyState": "No tokens", "portfolio.tokens.table.column.allocation": "Phân bổ", "portfolio.tokens.table.column.balance": "Số dư", "portfolio.tokens.table.column.change1d": "Biến động trong 1 ngày", @@ -2505,6 +2521,12 @@ "uwulink.error.insufficientTokens": "Không đủ {{tokenSymbol}} trên {{chain}}", "v2.notAvailable": "Uniswap V2 không khả dụng trên mạng này.", "wallet.appSignIn": "Đăng nhập bằng ứng dụng", + "wallet.connecting.description": "Complete connection in your wallet", + "wallet.connecting.solanaPrompt": "Use Solana on Uniswap", + "wallet.connecting.solanaPrompt.button": "Use Solana", + "wallet.connecting.solanaPrompt.description": "You’ll be asked to connect again in MetaMask", + "wallet.connecting.title.evm": "Connect to {{walletName}}", + "wallet.connecting.title.svm": "Connect to {{walletName}} on Solana", "wallet.connectingAgreement": "Bằng việc kết nối ví, bạn đồng ý với Điều khoản dịch vụ của Uniswap Labs và chấp nhận Chính sách về quyền riêng tư của Uniswap Labs.", "wallet.connectionFailed.message": "Connection attempt failed. Please try again, following the steps to connect in your wallet.", "wallet.mismatch.popup.description": "Ví đã kết nối của bạn không hỗ trợ một số tính năng.", diff --git a/packages/uniswap/src/i18n/locales/translations/zh-CN.json b/packages/uniswap/src/i18n/locales/translations/zh-CN.json index 4822b97d6f4..17ad3bb5632 100644 --- a/packages/uniswap/src/i18n/locales/translations/zh-CN.json +++ b/packages/uniswap/src/i18n/locales/translations/zh-CN.json @@ -152,6 +152,7 @@ "bridging.estimatedTime.minutesAndSeconds": "~{{minutes}} 分钟 {{seconds}} 秒", "bridging.estimatedTime.minutesOnly": "~{{minutes}} 分钟", "bridging.estimatedTime.secondsOnly": "~{{seconds}} 秒", + "bridgingPopularTokens.banner.description": "DOGE, XRP, XLP — now available on Unichain.", "chart.candlestick": "K 线图", "chart.error.pools": "无法显示当前资金池的历史数据。", "chart.error.tokens": "无法显示当前代币的历史数据。", @@ -1437,17 +1438,32 @@ "poolFinder.availablePools.notFound.description": "未找到相符的 v2 资金池。请仔细检查你选择的代币,并确保你已连接至正确的钱包。", "pools.explore": "探索资金池", "portfolio.activity.filters.timePeriod.all": "所有时间", + "portfolio.activity.filters.transactionType.addLiquidity": "Add Liquidity", "portfolio.activity.filters.transactionType.all": "所有类型", - "portfolio.activity.filters.transactionType.deposits": "存入", - "portfolio.activity.filters.transactionType.staking": "质押", + "portfolio.activity.filters.transactionType.approvals": "Approvals", + "portfolio.activity.filters.transactionType.claimFees": "Claim Fees", + "portfolio.activity.filters.transactionType.createPool": "Create Pool", + "portfolio.activity.filters.transactionType.mints": "Mints", + "portfolio.activity.filters.transactionType.removeLiquidity": "Remove Liquidity", "portfolio.activity.filters.transactionType.swaps": "交换", + "portfolio.activity.filters.transactionType.wraps": "Wraps", + "portfolio.activity.table.column.address": "Address", + "portfolio.activity.table.column.amount": "Amount", + "portfolio.activity.table.column.time": "Time", + "portfolio.activity.table.column.type": "Type", "portfolio.activity.title": "活动", - "portfolio.connectWallet.summary": "Track tokens, pools, and more across {{amount}}+ networks", "portfolio.defi.title": "DeFi", "portfolio.description": "跨所有区块链和协议追踪你的加密货币资产组合", + "portfolio.disconnected.connectWallet.cta": "Track your portfolio", + "portfolio.disconnected.cta.description": "Track tokens, positions, NFTs and more across {{numNetworks}}+ networks", + "portfolio.disconnected.demoWallet.description": "This is a view-only example. Connect your wallet to manage your portfolio.", + "portfolio.disconnected.demoWallet.title": "Demo wallet", + "portfolio.disconnected.viewYourPortfolio.cta": "to view your portfolio", "portfolio.nfts.title": "非同质化代币", "portfolio.overview.title": "概览", "portfolio.title": "资产组合", + "portfolio.tokens.balance.totalTokens": "{{numTokens}} tokens", + "portfolio.tokens.emptyState": "No tokens", "portfolio.tokens.table.column.allocation": "配额", "portfolio.tokens.table.column.balance": "余额", "portfolio.tokens.table.column.change1d": "24 小时变动", @@ -2505,6 +2521,12 @@ "uwulink.error.insufficientTokens": "{{chain}} 上的 {{tokenSymbol}} 不足", "v2.notAvailable": "Uniswap V2 在此网络上不可用。", "wallet.appSignIn": "使用应用登录", + "wallet.connecting.description": "Complete connection in your wallet", + "wallet.connecting.solanaPrompt": "Use Solana on Uniswap", + "wallet.connecting.solanaPrompt.button": "Use Solana", + "wallet.connecting.solanaPrompt.description": "You’ll be asked to connect again in MetaMask", + "wallet.connecting.title.evm": "Connect to {{walletName}}", + "wallet.connecting.title.svm": "Connect to {{walletName}} on Solana", "wallet.connectingAgreement": "通过连接钱包,表明你同意 Uniswap 实验室 的服务条款及其隐私政策。", "wallet.connectionFailed.message": "Connection attempt failed. Please try again, following the steps to connect in your wallet.", "wallet.mismatch.popup.description": "某些功能你的联网钱包不支持。", diff --git a/packages/uniswap/src/i18n/locales/translations/zh-TW.json b/packages/uniswap/src/i18n/locales/translations/zh-TW.json index 43410fc3108..21c0ea4913e 100644 --- a/packages/uniswap/src/i18n/locales/translations/zh-TW.json +++ b/packages/uniswap/src/i18n/locales/translations/zh-TW.json @@ -152,6 +152,7 @@ "bridging.estimatedTime.minutesAndSeconds": "~{{minutes}} 分 {{seconds}} 秒", "bridging.estimatedTime.minutesOnly": "~{{minutes}} 分鐘", "bridging.estimatedTime.secondsOnly": "~{{seconds}} 秒", + "bridgingPopularTokens.banner.description": "DOGE, XRP, XLP — now available on Unichain.", "chart.candlestick": "K 線圖表", "chart.error.pools": "無法顯示目前資產池的過往記錄資料。", "chart.error.tokens": "無法顯示目前代幣的過往記錄資料。", @@ -1437,17 +1438,32 @@ "poolFinder.availablePools.notFound.description": "未找到相符的 v2 資產池。請仔細檢查你選取的代幣,並確保你已連接至正確的錢包。", "pools.explore": "探索資產池", "portfolio.activity.filters.timePeriod.all": "全部時間", + "portfolio.activity.filters.transactionType.addLiquidity": "Add Liquidity", "portfolio.activity.filters.transactionType.all": "所有類型", - "portfolio.activity.filters.transactionType.deposits": "存入", - "portfolio.activity.filters.transactionType.staking": "質押", + "portfolio.activity.filters.transactionType.approvals": "Approvals", + "portfolio.activity.filters.transactionType.claimFees": "Claim Fees", + "portfolio.activity.filters.transactionType.createPool": "Create Pool", + "portfolio.activity.filters.transactionType.mints": "Mints", + "portfolio.activity.filters.transactionType.removeLiquidity": "Remove Liquidity", "portfolio.activity.filters.transactionType.swaps": "交換", + "portfolio.activity.filters.transactionType.wraps": "Wraps", + "portfolio.activity.table.column.address": "Address", + "portfolio.activity.table.column.amount": "Amount", + "portfolio.activity.table.column.time": "Time", + "portfolio.activity.table.column.type": "Type", "portfolio.activity.title": "活動", - "portfolio.connectWallet.summary": "Track tokens, pools, and more across {{amount}}+ networks", "portfolio.defi.title": "DeFi", "portfolio.description": "跨所有鏈和協定追蹤您的加密貨幣資產組合", + "portfolio.disconnected.connectWallet.cta": "Track your portfolio", + "portfolio.disconnected.cta.description": "Track tokens, positions, NFTs and more across {{numNetworks}}+ networks", + "portfolio.disconnected.demoWallet.description": "This is a view-only example. Connect your wallet to manage your portfolio.", + "portfolio.disconnected.demoWallet.title": "Demo wallet", + "portfolio.disconnected.viewYourPortfolio.cta": "to view your portfolio", "portfolio.nfts.title": "NFT", "portfolio.overview.title": "概覽", "portfolio.title": "資產組合", + "portfolio.tokens.balance.totalTokens": "{{numTokens}} tokens", + "portfolio.tokens.emptyState": "No tokens", "portfolio.tokens.table.column.allocation": "配額", "portfolio.tokens.table.column.balance": "餘額", "portfolio.tokens.table.column.change1d": "1 日變動", @@ -2505,6 +2521,12 @@ "uwulink.error.insufficientTokens": "{{chain}} 上的 {{tokenSymbol}} 不足", "v2.notAvailable": "Uniswap V2 在此網路上不適用。", "wallet.appSignIn": "使用 App 登入", + "wallet.connecting.description": "Complete connection in your wallet", + "wallet.connecting.solanaPrompt": "Use Solana on Uniswap", + "wallet.connecting.solanaPrompt.button": "Use Solana", + "wallet.connecting.solanaPrompt.description": "You’ll be asked to connect again in MetaMask", + "wallet.connecting.title.evm": "Connect to {{walletName}}", + "wallet.connecting.title.svm": "Connect to {{walletName}} on Solana", "wallet.connectingAgreement": "連線錢包即表示你同意 Uniswap Labs 的服務條款並同意其隱私權政策。", "wallet.connectionFailed.message": "Connection attempt failed. Please try again, following the steps to connect in your wallet.", "wallet.mismatch.popup.description": "你的已連接錢包不支援某些功能。", diff --git a/packages/uniswap/src/state/oldTypes.ts b/packages/uniswap/src/state/oldTypes.ts index c78dd73667e..964e7c1d065 100644 --- a/packages/uniswap/src/state/oldTypes.ts +++ b/packages/uniswap/src/state/oldTypes.ts @@ -1,4 +1,4 @@ -import { ProtocolVersion } from '@uniswap/client-pools/dist/pools/v1/types_pb' +import { ProtocolVersion } from '@uniswap/client-data-api/dist/data/v1/poolTypes_pb' import { GraphQLApi } from '@universe/api' import { UniverseChainId } from 'uniswap/src/features/chains/types' import { SafetyInfo } from 'uniswap/src/features/dataApi/types' diff --git a/packages/uniswap/src/test/fixtures/gql/assets/tokens.ts b/packages/uniswap/src/test/fixtures/gql/assets/tokens.ts index ef474c92660..708ed37422c 100644 --- a/packages/uniswap/src/test/fixtures/gql/assets/tokens.ts +++ b/packages/uniswap/src/test/fixtures/gql/assets/tokens.ts @@ -58,6 +58,8 @@ export const token = createFixture({ sellFeeBps: '', }, protectionInfo, + isBridged: undefined, + bridgedWithdrawalInfo: undefined, })) export const tokenBalance = createFixture()(() => ({ diff --git a/packages/uniswap/src/test/fixtures/testIDs.ts b/packages/uniswap/src/test/fixtures/testIDs.ts index 4536dff1a7a..dc0ef02f8b1 100644 --- a/packages/uniswap/src/test/fixtures/testIDs.ts +++ b/packages/uniswap/src/test/fixtures/testIDs.ts @@ -69,6 +69,7 @@ export const TestID = { ExploreFilterChainPrefix: 'explore-filter-chain-', ExploreSearchInput: 'explore-search-input', ExploreSortButton: 'explore-sort-button', + ExploreTab: 'explore-tab', ExploreSortByVolume: 'explore-sort-by-volume', ExploreTokensSearchInput: 'explore-tokens-search-input', Favorite: 'favorite', @@ -81,6 +82,7 @@ export const TestID = { HiddenNftsRow: 'hidden-nfts-row', HelpIcon: 'help-icon', HelpModal: 'help-modal', + HomeTab: 'home-tab', ImportAccount: 'import-account', ImportAccountInput: 'import-account-input', InvertPrice: 'invert-price', diff --git a/packages/uniswap/src/test/mocks/gql/mocks.ts b/packages/uniswap/src/test/mocks/gql/mocks.ts index ec307734bc0..894a005e0ae 100644 --- a/packages/uniswap/src/test/mocks/gql/mocks.ts +++ b/packages/uniswap/src/test/mocks/gql/mocks.ts @@ -28,6 +28,8 @@ export const mocks = { symbol: () => faker.lorem.word(), protectionInfo: () => ({ result: randomEnumValue(GraphQLApi.ProtectionResult), attackTypes: [] }), feeData: () => ({ buyFeeBps: '', sellFeeBps: '' }), + isBridged: () => null, + bridgedWithdrawalInfo: () => null, }, Amount: { id: () => faker.datatype.uuid(), diff --git a/packages/uniswap/src/utils/datadog.web.ts b/packages/uniswap/src/utils/datadog.web.ts index 3da89dd1680..fd79ee7cc6e 100644 --- a/packages/uniswap/src/utils/datadog.web.ts +++ b/packages/uniswap/src/utils/datadog.web.ts @@ -1,17 +1,18 @@ import { datadogLogs } from '@datadog/browser-logs' import { datadogRum, RumEvent, RumEventDomainContext, RumFetchResourceEventDomainContext } from '@datadog/browser-rum' -import { config } from 'uniswap/src/config' import { DatadogIgnoredErrorsConfigKey, DatadogIgnoredErrorsValType, DatadogSessionSampleRateKey, DatadogSessionSampleRateValType, DynamicConfigs, -} from 'uniswap/src/features/gating/configs' -import { Experiments } from 'uniswap/src/features/gating/experiments' -import { WALLET_FEATURE_FLAG_NAMES, WEB_FEATURE_FLAG_NAMES } from 'uniswap/src/features/gating/flags' -import { getDynamicConfigValue } from 'uniswap/src/features/gating/hooks' -import { getStatsigClient } from 'uniswap/src/features/gating/sdk/statsig' + Experiments, + getDynamicConfigValue, + getStatsigClient, + WALLET_FEATURE_FLAG_NAMES, + WEB_FEATURE_FLAG_NAMES, +} from '@universe/gating' +import { config } from 'uniswap/src/config' import { getUniqueId } from 'utilities/src/device/uniqueId' import { datadogEnabledBuild, localDevDatadogEnabled } from 'utilities/src/environment/constants' import { isBetaEnv } from 'utilities/src/environment/env' @@ -40,7 +41,9 @@ function beforeSend(event: RumEvent, context: RumEventDomainContext): boolean { defaultValue: [], }) - const ignoredError = ignoredErrors.find(({ messageContains }) => event.error.message.includes(messageContains)) + const ignoredError = ignoredErrors.find(({ messageContains }: { messageContains: string }) => + event.error.message.includes(messageContains), + ) if (ignoredError && Math.random() > ignoredError.sampleRate) { return false } diff --git a/packages/uniswap/src/utils/search/doesTokenMatchSearchTerm.test.ts b/packages/uniswap/src/utils/search/doesTokenMatchSearchTerm.test.ts new file mode 100644 index 00000000000..f04ac41d388 --- /dev/null +++ b/packages/uniswap/src/utils/search/doesTokenMatchSearchTerm.test.ts @@ -0,0 +1,419 @@ +import { Currency } from '@uniswap/sdk-core' +import { DAI, nativeOnChain, USDC, WBTC } from 'uniswap/src/constants/tokens' +import { UniverseChainId } from 'uniswap/src/features/chains/types' +import { doesTokenMatchSearchTerm } from 'uniswap/src/utils/search/doesTokenMatchSearchTerm' + +// Test data factory functions using real tokens +const createMockCurrencyInfo = ( + overrides: Partial<{ currencyId: string; currency: Currency }> = {}, +): { currencyId: string; currency: Currency } => ({ + currencyId: 'TEST', + currency: USDC, // Default to USDC + ...overrides, +}) + +const createMockTokenWithInfo = ( + overrides: Partial<{ currencyInfo: { currencyId: string; currency: Currency } | null }> = {}, +): { currencyInfo: { currencyId: string; currency: Currency } | null } => ({ + currencyInfo: createMockCurrencyInfo(), + ...overrides, +}) + +describe('doesTokenMatchSearchTerm', () => { + describe('when searchTerm is empty or undefined', () => { + it('should return true when searchTerm is undefined', () => { + const token = createMockTokenWithInfo() + + const result = doesTokenMatchSearchTerm(token, undefined as any) + + expect(result).toBe(true) + }) + + it('should return true when searchTerm is null', () => { + const token = createMockTokenWithInfo() + + const result = doesTokenMatchSearchTerm(token, null as any) + + expect(result).toBe(true) + }) + + it('should return true when searchTerm is empty string', () => { + const token = createMockTokenWithInfo() + + const result = doesTokenMatchSearchTerm(token, '') + + expect(result).toBe(true) + }) + + it('should return true when searchTerm is only whitespace', () => { + const token = createMockTokenWithInfo() + + const result = doesTokenMatchSearchTerm(token, ' ') + + expect(result).toBe(true) + }) + + it('should return true when searchTerm is only tabs and newlines', () => { + const token = createMockTokenWithInfo() + + const result = doesTokenMatchSearchTerm(token, '\t\n\r ') + + expect(result).toBe(true) + }) + }) + + describe('when currencyInfo is null', () => { + it('should return false when currencyInfo is null', () => { + const token = createMockTokenWithInfo({ + currencyInfo: null, + }) + + const result = doesTokenMatchSearchTerm(token, 'test') + + expect(result).toBe(false) + }) + }) + + describe('when searching by token name', () => { + it('should match when search term is in token name (case insensitive)', () => { + const token = createMockTokenWithInfo({ + currencyInfo: createMockCurrencyInfo({ + currency: DAI, // DAI has name "Dai Stablecoin" + }), + }) + + const result = doesTokenMatchSearchTerm(token, 'dai') + + expect(result).toBe(true) + }) + + it('should match when search term is in token name with different case', () => { + const token = createMockTokenWithInfo({ + currencyInfo: createMockCurrencyInfo({ + currency: DAI, // DAI has name "Dai Stablecoin" + }), + }) + + const result = doesTokenMatchSearchTerm(token, 'STABLECOIN') + + expect(result).toBe(true) + }) + + it('should not match when search term is not in token name', () => { + const token = createMockTokenWithInfo({ + currencyInfo: createMockCurrencyInfo({ + currency: USDC, // USDC has name "USD Coin" + }), + }) + + const result = doesTokenMatchSearchTerm(token, 'bitcoin') + + expect(result).toBe(false) + }) + + it('should handle undefined token name', () => { + // Create a token with undefined name by modifying WBTC + const tokenWithUndefinedName = { + ...WBTC, + name: undefined, + } as any + + const token = createMockTokenWithInfo({ + currencyInfo: createMockCurrencyInfo({ + currency: tokenWithUndefinedName, + }), + }) + + const result = doesTokenMatchSearchTerm(token, 'test') + + expect(result).toBe(false) + }) + }) + + describe('when searching by token symbol', () => { + it('should match when search term is in token symbol (case insensitive)', () => { + const token = createMockTokenWithInfo({ + currencyInfo: createMockCurrencyInfo({ + currency: USDC, // USDC has symbol "USDC" + }), + }) + + const result = doesTokenMatchSearchTerm(token, 'usd') + + expect(result).toBe(true) + }) + + it('should match when search term is in token symbol with different case', () => { + const token = createMockTokenWithInfo({ + currencyInfo: createMockCurrencyInfo({ + currency: USDC, // USDC has symbol "USDC" + }), + }) + + const result = doesTokenMatchSearchTerm(token, 'USDC') + + expect(result).toBe(true) + }) + + it('should not match when search term is not in token symbol', () => { + const token = createMockTokenWithInfo({ + currencyInfo: createMockCurrencyInfo({ + currency: USDC, // USDC has symbol "USDC" + }), + }) + + const result = doesTokenMatchSearchTerm(token, 'bitcoin') + + expect(result).toBe(false) + }) + + it('should handle undefined token symbol', () => { + // Create a token with undefined symbol by modifying WBTC + const tokenWithUndefinedSymbol = { + ...WBTC, + symbol: undefined, + } as any + + const token = createMockTokenWithInfo({ + currencyInfo: createMockCurrencyInfo({ + currency: tokenWithUndefinedSymbol, + }), + }) + + const result = doesTokenMatchSearchTerm(token, 'test') + + expect(result).toBe(false) + }) + }) + + describe('when searching by token address', () => { + it('should match when search term is in token address (case insensitive)', () => { + const token = createMockTokenWithInfo({ + currencyInfo: createMockCurrencyInfo({ + currency: USDC, // USDC has address starting with 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 + }), + }) + + const result = doesTokenMatchSearchTerm(token, 'a0b8') + + expect(result).toBe(true) + }) + + it('should match when search term is in token address with different case', () => { + const token = createMockTokenWithInfo({ + currencyInfo: createMockCurrencyInfo({ + currency: USDC, // USDC has address starting with 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 + }), + }) + + const result = doesTokenMatchSearchTerm(token, 'A0B8') + + expect(result).toBe(true) + }) + + it('should not match when search term is not in token address', () => { + const token = createMockTokenWithInfo({ + currencyInfo: createMockCurrencyInfo({ + currency: USDC, + }), + }) + + const result = doesTokenMatchSearchTerm(token, '9999') + + expect(result).toBe(false) + }) + + it('should not search by address for native currencies', () => { + const token = createMockTokenWithInfo({ + currencyInfo: createMockCurrencyInfo({ + currency: nativeOnChain(UniverseChainId.Mainnet), // Native ETH + }), + }) + + const result = doesTokenMatchSearchTerm(token, '0x') + + expect(result).toBe(false) + }) + }) + + describe('when multiple fields match', () => { + it('should return true if name matches even if symbol and address do not', () => { + const token = createMockTokenWithInfo({ + currencyInfo: createMockCurrencyInfo({ + currency: DAI, // DAI has name "Dai Stablecoin" and symbol "DAI" + }), + }) + + const result = doesTokenMatchSearchTerm(token, 'dai') + + expect(result).toBe(true) + }) + + it('should return true if symbol matches even if name and address do not', () => { + const token = createMockTokenWithInfo({ + currencyInfo: createMockCurrencyInfo({ + currency: USDC, // USDC has symbol "USDC" and name "USD Coin" + }), + }) + + const result = doesTokenMatchSearchTerm(token, 'usd') + + expect(result).toBe(true) + }) + + it('should return true if address matches even if name and symbol do not', () => { + const token = createMockTokenWithInfo({ + currencyInfo: createMockCurrencyInfo({ + currency: USDC, // USDC has address starting with 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 + }), + }) + + const result = doesTokenMatchSearchTerm(token, 'a0b8') + + expect(result).toBe(true) + }) + }) + + describe('edge cases', () => { + it('should handle partial matches at the beginning of strings', () => { + const token = createMockTokenWithInfo({ + currencyInfo: createMockCurrencyInfo({ + currency: DAI, // DAI has symbol "DAI" + }), + }) + + const result = doesTokenMatchSearchTerm(token, 'dai') + + expect(result).toBe(true) + }) + + it('should handle partial matches at the end of strings', () => { + const token = createMockTokenWithInfo({ + currencyInfo: createMockCurrencyInfo({ + currency: DAI, // DAI has name "Dai Stablecoin" + }), + }) + + const result = doesTokenMatchSearchTerm(token, 'coin') + + expect(result).toBe(true) + }) + + it('should handle partial matches in the middle of strings', () => { + // Fix the imported WBTC token properties + const wbtcToken = { + ...WBTC, + name: 'Wrapped BTC', + symbol: 'WBTC', + } as any + + const token = createMockTokenWithInfo({ + currencyInfo: createMockCurrencyInfo({ + currency: wbtcToken, + }), + }) + + const result = doesTokenMatchSearchTerm(token, 'wrapped') + + expect(result).toBe(true) + }) + + it('should handle special characters in search term', () => { + const token = createMockTokenWithInfo({ + currencyInfo: createMockCurrencyInfo({ + currency: USDC, // USDC has name "USD Coin" + }), + }) + + const result = doesTokenMatchSearchTerm(token, 'usd') + + expect(result).toBe(true) + }) + + it('should handle very long search terms', () => { + const token = createMockTokenWithInfo({ + currencyInfo: createMockCurrencyInfo({ + currency: USDC, + }), + }) + + const longSearchTerm = 'a'.repeat(1000) + const result = doesTokenMatchSearchTerm(token, longSearchTerm) + + expect(result).toBe(false) + }) + + it('should handle empty token name and symbol', () => { + // Create a token with empty name and symbol by modifying WBTC + const tokenWithEmptyFields = { + ...WBTC, + name: '', + symbol: '', + } as any + + const token = createMockTokenWithInfo({ + currencyInfo: createMockCurrencyInfo({ + currency: tokenWithEmptyFields, + }), + }) + + const result = doesTokenMatchSearchTerm(token, 'test') + + expect(result).toBe(false) + }) + }) + + describe('with different currency types', () => { + it('should work with Token instances', () => { + const token = createMockTokenWithInfo({ + currencyInfo: createMockCurrencyInfo({ + currency: USDC, // USDC is a Token instance + }), + }) + + const result = doesTokenMatchSearchTerm(token, 'usd') + + expect(result).toBe(true) + }) + + it('should work with NativeCurrency instances', () => { + const token = createMockTokenWithInfo({ + currencyInfo: createMockCurrencyInfo({ + currency: nativeOnChain(UniverseChainId.Mainnet), // Native ETH + }), + }) + + const result = doesTokenMatchSearchTerm(token, 'ethereum') + + expect(result).toBe(true) + }) + + it('should not search by address for NativeCurrency', () => { + const token = createMockTokenWithInfo({ + currencyInfo: createMockCurrencyInfo({ + currency: nativeOnChain(UniverseChainId.Mainnet), // Native ETH + }), + }) + + // NativeCurrency doesn't have an address, so this should not match + const result = doesTokenMatchSearchTerm(token, '0x') + + expect(result).toBe(false) + }) + }) + + describe('case sensitivity', () => { + it('should be case insensitive for all fields', () => { + const token = createMockTokenWithInfo({ + currencyInfo: createMockCurrencyInfo({ + currency: USDC, // USDC has name "USD Coin" and symbol "USDC" + }), + }) + + expect(doesTokenMatchSearchTerm(token, 'usd')).toBe(true) + expect(doesTokenMatchSearchTerm(token, 'USD')).toBe(true) + expect(doesTokenMatchSearchTerm(token, 'Usd')).toBe(true) + expect(doesTokenMatchSearchTerm(token, 'UsD')).toBe(true) + }) + }) +}) diff --git a/packages/uniswap/src/utils/search/doesTokenMatchSearchTerm.ts b/packages/uniswap/src/utils/search/doesTokenMatchSearchTerm.ts new file mode 100644 index 00000000000..f056a6f7c2a --- /dev/null +++ b/packages/uniswap/src/utils/search/doesTokenMatchSearchTerm.ts @@ -0,0 +1,37 @@ +import { Currency, Token } from '@uniswap/sdk-core' + +/** + * Checks if a token matches a search term. + * + * @param token - The token to check + * @param searchTerm - The search term to match against + * @returns True if the token matches the search term, false otherwise + */ +export function doesTokenMatchSearchTerm( + token: { currencyInfo: { currencyId: string; currency: Currency } | null }, + searchTerm: string, +): boolean { + if (!searchTerm || !searchTerm.trim()) { + return true + } + + const lowercaseSearch = searchTerm.toLowerCase() + + const currencyInfo = token.currencyInfo + if (!currencyInfo) { + return false + } + const currency = currencyInfo.currency + + // Search by token name + const nameIncludesSearch = currency.name?.toLowerCase().includes(lowercaseSearch) + + // Search by token symbol + const symbolIncludesSearch = currency.symbol?.toLowerCase().includes(lowercaseSearch) + + // Search by token address (normalized for consistency with explore page) + const addressIncludesSearch = + currency instanceof Token ? currency.address.toLowerCase().includes(lowercaseSearch) : false + + return Boolean(nameIncludesSearch || symbolIncludesSearch || addressIncludesSearch) +} diff --git a/packages/uniswap/src/utils/search/getPossibleChainMatchFromSearchWord.test.ts b/packages/uniswap/src/utils/search/getPossibleChainMatchFromSearchWord.test.ts new file mode 100644 index 00000000000..af0cfc67144 --- /dev/null +++ b/packages/uniswap/src/utils/search/getPossibleChainMatchFromSearchWord.test.ts @@ -0,0 +1,437 @@ +import { UniverseChainId } from 'uniswap/src/features/chains/types' +import { getPossibleChainMatchFromSearchWord } from 'uniswap/src/utils/search/getPossibleChainMatchFromSearchWord' + +// Mock the dependencies before importing the function +jest.mock('uniswap/src/features/chains/chainInfo', () => ({ + getChainInfo: jest.fn(), +})) + +jest.mock('uniswap/src/features/chains/utils', () => ({ + isTestnetChain: jest.fn(), +})) + +// Import the mocked functions +import { getChainInfo } from 'uniswap/src/features/chains/chainInfo' +import { isTestnetChain } from 'uniswap/src/features/chains/utils' + +const mockGetChainInfo = getChainInfo as any +const mockIsTestnetChain = isTestnetChain as any + +describe('getPossibleChainMatchFromSearchWord', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + describe('when search word is empty or invalid', () => { + it('should return undefined when search word is empty string', () => { + const enabledChains = [UniverseChainId.Mainnet, UniverseChainId.Polygon] + + const result = getPossibleChainMatchFromSearchWord('', enabledChains) + + expect(result).toBeUndefined() + }) + + it('should return undefined when search word is null', () => { + const enabledChains = [UniverseChainId.Mainnet, UniverseChainId.Polygon] + + const result = getPossibleChainMatchFromSearchWord(null as any, enabledChains) + + expect(result).toBeUndefined() + }) + + it('should return undefined when search word is undefined', () => { + const enabledChains = [UniverseChainId.Mainnet, UniverseChainId.Polygon] + + const result = getPossibleChainMatchFromSearchWord(undefined as any, enabledChains) + + expect(result).toBeUndefined() + }) + + it('should return undefined when search word is only whitespace', () => { + const enabledChains = [UniverseChainId.Mainnet, UniverseChainId.Polygon] + + // Mock the functions to return proper structure + mockIsTestnetChain.mockReturnValue(false) + mockGetChainInfo.mockImplementation((chainId: UniverseChainId) => { + if (chainId === UniverseChainId.Mainnet) { + return { + nativeCurrency: { name: 'Ethereum' }, + interfaceName: 'mainnet', + } as any + } + if (chainId === UniverseChainId.Polygon) { + return { + nativeCurrency: { name: 'Polygon' }, + interfaceName: 'polygon', + } as any + } + return {} as any + }) + + const result = getPossibleChainMatchFromSearchWord(' ', enabledChains) + + expect(result).toBeUndefined() + }) + }) + + describe('when matching by native currency name', () => { + it('should match exact native currency name', () => { + const enabledChains = [UniverseChainId.Mainnet, UniverseChainId.Polygon] + + mockIsTestnetChain.mockReturnValue(false) + mockGetChainInfo.mockImplementation((chainId: UniverseChainId) => { + if (chainId === UniverseChainId.Mainnet) { + return { + nativeCurrency: { name: 'Ethereum' }, + interfaceName: 'mainnet', + } as any + } + if (chainId === UniverseChainId.Polygon) { + return { + nativeCurrency: { name: 'Polygon' }, + interfaceName: 'polygon', + } as any + } + return {} as any + }) + + const result = getPossibleChainMatchFromSearchWord('ethereum', enabledChains) + + expect(result).toBe(UniverseChainId.Mainnet) + }) + + it('should be case insensitive for native currency name', () => { + const enabledChains = [UniverseChainId.Mainnet, UniverseChainId.Polygon] + + mockIsTestnetChain.mockReturnValue(false) + mockGetChainInfo.mockImplementation((chainId: UniverseChainId) => { + if (chainId === UniverseChainId.Mainnet) { + return { + nativeCurrency: { name: 'Ethereum' }, + interfaceName: 'mainnet', + } as any + } + if (chainId === UniverseChainId.Polygon) { + return { + nativeCurrency: { name: 'Polygon' }, + interfaceName: 'polygon', + } as any + } + return {} as any + }) + + const result = getPossibleChainMatchFromSearchWord('ETHEREUM', enabledChains) + + expect(result).toBe(UniverseChainId.Mainnet) + }) + }) + + describe('when matching by interface name', () => { + it('should match exact interface name', () => { + const enabledChains = [UniverseChainId.Mainnet, UniverseChainId.Polygon] + + mockIsTestnetChain.mockReturnValue(false) + mockGetChainInfo.mockImplementation((chainId: UniverseChainId) => { + if (chainId === UniverseChainId.Mainnet) { + return { + nativeCurrency: { name: 'Ethereum' }, + interfaceName: 'mainnet', + } as any + } + if (chainId === UniverseChainId.Polygon) { + return { + nativeCurrency: { name: 'Polygon' }, + interfaceName: 'polygon', + } as any + } + return {} as any + }) + + const result = getPossibleChainMatchFromSearchWord('mainnet', enabledChains) + + expect(result).toBe(UniverseChainId.Mainnet) + }) + + it('should be case insensitive for interface name', () => { + const enabledChains = [UniverseChainId.Mainnet, UniverseChainId.Polygon] + + mockIsTestnetChain.mockReturnValue(false) + mockGetChainInfo.mockImplementation((chainId: UniverseChainId) => { + if (chainId === UniverseChainId.Mainnet) { + return { + nativeCurrency: { name: 'Ethereum' }, + interfaceName: 'mainnet', + } as any + } + if (chainId === UniverseChainId.Polygon) { + return { + nativeCurrency: { name: 'Polygon' }, + interfaceName: 'polygon', + } as any + } + return {} as any + }) + + const result = getPossibleChainMatchFromSearchWord('MAINNET', enabledChains) + + expect(result).toBe(UniverseChainId.Mainnet) + }) + + it('should match polygon interface name', () => { + const enabledChains = [UniverseChainId.Mainnet, UniverseChainId.Polygon] + + mockIsTestnetChain.mockReturnValue(false) + mockGetChainInfo.mockImplementation((chainId: UniverseChainId) => { + if (chainId === UniverseChainId.Mainnet) { + return { + nativeCurrency: { name: 'Ethereum' }, + interfaceName: 'mainnet', + } as any + } + if (chainId === UniverseChainId.Polygon) { + return { + nativeCurrency: { name: 'Polygon' }, + interfaceName: 'polygon', + } as any + } + return {} as any + }) + + const result = getPossibleChainMatchFromSearchWord('polygon', enabledChains) + + expect(result).toBe(UniverseChainId.Polygon) + }) + + it('should match arbitrum interface name', () => { + const enabledChains = [UniverseChainId.Mainnet, UniverseChainId.ArbitrumOne] + + mockIsTestnetChain.mockReturnValue(false) + mockGetChainInfo.mockImplementation((chainId: UniverseChainId) => { + if (chainId === UniverseChainId.Mainnet) { + return { + nativeCurrency: { name: 'Ethereum' }, + interfaceName: 'mainnet', + } as any + } + if (chainId === UniverseChainId.ArbitrumOne) { + return { + nativeCurrency: { name: 'Ethereum' }, + interfaceName: 'arbitrum', + } as any + } + return {} as any + }) + + const result = getPossibleChainMatchFromSearchWord('arbitrum', enabledChains) + + expect(result).toBe(UniverseChainId.ArbitrumOne) + }) + }) + + describe('when handling testnet chains', () => { + it('should skip testnet chains', () => { + const enabledChains = [UniverseChainId.Mainnet, UniverseChainId.Sepolia] + + mockIsTestnetChain.mockImplementation((chainId: UniverseChainId) => chainId === UniverseChainId.Sepolia) + mockGetChainInfo.mockImplementation((chainId: UniverseChainId) => { + if (chainId === UniverseChainId.Mainnet) { + return { + nativeCurrency: { name: 'Ethereum' }, + interfaceName: 'mainnet', + } as any + } + if (chainId === UniverseChainId.Sepolia) { + return { + nativeCurrency: { name: 'Ethereum' }, + interfaceName: 'sepolia', + } as any + } + return {} as any + }) + + const result = getPossibleChainMatchFromSearchWord('ethereum', enabledChains) + + expect(result).toBe(UniverseChainId.Mainnet) + expect(mockIsTestnetChain).toHaveBeenCalledWith(UniverseChainId.Mainnet) + // The function returns early when it finds a match, so it doesn't check Sepolia + // This is the correct behavior - it should return the first non-testnet match + }) + + it('should return undefined when only testnet chains match', () => { + const enabledChains = [UniverseChainId.Sepolia] + + mockIsTestnetChain.mockReturnValue(true) + mockGetChainInfo.mockImplementation((chainId: UniverseChainId) => { + if (chainId === UniverseChainId.Sepolia) { + return { + nativeCurrency: { name: 'Ethereum' }, + interfaceName: 'sepolia', + } as any + } + return {} as any + }) + + const result = getPossibleChainMatchFromSearchWord('ethereum', enabledChains) + + expect(result).toBeUndefined() + }) + }) + + describe('when no matches are found', () => { + it('should return undefined when no chains match', () => { + const enabledChains = [UniverseChainId.Mainnet, UniverseChainId.Polygon] + + mockIsTestnetChain.mockReturnValue(false) + mockGetChainInfo.mockImplementation((chainId: UniverseChainId) => { + if (chainId === UniverseChainId.Mainnet) { + return { + nativeCurrency: { name: 'Ethereum' }, + interfaceName: 'mainnet', + } as any + } + if (chainId === UniverseChainId.Polygon) { + return { + nativeCurrency: { name: 'Polygon' }, + interfaceName: 'polygon', + } as any + } + return {} as any + }) + + const result = getPossibleChainMatchFromSearchWord('bitcoin', enabledChains) + + expect(result).toBeUndefined() + }) + + it('should return undefined when enabledChains is empty', () => { + const enabledChains: UniverseChainId[] = [] + + const result = getPossibleChainMatchFromSearchWord('ethereum', enabledChains) + + expect(result).toBeUndefined() + }) + + it('should return undefined when all chains are testnets', () => { + const enabledChains = [UniverseChainId.Sepolia, UniverseChainId.UnichainSepolia] + + mockIsTestnetChain.mockReturnValue(true) + mockGetChainInfo.mockImplementation((chainId: UniverseChainId) => { + if (chainId === UniverseChainId.Sepolia) { + return { + nativeCurrency: { name: 'Ethereum' }, + interfaceName: 'sepolia', + } as any + } + if (chainId === UniverseChainId.UnichainSepolia) { + return { + nativeCurrency: { name: 'Ethereum' }, + interfaceName: 'unichain-sepolia', + } as any + } + return {} as any + }) + + const result = getPossibleChainMatchFromSearchWord('ethereum', enabledChains) + + expect(result).toBeUndefined() + }) + }) + + describe('when multiple chains could match', () => { + it('should return the first matching chain', () => { + const enabledChains = [UniverseChainId.Mainnet, UniverseChainId.Polygon, UniverseChainId.ArbitrumOne] + + mockIsTestnetChain.mockReturnValue(false) + mockGetChainInfo.mockImplementation((chainId: UniverseChainId) => { + if (chainId === UniverseChainId.Mainnet) { + return { + nativeCurrency: { name: 'Ethereum' }, + interfaceName: 'mainnet', + } as any + } + if (chainId === UniverseChainId.Polygon) { + return { + nativeCurrency: { name: 'Ethereum' }, + interfaceName: 'polygon', + } as any + } + if (chainId === UniverseChainId.ArbitrumOne) { + return { + nativeCurrency: { name: 'Ethereum' }, + interfaceName: 'arbitrum', + } as any + } + return {} as any + }) + + const result = getPossibleChainMatchFromSearchWord('ethereum', enabledChains) + + expect(result).toBe(UniverseChainId.Mainnet) + }) + }) + + describe('edge cases', () => { + it('should handle native currency names with empty first word', () => { + const enabledChains = [UniverseChainId.Mainnet] + + mockIsTestnetChain.mockReturnValue(false) + mockGetChainInfo.mockImplementation((chainId: UniverseChainId) => { + if (chainId === UniverseChainId.Mainnet) { + return { + nativeCurrency: { name: ' Ethereum' }, // Leading space + interfaceName: 'mainnet', + } as any + } + return {} as any + }) + + const result = getPossibleChainMatchFromSearchWord('ethereum', enabledChains) + + expect(result).toBeUndefined() + }) + + it('should handle interface names with special characters', () => { + const enabledChains = [UniverseChainId.Mainnet] + + mockIsTestnetChain.mockReturnValue(false) + mockGetChainInfo.mockImplementation((chainId: UniverseChainId) => { + if (chainId === UniverseChainId.Mainnet) { + return { + nativeCurrency: { name: 'Ethereum' }, + interfaceName: 'main-net', // With hyphen + } as any + } + return {} as any + }) + + const result = getPossibleChainMatchFromSearchWord('main-net', enabledChains) + + expect(result).toBe(UniverseChainId.Mainnet) + }) + + it('should match base chain interface name', () => { + const enabledChains = [UniverseChainId.Mainnet, UniverseChainId.Base] + + mockIsTestnetChain.mockReturnValue(false) + mockGetChainInfo.mockImplementation((chainId: UniverseChainId) => { + if (chainId === UniverseChainId.Mainnet) { + return { + nativeCurrency: { name: 'Ethereum' }, + interfaceName: 'mainnet', + } as any + } + if (chainId === UniverseChainId.Base) { + return { + nativeCurrency: { name: 'Ethereum' }, + interfaceName: 'base', + } as any + } + return {} as any + }) + + const result = getPossibleChainMatchFromSearchWord('base', enabledChains) + + expect(result).toBe(UniverseChainId.Base) + }) + }) +}) diff --git a/packages/uniswap/src/utils/search/getPossibleChainMatchFromSearchWord.ts b/packages/uniswap/src/utils/search/getPossibleChainMatchFromSearchWord.ts new file mode 100644 index 00000000000..5331ee7c0a2 --- /dev/null +++ b/packages/uniswap/src/utils/search/getPossibleChainMatchFromSearchWord.ts @@ -0,0 +1,47 @@ +import { getChainInfo } from 'uniswap/src/features/chains/chainInfo' +import { UniverseChainId } from 'uniswap/src/features/chains/types' +import { isTestnetChain } from 'uniswap/src/features/chains/utils' + +/** + * Finds a matching chain ID based on the provided chain name. + * This is intended to check if a singular word is a chain name. It doesn't + * look for chain names in multi-word searches. + * + * @param maybeChainName - The potential chain name to match against + * @param enabledChains - Array of enabled chain IDs to search within + * @returns The matching UniverseChainId or undefined if no match found + */ +export function getPossibleChainMatchFromSearchWord( + maybeChainName: string, + enabledChains: UniverseChainId[], +): UniverseChainId | undefined { + if (!maybeChainName) { + return undefined + } + + const lowerCaseChainName = maybeChainName.toLowerCase() + + for (const chainId of enabledChains) { + if (isTestnetChain(chainId)) { + continue + } + + const chainInfo = getChainInfo(chainId) + + // Check against native currency name + const nativeCurrencyName = chainInfo.nativeCurrency.name.toLowerCase() + const firstWord = nativeCurrencyName.split(' ')[0] + + if (firstWord && firstWord === lowerCaseChainName) { + return chainId + } + + // Check against interface name + const interfaceName = chainInfo.interfaceName.toLowerCase() + if (interfaceName === lowerCaseChainName) { + return chainId + } + } + + return undefined +} diff --git a/packages/uniswap/src/utils/search/parseChainFromTokenSearchQuery.test.ts b/packages/uniswap/src/utils/search/parseChainFromTokenSearchQuery.test.ts new file mode 100644 index 00000000000..cfecb304f8e --- /dev/null +++ b/packages/uniswap/src/utils/search/parseChainFromTokenSearchQuery.test.ts @@ -0,0 +1,138 @@ +import { UniverseChainId } from 'uniswap/src/features/chains/types' +import { parseChainFromTokenSearchQuery } from 'uniswap/src/utils/search/parseChainFromTokenSearchQuery' + +describe('parseChainFromTokenSearchQuery', () => { + const enabledChains: UniverseChainId[] = [ + UniverseChainId.Mainnet, + UniverseChainId.ArbitrumOne, + UniverseChainId.Base, + UniverseChainId.Optimism, + UniverseChainId.Polygon, + ] + + describe('null/empty input handling', () => { + it('returns empty result for null/empty/whitespace inputs', () => { + expect(parseChainFromTokenSearchQuery(null, enabledChains)).toEqual({ chainFilter: null, searchTerm: null }) + expect(parseChainFromTokenSearchQuery('', enabledChains)).toEqual({ chainFilter: null, searchTerm: null }) + expect(parseChainFromTokenSearchQuery(' ', enabledChains)).toEqual({ chainFilter: null, searchTerm: null }) + }) + }) + + describe('single word searches', () => { + it('returns chain filter for chain names (native currency and interface)', () => { + expect(parseChainFromTokenSearchQuery('ethereum', enabledChains)).toEqual({ + chainFilter: UniverseChainId.Mainnet, + searchTerm: null, + }) + expect(parseChainFromTokenSearchQuery('mainnet', enabledChains)).toEqual({ + chainFilter: UniverseChainId.Mainnet, + searchTerm: null, + }) + expect(parseChainFromTokenSearchQuery('EtHeReUm', enabledChains)).toEqual({ + chainFilter: UniverseChainId.Mainnet, + searchTerm: null, + }) // case insensitive + }) + + it('returns search term for non-chain words', () => { + expect(parseChainFromTokenSearchQuery('dai', enabledChains)).toEqual({ chainFilter: null, searchTerm: 'dai' }) + expect(parseChainFromTokenSearchQuery('unsupported', enabledChains)).toEqual({ + chainFilter: null, + searchTerm: 'unsupported', + }) + }) + }) + + describe('multi-word searches', () => { + it('parses chain from first word', () => { + expect(parseChainFromTokenSearchQuery('ethereum dai', enabledChains)).toEqual({ + chainFilter: UniverseChainId.Mainnet, + searchTerm: 'dai', + }) + expect(parseChainFromTokenSearchQuery('ethereum dai token', enabledChains)).toEqual({ + chainFilter: UniverseChainId.Mainnet, + searchTerm: 'dai token', + }) + expect(parseChainFromTokenSearchQuery('arbitrum uni corn token', enabledChains)).toEqual({ + chainFilter: UniverseChainId.ArbitrumOne, + searchTerm: 'uni corn token', + }) + }) + + it('parses chain from last word when first word is not a chain', () => { + expect(parseChainFromTokenSearchQuery('dai ethereum', enabledChains)).toEqual({ + chainFilter: UniverseChainId.Mainnet, + searchTerm: 'dai', + }) + expect(parseChainFromTokenSearchQuery('uni corn token base', enabledChains)).toEqual({ + chainFilter: UniverseChainId.Base, + searchTerm: 'uni corn token', + }) + }) + + it('prioritizes first word chain match over last word', () => { + expect(parseChainFromTokenSearchQuery('base token ethereum', enabledChains)).toEqual({ + chainFilter: UniverseChainId.Base, + searchTerm: 'token ethereum', + }) + expect(parseChainFromTokenSearchQuery('ethereum token base', enabledChains)).toEqual({ + chainFilter: UniverseChainId.Mainnet, + searchTerm: 'token base', + }) + }) + }) + + describe('edge cases', () => { + it('handles extra spaces and trimming', () => { + expect(parseChainFromTokenSearchQuery(' ethereum dai ', enabledChains)).toEqual({ + chainFilter: UniverseChainId.Mainnet, + searchTerm: 'dai', + }) + expect(parseChainFromTokenSearchQuery('ethereum dai', enabledChains)).toEqual({ + chainFilter: UniverseChainId.Mainnet, + searchTerm: 'dai', + }) + }) + + it('returns original search when no chain is found', () => { + expect(parseChainFromTokenSearchQuery('random search terms', enabledChains)).toEqual({ + chainFilter: null, + searchTerm: 'random search terms', + }) + }) + + it('handles chain name that matches but no search term remains', () => { + expect(parseChainFromTokenSearchQuery('ethereum', enabledChains)).toEqual({ + chainFilter: UniverseChainId.Mainnet, + searchTerm: null, + }) + }) + }) + + describe('different chain types', () => { + it('parses various chain types', () => { + expect(parseChainFromTokenSearchQuery('arbitrum dai', enabledChains)).toEqual({ + chainFilter: UniverseChainId.ArbitrumOne, + searchTerm: 'dai', + }) + expect(parseChainFromTokenSearchQuery('base usdc', enabledChains)).toEqual({ + chainFilter: UniverseChainId.Base, + searchTerm: 'usdc', + }) + expect(parseChainFromTokenSearchQuery('optimism link', enabledChains)).toEqual({ + chainFilter: UniverseChainId.Optimism, + searchTerm: 'link', + }) + expect(parseChainFromTokenSearchQuery('polygon matic', enabledChains)).toEqual({ + chainFilter: UniverseChainId.Polygon, + searchTerm: 'matic', + }) + }) + }) + + describe('empty enabled chains', () => { + it('returns search term when no chains are enabled', () => { + expect(parseChainFromTokenSearchQuery('eth dai', [])).toEqual({ chainFilter: null, searchTerm: 'eth dai' }) + }) + }) +}) diff --git a/packages/uniswap/src/utils/search/parseChainFromTokenSearchQuery.ts b/packages/uniswap/src/utils/search/parseChainFromTokenSearchQuery.ts new file mode 100644 index 00000000000..b44c3db8dad --- /dev/null +++ b/packages/uniswap/src/utils/search/parseChainFromTokenSearchQuery.ts @@ -0,0 +1,81 @@ +import { UniverseChainId } from 'uniswap/src/features/chains/types' +import { getPossibleChainMatchFromSearchWord } from 'uniswap/src/utils/search/getPossibleChainMatchFromSearchWord' + +/** + * Parses a search query to extract chain filter and search term. + * Handles patterns like "eth dai", "dai eth", "ethereum usdc", etc. + * + * @param searchQuery - The search query string + * @param enabledChains - Array of enabled chain IDs to search within + * @returns An object containing the parsed `chainFilter` and `searchTerm` + */ +export function parseChainFromTokenSearchQuery( + searchQuery: string | null, + enabledChains: UniverseChainId[], +): { + chainFilter: UniverseChainId | null + searchTerm: string | null +} { + if (!searchQuery) { + return { + chainFilter: null, + searchTerm: null, + } + } + + const sanitizedSearch = searchQuery.trim().replace(/\s+/g, ' ') + const splitSearch = sanitizedSearch.split(' ') + if (splitSearch.length === 0) { + return { + chainFilter: null, + searchTerm: null, + } + } + + if (splitSearch.length === 1) { + const singleWordSearch = splitSearch[0] + const searchChainMatch = singleWordSearch + ? getPossibleChainMatchFromSearchWord(singleWordSearch, enabledChains) + : undefined + if (searchChainMatch) { + return { + chainFilter: searchChainMatch, + searchTerm: null, + } + } else { + return { + chainFilter: null, + searchTerm: splitSearch[0] || null, + } + } + } + + const firstWord = splitSearch[0]?.toLowerCase() + const lastWord = splitSearch[splitSearch.length - 1]?.toLowerCase() + + const firstWordChainMatch = firstWord ? getPossibleChainMatchFromSearchWord(firstWord, enabledChains) : undefined + const lastWordChainMatch = lastWord ? getPossibleChainMatchFromSearchWord(lastWord, enabledChains) : undefined + + if (firstWordChainMatch) { + // First word is chain, rest is search term + const search = splitSearch.slice(1).join(' ').trim() + return { + chainFilter: firstWordChainMatch, + searchTerm: search || null, + } + } + + if (lastWordChainMatch) { + // Last word is chain, preceding words are search term + const search = splitSearch.slice(0, -1).join(' ').trim() + return { + chainFilter: lastWordChainMatch, + searchTerm: search || null, + } + } + + return { + chainFilter: null, + searchTerm: searchQuery, + } +} diff --git a/packages/uniswap/tsconfig.json b/packages/uniswap/tsconfig.json index 156cfd244ec..0e4e5fb492b 100644 --- a/packages/uniswap/tsconfig.json +++ b/packages/uniswap/tsconfig.json @@ -13,6 +13,9 @@ }, { "path": "../config" + }, + { + "path": "../gating" } ], "compilerOptions": { diff --git a/packages/wallet/package.json b/packages/wallet/package.json index 9ce21858e57..7695cf79342 100644 --- a/packages/wallet/package.json +++ b/packages/wallet/package.json @@ -29,12 +29,13 @@ "@scure/bip32": "1.3.2", "@tanstack/react-query": "5.77.2", "@uniswap/analytics-events": "2.43.0", - "@uniswap/client-data-api": "0.0.14", + "@uniswap/client-data-api": "0.0.18", "@uniswap/permit2-sdk": "1.3.0", "@uniswap/router-sdk": "2.0.2", "@uniswap/sdk-core": "7.7.2", "@uniswap/universal-router-sdk": "4.19.5", "@universe/api": "workspace:^", + "@universe/gating": "workspace:^", "apollo3-cache-persist": "0.14.1", "dayjs": "1.11.7", "ethers": "5.7.2", diff --git a/packages/wallet/src/components/landing/LandingBackground.tsx b/packages/wallet/src/components/landing/LandingBackground.tsx index 656492eb7a3..d39a33cb798 100644 --- a/packages/wallet/src/components/landing/LandingBackground.tsx +++ b/packages/wallet/src/components/landing/LandingBackground.tsx @@ -76,7 +76,7 @@ const OnboardingAnimation = ({ easing: Easing.elastic(1.1), }), ) - }, [uniswapLogoScale]) + }, []) useTimeout(() => { setShowAnimatedElements(true) @@ -184,7 +184,7 @@ const AnimatedElements = ({ ) innerAnimation.value = withDelay(INNER_CIRCLE_SHOW_DELAY, withSpring(0.8)) outerAnimation.value = withDelay(OUTER_CIRCLE_SHOW_DELAY, withSpring(0.8)) - }, [innerAnimation, outerAnimation, rotation]) + }, []) const innerCircleStyle = useAnimatedStyle(() => { return { diff --git a/packages/wallet/src/components/smartWallet/smartAccounts/hooks.ts b/packages/wallet/src/components/smartWallet/smartAccounts/hooks.ts index 5b252e271bb..72f960be889 100644 --- a/packages/wallet/src/components/smartWallet/smartAccounts/hooks.ts +++ b/packages/wallet/src/components/smartWallet/smartAccounts/hooks.ts @@ -1,8 +1,7 @@ +import { FeatureFlags, useFeatureFlag } from '@universe/gating' import { useCallback, useEffect, useState } from 'react' import { useSelector } from 'react-redux' import { AccountType } from 'uniswap/src/features/accounts/types' -import { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' import { DEFAULT_TOAST_HIDE_DELAY } from 'uniswap/src/features/notifications/constants' import { useSuccessfulSwapCompleted } from 'uniswap/src/features/transactions/hooks/useSuccessfulSwapCompleted' import { TransactionDetails } from 'uniswap/src/features/transactions/types/transactionDetails' diff --git a/packages/wallet/src/features/gating/userPropertyHooks.ts b/packages/wallet/src/features/gating/userPropertyHooks.ts index 3fe58e437cc..f9f9a571080 100644 --- a/packages/wallet/src/features/gating/userPropertyHooks.ts +++ b/packages/wallet/src/features/gating/userPropertyHooks.ts @@ -1,8 +1,8 @@ +import { getStatsigClient } from '@universe/gating' import { useEffect } from 'react' import { useUnitagsAddressQuery } from 'uniswap/src/data/apiClients/unitagsApi/useUnitagsAddressQuery' import { AccountType } from 'uniswap/src/features/accounts/types' import { useENSName } from 'uniswap/src/features/ens/api' -import { getStatsigClient } from 'uniswap/src/features/gating/sdk/statsig' import { Platform } from 'uniswap/src/features/platforms/types/Platform' import { getValidAddress } from 'uniswap/src/utils/addresses' import { logger } from 'utilities/src/logger/logger' diff --git a/packages/wallet/src/features/smartWallet/hooks/useNetworkBalances.tsx b/packages/wallet/src/features/smartWallet/hooks/useNetworkBalances.tsx index ced5d4c5724..75146ef0bb6 100644 --- a/packages/wallet/src/features/smartWallet/hooks/useNetworkBalances.tsx +++ b/packages/wallet/src/features/smartWallet/hooks/useNetworkBalances.tsx @@ -1,8 +1,8 @@ +import { useStatsigClientStatus } from '@universe/gating' import { useEffect, useState } from 'react' import { fetchGasFeeQuery } from 'uniswap/src/data/apiClients/uniswapApi/useGasFeeQuery' import { getChainInfo } from 'uniswap/src/features/chains/chainInfo' import { DEFAULT_NATIVE_ADDRESS } from 'uniswap/src/features/chains/evm/defaults' -import { useStatsigClientStatus } from 'uniswap/src/features/gating/hooks' import { createEthersProvider } from 'uniswap/src/features/providers/createEthersProvider' import { useSmartWalletChains } from 'wallet/src/features/smartWallet/hooks/useSmartWalletChains' import { NetworkInfo } from 'wallet/src/features/smartWallet/InsufficientFundsNetworkRow' diff --git a/packages/wallet/src/features/transactions/contexts/WalletUniswapContext.tsx b/packages/wallet/src/features/transactions/contexts/WalletUniswapContext.tsx index 4a3da58b3a1..3fa70472e1b 100644 --- a/packages/wallet/src/features/transactions/contexts/WalletUniswapContext.tsx +++ b/packages/wallet/src/features/transactions/contexts/WalletUniswapContext.tsx @@ -1,11 +1,10 @@ +import { FeatureFlags, useFeatureFlag } from '@universe/gating' import { ethers } from 'ethers' import React, { PropsWithChildren, useCallback, useEffect, useState } from 'react' import { UniswapProvider } from 'uniswap/src/contexts/UniswapContext' import { getDelegationService } from 'uniswap/src/domains/services' import { useEnabledChains } from 'uniswap/src/features/chains/hooks/useEnabledChains' import { UniverseChainId } from 'uniswap/src/features/chains/types' -import { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' import { useUpdateDelegatedState } from 'uniswap/src/features/smartWallet/delegation/hooks/useUpdateDelegateState' import { useHasAccountMismatchCallback } from 'uniswap/src/features/smartWallet/mismatch/hooks' import { MismatchContextProvider } from 'uniswap/src/features/smartWallet/mismatch/MismatchContext' diff --git a/packages/wallet/src/features/transactions/executeTransaction/services/featureFlagService.ts b/packages/wallet/src/features/transactions/executeTransaction/services/featureFlagService.ts index ea11a986c21..2780711d0a6 100644 --- a/packages/wallet/src/features/transactions/executeTransaction/services/featureFlagService.ts +++ b/packages/wallet/src/features/transactions/executeTransaction/services/featureFlagService.ts @@ -1,5 +1,5 @@ -import { ExperimentProperties } from 'uniswap/src/features/gating/experiments' -import type { FeatureFlags } from 'uniswap/src/features/gating/flags' +import type { FeatureFlags } from '@universe/gating' +import { ExperimentProperties } from '@universe/gating' export interface FeatureFlagService { isFeatureEnabled(flagName: FeatureFlags): boolean diff --git a/packages/wallet/src/features/transactions/executeTransaction/services/featureFlagServiceImpl.ts b/packages/wallet/src/features/transactions/executeTransaction/services/featureFlagServiceImpl.ts index b55151e3c28..dd5b6ec99a5 100644 --- a/packages/wallet/src/features/transactions/executeTransaction/services/featureFlagServiceImpl.ts +++ b/packages/wallet/src/features/transactions/executeTransaction/services/featureFlagServiceImpl.ts @@ -1,6 +1,4 @@ -import { ExperimentProperties } from 'uniswap/src/features/gating/experiments' -import { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { getExperimentValue, getFeatureFlag } from 'uniswap/src/features/gating/hooks' +import { ExperimentProperties, FeatureFlags, getExperimentValue, getFeatureFlag } from '@universe/gating' import { FeatureFlagService } from 'wallet/src/features/transactions/executeTransaction/services/featureFlagService' export const createFeatureFlagService = (): FeatureFlagService => { diff --git a/packages/wallet/src/features/transactions/executeTransaction/services/transactionConfigServiceImpl.test.ts b/packages/wallet/src/features/transactions/executeTransaction/services/transactionConfigServiceImpl.test.ts index 74c9a33abd9..fb0d18f8bfc 100644 --- a/packages/wallet/src/features/transactions/executeTransaction/services/transactionConfigServiceImpl.test.ts +++ b/packages/wallet/src/features/transactions/executeTransaction/services/transactionConfigServiceImpl.test.ts @@ -1,5 +1,5 @@ +import { FeatureFlags } from '@universe/gating' import { UniverseChainId } from 'uniswap/src/features/chains/types' -import { FeatureFlags } from 'uniswap/src/features/gating/flags' import { logger as loggerUtil } from 'utilities/src/logger/logger' import { isPrivateRpcSupportedOnChain } from 'wallet/src/features/providers/utils' import { FeatureFlagService } from 'wallet/src/features/transactions/executeTransaction/services/featureFlagService' diff --git a/packages/wallet/src/features/transactions/executeTransaction/services/transactionConfigServiceImpl.ts b/packages/wallet/src/features/transactions/executeTransaction/services/transactionConfigServiceImpl.ts index 7c4f3d0a18c..97651db7513 100644 --- a/packages/wallet/src/features/transactions/executeTransaction/services/transactionConfigServiceImpl.ts +++ b/packages/wallet/src/features/transactions/executeTransaction/services/transactionConfigServiceImpl.ts @@ -1,6 +1,5 @@ +import { Experiments, FeatureFlags, PrivateRpcProperties } from '@universe/gating' import { UniverseChainId } from 'uniswap/src/features/chains/types' -import { Experiments, PrivateRpcProperties } from 'uniswap/src/features/gating/experiments' -import { FeatureFlags } from 'uniswap/src/features/gating/flags' import { DEFAULT_FLASHBOTS_ENABLED } from 'uniswap/src/features/providers/FlashbotsCommon' import { logger as loggerUtil } from 'utilities/src/logger/logger' import { isPrivateRpcSupportedOnChain } from 'wallet/src/features/providers/utils' diff --git a/packages/wallet/src/features/transactions/executeTransaction/tryGetNonce.ts b/packages/wallet/src/features/transactions/executeTransaction/tryGetNonce.ts index 2b443309e17..ac9f241eb22 100644 --- a/packages/wallet/src/features/transactions/executeTransaction/tryGetNonce.ts +++ b/packages/wallet/src/features/transactions/executeTransaction/tryGetNonce.ts @@ -1,11 +1,15 @@ +import { + Experiments, + FeatureFlags, + getExperimentValue, + getFeatureFlagName, + getStatsigClient, + PrivateRpcProperties, +} from '@universe/gating' import { SagaIterator } from 'redux-saga' import { call, select } from 'typed-redux-saga' import { SignerMnemonicAccountMeta } from 'uniswap/src/features/accounts/types' import { UniverseChainId } from 'uniswap/src/features/chains/types' -import { Experiments, PrivateRpcProperties } from 'uniswap/src/features/gating/experiments' -import { FeatureFlags, getFeatureFlagName } from 'uniswap/src/features/gating/flags' -import { getExperimentValue } from 'uniswap/src/features/gating/hooks' -import { getStatsigClient } from 'uniswap/src/features/gating/sdk/statsig' import { DEFAULT_FLASHBOTS_ENABLED } from 'uniswap/src/features/providers/FlashbotsCommon' import { makeSelectAddressTransactions } from 'uniswap/src/features/transactions/selectors' import { isClassic } from 'uniswap/src/features/transactions/swap/utils/routing' diff --git a/packages/wallet/src/features/transactions/send/TokenSelectorPanel.tsx b/packages/wallet/src/features/transactions/send/TokenSelectorPanel.tsx index 5cf5a03e7a9..453caf0b2f1 100644 --- a/packages/wallet/src/features/transactions/send/TokenSelectorPanel.tsx +++ b/packages/wallet/src/features/transactions/send/TokenSelectorPanel.tsx @@ -1,5 +1,4 @@ import { Currency, CurrencyAmount } from '@uniswap/sdk-core' -import { Maybe } from 'graphql/jsutils/Maybe' import { useTranslation } from 'react-i18next' import { Flex, Text, TouchableArea } from 'ui/src' import { RotatableChevron } from 'ui/src/components/icons' diff --git a/packages/wallet/src/features/transactions/swap/confirmation.ts b/packages/wallet/src/features/transactions/swap/confirmation.ts index b0adbcde1c4..c784e69e98a 100644 --- a/packages/wallet/src/features/transactions/swap/confirmation.ts +++ b/packages/wallet/src/features/transactions/swap/confirmation.ts @@ -1,9 +1,8 @@ import { TradingApi } from '@universe/api' +import { FeatureFlags, getFeatureFlagName, getStatsigClient } from '@universe/gating' import { SagaGenerator, take } from 'typed-redux-saga' import { getDelegationService } from 'uniswap/src/domains/services' import { UniverseChainId } from 'uniswap/src/features/chains/types' -import { FeatureFlags, getFeatureFlagName } from 'uniswap/src/features/gating/flags' -import { getStatsigClient } from 'uniswap/src/features/gating/sdk/statsig' import { finalizeTransaction } from 'uniswap/src/features/transactions/slice' import { PermitMethod, SwapTxAndGasInfo } from 'uniswap/src/features/transactions/swap/types/swapTxAndGasInfo' import { TransactionStatus } from 'uniswap/src/features/transactions/types/transactionDetails' diff --git a/packages/wallet/src/features/transactions/swap/executeSwapSaga.ts b/packages/wallet/src/features/transactions/swap/executeSwapSaga.ts index 29439a7438c..10380eabbda 100644 --- a/packages/wallet/src/features/transactions/swap/executeSwapSaga.ts +++ b/packages/wallet/src/features/transactions/swap/executeSwapSaga.ts @@ -1,8 +1,7 @@ +import { DynamicConfigs, getDynamicConfigValue, SyncTransactionSubmissionChainIdsConfigKey } from '@universe/gating' import { call, put } from 'typed-redux-saga' import type { SignerMnemonicAccountMeta } from 'uniswap/src/features/accounts/types' import type { UniverseChainId } from 'uniswap/src/features/chains/types' -import { DynamicConfigs, SyncTransactionSubmissionChainIdsConfigKey } from 'uniswap/src/features/gating/configs' -import { getDynamicConfigValue } from 'uniswap/src/features/gating/hooks' import { pushNotification } from 'uniswap/src/features/notifications/slice/slice' import { AppNotificationType } from 'uniswap/src/features/notifications/slice/types' import type { SwapTradeBaseProperties } from 'uniswap/src/features/telemetry/types' diff --git a/packages/wallet/src/features/transactions/swap/hooks/useSwapHandlers.ts b/packages/wallet/src/features/transactions/swap/hooks/useSwapHandlers.ts index 420ac15b9a1..ab5987f0921 100644 --- a/packages/wallet/src/features/transactions/swap/hooks/useSwapHandlers.ts +++ b/packages/wallet/src/features/transactions/swap/hooks/useSwapHandlers.ts @@ -1,9 +1,8 @@ +import { FeatureFlags, getFeatureFlag } from '@universe/gating' import { useCallback, useMemo } from 'react' import { useDispatch, useSelector } from 'react-redux' import { AccountMeta } from 'uniswap/src/features/accounts/types' import { usePortfolioTotalValue } from 'uniswap/src/features/dataApi/balances/balancesRest' -import { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { getFeatureFlag } from 'uniswap/src/features/gating/hooks' import { useLocalizationContext } from 'uniswap/src/features/language/LocalizationContext' import { SwapEventName } from 'uniswap/src/features/telemetry/constants' import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' diff --git a/packages/wallet/src/features/transactions/swap/modals/QueuedOrderModal.tsx b/packages/wallet/src/features/transactions/swap/modals/QueuedOrderModal.tsx index 18e6819fb83..db13d952f17 100644 --- a/packages/wallet/src/features/transactions/swap/modals/QueuedOrderModal.tsx +++ b/packages/wallet/src/features/transactions/swap/modals/QueuedOrderModal.tsx @@ -1,4 +1,5 @@ import { CurrencyAmount, TradeType } from '@uniswap/sdk-core' +import { FeatureFlags, useFeatureFlag } from '@universe/gating' import { useCallback, useMemo } from 'react' import { useTranslation } from 'react-i18next' import { useDispatch } from 'react-redux' @@ -10,8 +11,6 @@ import { Modal } from 'uniswap/src/components/modals/Modal' import { LearnMoreLink } from 'uniswap/src/components/text/LearnMoreLink' import { uniswapUrls } from 'uniswap/src/constants/urls' import { AssetType, TradeableAsset } from 'uniswap/src/entities/assets' -import { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' import { ModalName } from 'uniswap/src/features/telemetry/constants' import { useCurrencyInfo } from 'uniswap/src/features/tokens/useCurrencyInfo' import { diff --git a/packages/wallet/src/features/transactions/swap/prepareAndSignSwapSaga.test.ts b/packages/wallet/src/features/transactions/swap/prepareAndSignSwapSaga.test.ts index dcd61b4350c..8b9eda0d70f 100644 --- a/packages/wallet/src/features/transactions/swap/prepareAndSignSwapSaga.test.ts +++ b/packages/wallet/src/features/transactions/swap/prepareAndSignSwapSaga.test.ts @@ -35,7 +35,8 @@ jest.mock('wallet/src/features/transactions/factories/createTransactionServices' const mockPrivateRpcFlag = jest.fn().mockReturnValue(true) -jest.mock('uniswap/src/features/gating/sdk/statsig', () => ({ +jest.mock('@universe/gating', () => ({ + ...jest.requireActual('@universe/gating'), getStatsigClient: jest.fn(() => ({ checkGate: jest.fn().mockImplementation((flagName: string) => { if (flagName === 'mev-blocker') { diff --git a/packages/wallet/src/features/transactions/swap/prepareAndSignSwapSaga.ts b/packages/wallet/src/features/transactions/swap/prepareAndSignSwapSaga.ts index 86d53f2ebfe..a130e9568ae 100644 --- a/packages/wallet/src/features/transactions/swap/prepareAndSignSwapSaga.ts +++ b/packages/wallet/src/features/transactions/swap/prepareAndSignSwapSaga.ts @@ -1,7 +1,6 @@ +import { FeatureFlags, getFeatureFlagName, getStatsigClient } from '@universe/gating' import { call, select } from 'typed-redux-saga' import type { SignerMnemonicAccountMeta } from 'uniswap/src/features/accounts/types' -import { FeatureFlags, getFeatureFlagName } from 'uniswap/src/features/gating/flags' -import { getStatsigClient } from 'uniswap/src/features/gating/sdk/statsig' import type { PrepareSwapParams } from 'uniswap/src/features/transactions/swap/types/swapHandlers' import { PermitMethod } from 'uniswap/src/features/transactions/swap/types/swapTxAndGasInfo' import { isBridge, isClassic, isUniswapX, isWrap } from 'uniswap/src/features/transactions/swap/utils/routing' diff --git a/packages/wallet/src/features/transactions/swap/settings/SwapProtection.tsx b/packages/wallet/src/features/transactions/swap/settings/SwapProtection.tsx index c4a1760c11b..70a8d61e30e 100644 --- a/packages/wallet/src/features/transactions/swap/settings/SwapProtection.tsx +++ b/packages/wallet/src/features/transactions/swap/settings/SwapProtection.tsx @@ -1,9 +1,9 @@ +import { FeatureFlags } from '@universe/gating' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' import { useDispatch } from 'react-redux' import { Switch, Text } from 'ui/src' import { getChainLabel } from 'uniswap/src/features/chains/utils' -import { FeatureFlags } from 'uniswap/src/features/gating/flags' import { Platform } from 'uniswap/src/features/platforms/types/Platform' import type { TransactionSettingConfig } from 'uniswap/src/features/transactions/components/settings/types' import { useSwapFormStoreDerivedSwapInfo } from 'uniswap/src/features/transactions/swap/stores/swapFormStore/useSwapFormStore' diff --git a/packages/wallet/src/features/transactions/swap/swapSaga.test.ts b/packages/wallet/src/features/transactions/swap/swapSaga.test.ts index cf2475ead8d..739e6bce957 100644 --- a/packages/wallet/src/features/transactions/swap/swapSaga.test.ts +++ b/packages/wallet/src/features/transactions/swap/swapSaga.test.ts @@ -42,7 +42,8 @@ import { SwapProtectionSetting } from 'wallet/src/features/wallet/slice' import { signerMnemonicAccount } from 'wallet/src/test/fixtures' import { getTxProvidersMocks } from 'wallet/src/test/mocks' -jest.mock('uniswap/src/features/gating/sdk/statsig', () => ({ +jest.mock('@universe/gating', () => ({ + ...jest.requireActual('@universe/gating'), getStatsigClient: jest.fn(() => ({ checkGate: jest.fn().mockReturnValue(true), getLayer: jest.fn(() => ({ diff --git a/packages/wallet/src/features/transactions/swap/swapSaga.ts b/packages/wallet/src/features/transactions/swap/swapSaga.ts index 6b265b5347b..378fad172d7 100644 --- a/packages/wallet/src/features/transactions/swap/swapSaga.ts +++ b/packages/wallet/src/features/transactions/swap/swapSaga.ts @@ -1,14 +1,13 @@ import { permit2Address } from '@uniswap/permit2-sdk' import { TradingApi } from '@universe/api' +import { Experiments, FeatureFlags, getFeatureFlagName, getStatsigClient } from '@universe/gating' import { call, put, select } from 'typed-redux-saga' import { SignerMnemonicAccountMeta } from 'uniswap/src/features/accounts/types' -import { FeatureFlags, getFeatureFlagName } from 'uniswap/src/features/gating/flags' -import { getStatsigClient } from 'uniswap/src/features/gating/sdk/statsig' import { pushNotification } from 'uniswap/src/features/notifications/slice/slice' import { AppNotificationType } from 'uniswap/src/features/notifications/slice/types' import { SwapTradeBaseProperties } from 'uniswap/src/features/telemetry/types' -import { FLASHBLOCKS_UI_SKIP_ROUTES } from 'uniswap/src/features/transactions/swap/components/UnichainInstantBalanceModal/constants' -import { getIsFlashblocksEnabled } from 'uniswap/src/features/transactions/swap/hooks/useIsUnichainFlashblocksEnabled' +import { logExperimentQualifyingEvent } from 'uniswap/src/features/telemetry/utils/logExperimentQualifyingEvent' +import { getFlashblocksExperimentStatus } from 'uniswap/src/features/transactions/swap/hooks/useIsUnichainFlashblocksEnabled' import { PermitMethod, ValidatedSwapTxContext } from 'uniswap/src/features/transactions/swap/types/swapTxAndGasInfo' import { isUniswapX } from 'uniswap/src/features/transactions/swap/utils/routing' import { tradeToTransactionInfo } from 'uniswap/src/features/transactions/swap/utils/trade' @@ -217,8 +216,19 @@ export function* approveAndSwap(params: SwapParams) { } yield* call(executeTransaction, executeTransactionParams) - // Only show pending notification if not a flashblock transaction - if (!getIsFlashblocksEnabled(chainId) || FLASHBLOCKS_UI_SKIP_ROUTES.includes(swapTxContext.routing)) { + const { shouldLogQualifyingEvent, shouldShowModal } = getFlashblocksExperimentStatus({ + chainId, + routing: swapTxContext.routing, + }) + + if (shouldLogQualifyingEvent) { + logExperimentQualifyingEvent({ + experiment: Experiments.UnichainFlashblocksModal, + }) + } + + // Show pending notification for control variant or ineligible swaps + if (!shouldShowModal) { yield* put(pushNotification({ type: AppNotificationType.SwapPending, wrapType: WrapType.NotApplicable })) } diff --git a/packages/wallet/src/features/transactions/watcher/transactionFinalizationSaga.ts b/packages/wallet/src/features/transactions/watcher/transactionFinalizationSaga.ts index d6090419b58..1e11344cda9 100644 --- a/packages/wallet/src/features/transactions/watcher/transactionFinalizationSaga.ts +++ b/packages/wallet/src/features/transactions/watcher/transactionFinalizationSaga.ts @@ -1,14 +1,13 @@ import { ApolloClient, NormalizedCacheObject } from '@apollo/client' import { TradeType } from '@uniswap/sdk-core' import { SharedQueryClient } from '@universe/api' +import { Experiments, getExperimentValue, PrivateRpcProperties } from '@universe/gating' import { BigNumber } from 'ethers' import { call, put, select, takeEvery } from 'typed-redux-saga' import { UniverseChainId } from 'uniswap/src/features/chains/types' import { getChainLabel } from 'uniswap/src/features/chains/utils' import { getGasPrice } from 'uniswap/src/features/gas/types' import { findLocalGasStrategy } from 'uniswap/src/features/gas/utils' -import { Experiments, PrivateRpcProperties } from 'uniswap/src/features/gating/experiments' -import { getExperimentValue } from 'uniswap/src/features/gating/hooks' import { setNotificationStatus } from 'uniswap/src/features/notifications/slice/slice' import { Platform } from 'uniswap/src/features/platforms/types/Platform' import { refetchQueries } from 'uniswap/src/features/portfolio/portfolioUpdates/refetchQueriesSaga' diff --git a/packages/wallet/src/features/transactions/watcher/watchOnChainTransactionSaga.test.ts b/packages/wallet/src/features/transactions/watcher/watchOnChainTransactionSaga.test.ts index f2b65470cbf..46548927883 100644 --- a/packages/wallet/src/features/transactions/watcher/watchOnChainTransactionSaga.test.ts +++ b/packages/wallet/src/features/transactions/watcher/watchOnChainTransactionSaga.test.ts @@ -27,7 +27,8 @@ import { getProvider } from 'wallet/src/features/wallet/context' let mockGates: Record = {} -jest.mock('uniswap/src/features/gating/sdk/statsig', () => ({ +jest.mock('@universe/gating', () => ({ + ...jest.requireActual('@universe/gating'), getStatsigClient: jest.fn(() => ({ checkGate: jest.fn((gate: string) => mockGates[gate] ?? false), getDynamicConfig: jest.fn(() => ({ diff --git a/packages/wallet/src/features/transactions/watcher/watchOnChainTransactionSaga.ts b/packages/wallet/src/features/transactions/watcher/watchOnChainTransactionSaga.ts index 97956f77add..b134be6e2df 100644 --- a/packages/wallet/src/features/transactions/watcher/watchOnChainTransactionSaga.ts +++ b/packages/wallet/src/features/transactions/watcher/watchOnChainTransactionSaga.ts @@ -1,9 +1,8 @@ import { ApolloClient, NormalizedCacheObject } from '@apollo/client' +import { FeatureFlags, getFeatureFlagName, getStatsigClient } from '@universe/gating' import { BigNumber, BigNumberish, providers } from 'ethers' import { call, cancel, delay, fork, put, race, spawn, take } from 'typed-redux-saga' import { UniverseChainId } from 'uniswap/src/features/chains/types' -import { FeatureFlags, getFeatureFlagName } from 'uniswap/src/features/gating/flags' -import { getStatsigClient } from 'uniswap/src/features/gating/sdk/statsig' import { pushNotification } from 'uniswap/src/features/notifications/slice/slice' import { AppNotificationType } from 'uniswap/src/features/notifications/slice/types' import { waitForFlashbotsProtectReceipt } from 'uniswap/src/features/providers/FlashbotsCommon' diff --git a/packages/wallet/tsconfig.json b/packages/wallet/tsconfig.json index 96488672116..038c024dc0f 100644 --- a/packages/wallet/tsconfig.json +++ b/packages/wallet/tsconfig.json @@ -11,6 +11,9 @@ { "path": "../ui" }, + { + "path": "../gating" + }, { "path": "../api" } diff --git a/scripts/clean.sh b/scripts/clean.sh index 2f6aef5b8f6..fe3a27eb887 100644 --- a/scripts/clean.sh +++ b/scripts/clean.sh @@ -1,25 +1,78 @@ #!/bin/bash set -euo pipefail -# Restore the monorepo as close to a freshly cloned state as possible (except for the .env files) - -# Safety check - confirm with user before proceeding -echo "⚠️ WARNING: This will remove ALL untracked files and directories from your repository!" -echo "Only .env files will be preserved." -read -p "Are you sure you want to continue? (y/N): " -n 1 -r -echo -if [[ ! $REPLY =~ ^[Yy]$ ]]; then - echo "Operation cancelled." - exit 1 +# Restore the monorepo as close to a freshly cloned state as possible +# Usage: bun clean [--git] [--node] [--bun] +# --git Remove git untracked files (except for .env files, node_modules, and .claude directories) +# --node Remove all node_modules (instead of just local packages) +# --bun Clear the global bun cache + +# Parse CLI arguments +GIT_CLEAN=false +NODE_MODULES=false +BUN_CACHE=false +HAS_CLI_ARGS=false + +while [[ $# -gt 0 ]]; do + HAS_CLI_ARGS=true + case $1 in + --git) + GIT_CLEAN=true + shift + ;; + --node) + NODE_MODULES=true + shift + ;; + --bun) + BUN_CACHE=true + shift + ;; + *) + echo "Unknown argument: $1" + echo "Usage: $0 [--git] [--node] [--bun]" + exit 1 + ;; + esac +done + +prompt_yes_no() { + local message=$1 + local var_name=$2 + echo "$message" + read -p "(y/n): " -n 1 -r + echo + if [[ $REPLY =~ ^[Yy]$ ]]; then + eval "$var_name=true" + fi +} + +# Only prompt if no CLI args were provided +if [ "$HAS_CLI_ARGS" = false ]; then + prompt_yes_no "⚠️ UNTRACKED FILES: Do you want to remove all files untracked by git..." "GIT_CLEAN" + prompt_yes_no "📦 NODE MODULES: Local packages will be cleaned. Do you also want to remove ALL other node_modules (slower but more thorough)?" "NODE_MODULES" fi -# Remove all untracked files -echo "Removing all untracked files..." -git clean -fdx -e "**/.env*" +# Execute git clean if confirmed +if [ "$GIT_CLEAN" = true ]; then + echo "Removing all untracked files except for .env files..." + git clean -fdx -e "**/.env*" -e "**/node_modules" -e "**/.claude" +fi + +# Execute node_modules cleanup +if [ "$NODE_MODULES" = true ]; then + echo "Removing node_modules..." + bun run g:rm:nodemodules +else + echo "Removing local packages..." + bun run g:rm:local-packages +fi -# Remove node_modules -echo "Removing node_modules..." -bun run g:rm:nodemodules +# Clear global bun cache +if [ "$BUN_CACHE" = true ]; then + echo "Clearing global bun cache..." + bun pm cache rm +fi # Install dependencies echo "Installing dependencies..." @@ -28,6 +81,9 @@ bun install # Clear NX cache echo "Clearing NX cache..." bun nx reset +# Sync NX but silence errors because sometimes the first NX command +# after a reset fails due to a race condition with the NX daemon +bun nx sync 2>/dev/null || true # Prepare packages echo "Preparing packages..." diff --git a/scripts/remove-local-packages.sh b/scripts/remove-local-packages.sh new file mode 100755 index 00000000000..69bb087ad78 --- /dev/null +++ b/scripts/remove-local-packages.sh @@ -0,0 +1,14 @@ +#!/bin/bash +set -euo pipefail + +# This script queries NX and then removes local monorepo packages from node_modules + +projects=$(bun nx show projects) + +while IFS= read -r project; do + project_path="node_modules/$project" + echo "Removing $project_path" + rm -rf "$project_path" +done <<< "$projects" + +echo "Done removing local packages from node_modules" diff --git a/tools/uniswap-nx/src/generators/package/files/biome.json b/tools/uniswap-nx/src/generators/package/files/biome.json deleted file mode 100644 index 8e08a6604fc..00000000000 --- a/tools/uniswap-nx/src/generators/package/files/biome.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "extends": "//", - "formatter": { - "includes": ["**"] - } -} diff --git a/tools/uniswap-nx/src/generators/package/files/tsconfig.json b/tools/uniswap-nx/src/generators/package/files/tsconfig.json index 7b64995b539..194d7e1a230 100644 --- a/tools/uniswap-nx/src/generators/package/files/tsconfig.json +++ b/tools/uniswap-nx/src/generators/package/files/tsconfig.json @@ -5,7 +5,7 @@ "compilerOptions": { "noEmit": false, "emitDeclarationOnly": true, - "types": [<%= types %>], + "types": [<%- types %>], "paths": {} }, "references": [] diff --git a/tools/uniswap-nx/src/generators/package/package.ts b/tools/uniswap-nx/src/generators/package/package.ts index 56d5156071c..c926ad5b16f 100644 --- a/tools/uniswap-nx/src/generators/package/package.ts +++ b/tools/uniswap-nx/src/generators/package/package.ts @@ -1,5 +1,6 @@ import { addProjectConfiguration, generateFiles, Tree, updateJson } from '@nx/devkit' import { addTsConfigPath } from '@nx/js' +import { execSync } from 'child_process' import * as path from 'path' import { PackageGeneratorSchema } from './schema' @@ -23,14 +24,33 @@ export async function packageGenerator(tree: Tree, options: PackageGeneratorSche return json }) const relativePathToRoot = path.relative(options.path, '') + const typesList = options.types.split(',').map((t) => t.trim()) + const types = JSON.stringify(typesList).slice(1, -1) // Remove outer brackets to fit in template generateFiles(tree, path.join(__dirname, 'files'), projectRoot, { ...options, relativePathToRoot, - types: options.types - .split(',') - .map((t) => `"${t.trim()}"`) - .join(', '), + types, }) + + // Return a task that formats only the files changed by this generator + return () => { + // Get only the files that were changed by this generator + const changedFiles = tree.listChanges().map(change => change.path).join(' ') + + if (!changedFiles) { + return + } + + try { + console.log('Formatting generated files with Biome...') + // Run biome directly on just the files changed by this generator + execSync(`bun biome format --write ${changedFiles}`, { + stdio: 'inherit', + }) + } catch (error) { + console.warn('Could not format files. You may need to run "bun g:format" manually.') + } + } } export default packageGenerator diff --git a/tsconfig.base.json b/tsconfig.base.json index 61f218e91c4..32364947eab 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -40,7 +40,9 @@ "ui/*": ["packages/ui/*"], "uniswap/*": ["packages/uniswap/*"], "utilities/*": ["packages/utilities/*"], - "wallet/*": ["packages/wallet/*"] + "wallet/*": ["packages/wallet/*"], + "@universe/gating/*": ["packages/gating/*"], + "@universe/notifications/*": ["packages/notifications/*"] } } } diff --git a/tsconfig.json b/tsconfig.json index 72b73c7c20c..5b0b2b7b2f8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -36,6 +36,12 @@ }, { "path": "./tools/uniswap-nx" + }, + { + "path": "./packages/gating" + }, + { + "path": "./packages/notifications" } ] } From c7e7311c5ae14069956a03d707eab7abff31503f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 16 Nov 2025 04:32:10 +0000 Subject: [PATCH 26/34] build(deps-dev): bump js-yaml Bumps the npm_and_yarn group with 1 update in the / directory: [js-yaml](https://github.com/nodeca/js-yaml). Updates `js-yaml` from 4.1.0 to 4.1.1 - [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md) - [Commits](https://github.com/nodeca/js-yaml/compare/4.1.0...4.1.1) --- updated-dependencies: - dependency-name: js-yaml dependency-version: 4.1.1 dependency-type: direct:development dependency-group: npm_and_yarn ... Signed-off-by: dependabot[bot] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index f42f4722421..e99f14d975d 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "i18next": "23.10.0", "i18next-parser": "8.6.0", "inquirer": "8.2.6", - "js-yaml": "4.1.0", + "js-yaml": "4.1.1", "jsonc-parser": "3.2.0", "knip": "5.50.5", "lefthook": "1.12.2", From 479fb286265549335293d828529592aaf32563a9 Mon Sep 17 00:00:00 2001 From: snyk-bot Date: Wed, 24 Dec 2025 15:03:46 +0000 Subject: [PATCH 27/34] fix: apps/mobile/Gemfile & apps/mobile/Gemfile.lock to reduce vulnerabilities The following vulnerabilities are fixed with an upgrade: - https://snyk.io/vuln/SNYK-RUBY-AWSSDKS3-14465282 --- apps/mobile/Gemfile | 2 +- apps/mobile/Gemfile.lock | 48 ++++++++++++++++++---------------------- 2 files changed, 23 insertions(+), 27 deletions(-) diff --git a/apps/mobile/Gemfile b/apps/mobile/Gemfile index a9e494ea9a1..9ca02152c75 100644 --- a/apps/mobile/Gemfile +++ b/apps/mobile/Gemfile @@ -1,6 +1,6 @@ source "https://rubygems.org" -gem 'fastlane', '2.215.0' +gem 'fastlane', '2.215.1' # Exclude problematic versions of cocoapods and activesupport that causes build failures. gem 'cocoapods', '1.15.0' gem 'activesupport', '7.1.2' diff --git a/apps/mobile/Gemfile.lock b/apps/mobile/Gemfile.lock index 1cc3a294c88..0de72b95649 100644 --- a/apps/mobile/Gemfile.lock +++ b/apps/mobile/Gemfile.lock @@ -1,10 +1,7 @@ GEM remote: https://rubygems.org/ specs: - CFPropertyList (3.0.7) - base64 - nkf - rexml + CFPropertyList (3.0.9) activesupport (7.1.2) base64 bigdecimal @@ -15,16 +12,16 @@ GEM minitest (>= 5.1) mutex_m tzinfo (~> 2.0) - addressable (2.8.7) - public_suffix (>= 2.0.2, < 7.0) + addressable (2.8.8) + public_suffix (>= 2.0.2, < 8.0) 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.1170.0) - aws-sdk-core (3.233.0) + aws-partitions (1.1198.0) + aws-sdk-core (3.240.0) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) @@ -32,18 +29,18 @@ GEM bigdecimal jmespath (~> 1, >= 1.6.1) logger - aws-sdk-kms (1.113.0) - aws-sdk-core (~> 3, >= 3.231.0) + aws-sdk-kms (1.118.0) + aws-sdk-core (~> 3, >= 3.239.1) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.199.1) - aws-sdk-core (~> 3, >= 3.231.0) + aws-sdk-s3 (1.209.0) + aws-sdk-core (~> 3, >= 3.234.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) aws-sigv4 (1.12.1) aws-eventstream (~> 1, >= 1.0.2) babosa (1.0.4) base64 (0.3.0) - bigdecimal (3.3.1) + bigdecimal (4.0.1) claide (1.1.0) cocoapods (1.15.0) addressable (~> 2.8) @@ -111,9 +108,9 @@ GEM faraday-rack (~> 1.0) faraday-retry (~> 1.0) ruby2_keywords (>= 0.0.4) - faraday-cookie_jar (0.0.7) + faraday-cookie_jar (0.0.8) faraday (>= 0.8.0) - http-cookie (~> 1.0.0) + http-cookie (>= 1.0.0) faraday-em_http (1.0.0) faraday-em_synchrony (1.0.1) faraday-excon (1.1.0) @@ -128,7 +125,7 @@ GEM faraday_middleware (1.2.1) faraday (~> 1.0) fastimage (2.4.0) - fastlane (2.215.0) + fastlane (2.215.1) CFPropertyList (>= 2.3, < 4.0.0) addressable (>= 2.8, < 3.0.0) artifactory (~> 3.0) @@ -172,7 +169,7 @@ GEM fourflusher (2.3.1) fuzzy_match (2.0.4) gh_inspector (1.1.3) - google-apis-androidpublisher_v3 (0.87.0) + google-apis-androidpublisher_v3 (0.93.0) google-apis-core (>= 0.15.0, < 2.a) google-apis-core (0.18.0) addressable (~> 2.5, >= 2.5.1) @@ -182,11 +179,11 @@ GEM mutex_m representable (~> 3.0) retriable (>= 2.0, < 4.a) - google-apis-iamcredentials_v1 (0.24.0) + google-apis-iamcredentials_v1 (0.26.0) google-apis-core (>= 0.15.0, < 2.a) google-apis-playcustomapp_v1 (0.17.0) google-apis-core (>= 0.15.0, < 2.a) - google-apis-storage_v1 (0.57.0) + google-apis-storage_v1 (0.58.0) google-apis-core (>= 0.15.0, < 2.a) google-cloud-core (1.8.0) google-cloud-env (>= 1.0, < 3.a) @@ -195,7 +192,7 @@ GEM base64 (~> 0.2) faraday (>= 1.0, < 3.a) google-cloud-errors (1.5.0) - google-cloud-storage (1.57.0) + google-cloud-storage (1.57.1) addressable (~> 2.8) digest-crc (~> 0.4) google-apis-core (>= 0.18, < 2) @@ -205,7 +202,7 @@ GEM googleauth (~> 1.9) mini_mime (~> 1.0) google-logging-utils (0.2.0) - googleauth (1.15.0) + googleauth (1.16.0) faraday (>= 1.0, < 3.a) google-cloud-env (~> 2.2) google-logging-utils (~> 0.1) @@ -221,7 +218,7 @@ GEM i18n (1.14.7) concurrent-ruby (~> 1.0) jmespath (1.6.2) - json (2.15.1) + json (2.18.0) jwt (2.10.2) base64 logger (1.7.0) @@ -229,19 +226,18 @@ GEM mini_mime (1.1.5) minitest (5.26.0) molinillo (0.8.0) - multi_json (1.17.0) + multi_json (1.18.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.1.1) os (1.1.4) plist (3.7.2) public_suffix (4.0.7) - rake (13.3.0) + rake (13.3.1) representable (3.2.0) declarative (< 0.1.0) trailblazer-option (>= 0.1.1, < 0.2.0) @@ -295,7 +291,7 @@ DEPENDENCIES activesupport (= 7.1.2) cocoapods (= 1.15.0) concurrent-ruby (= 1.3.4) - fastlane (= 2.215.0) + fastlane (= 2.215.1) xcodeproj (= 1.27.0) BUNDLED WITH From 398296f7d53e031dbb784d6a256d9555b7735200 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 16 Jan 2026 12:44:24 +0000 Subject: [PATCH 28/34] build(deps): bump the npm_and_yarn group across 3 directories with 4 updates Bumps the npm_and_yarn group with 1 update in the /apps/extension directory: [react-router](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router). Bumps the npm_and_yarn group with 4 updates in the /apps/web directory: [react-router](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router), [hono](https://github.com/honojs/hono), [qs](https://github.com/ljharb/qs) and [storybook](https://github.com/storybookjs/storybook/tree/HEAD/code/core). Bumps the npm_and_yarn group with 2 updates in the /packages/uniswap directory: [react-router](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router) and [qs](https://github.com/ljharb/qs). Updates `react-router` from 7.6.3 to 7.12.0 - [Release notes](https://github.com/remix-run/react-router/releases) - [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router/CHANGELOG.md) - [Commits](https://github.com/remix-run/react-router/commits/react-router@7.12.0/packages/react-router) Updates `react-router` from 7.6.3 to 7.12.0 - [Release notes](https://github.com/remix-run/react-router/releases) - [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router/CHANGELOG.md) - [Commits](https://github.com/remix-run/react-router/commits/react-router@7.12.0/packages/react-router) Updates `hono` from 4.10.3 to 4.11.4 - [Release notes](https://github.com/honojs/hono/releases) - [Commits](https://github.com/honojs/hono/compare/v4.10.3...v4.11.4) Updates `qs` from 6.11.0 to 6.14.1 - [Changelog](https://github.com/ljharb/qs/blob/main/CHANGELOG.md) - [Commits](https://github.com/ljharb/qs/compare/v6.11.0...v6.14.1) Updates `storybook` from 8.5.2 to 8.6.15 - [Release notes](https://github.com/storybookjs/storybook/releases) - [Changelog](https://github.com/storybookjs/storybook/blob/next/CHANGELOG.md) - [Commits](https://github.com/storybookjs/storybook/commits/v8.6.15/code/core) Updates `react-router` from 7.6.3 to 7.12.0 - [Release notes](https://github.com/remix-run/react-router/releases) - [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router/CHANGELOG.md) - [Commits](https://github.com/remix-run/react-router/commits/react-router@7.12.0/packages/react-router) Updates `qs` from 6.11.0 to 6.14.1 - [Changelog](https://github.com/ljharb/qs/blob/main/CHANGELOG.md) - [Commits](https://github.com/ljharb/qs/compare/v6.11.0...v6.14.1) --- updated-dependencies: - dependency-name: react-router dependency-version: 7.12.0 dependency-type: direct:production dependency-group: npm_and_yarn - dependency-name: react-router dependency-version: 7.12.0 dependency-type: direct:production dependency-group: npm_and_yarn - dependency-name: hono dependency-version: 4.11.4 dependency-type: direct:production dependency-group: npm_and_yarn - dependency-name: qs dependency-version: 6.14.1 dependency-type: direct:production dependency-group: npm_and_yarn - dependency-name: storybook dependency-version: 8.6.15 dependency-type: direct:development dependency-group: npm_and_yarn - dependency-name: react-router dependency-version: 7.12.0 dependency-type: direct:production dependency-group: npm_and_yarn - dependency-name: qs dependency-version: 6.14.1 dependency-type: direct:production dependency-group: npm_and_yarn ... Signed-off-by: dependabot[bot] --- apps/extension/package.json | 2 +- apps/web/package.json | 8 ++++---- packages/uniswap/package.json | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/extension/package.json b/apps/extension/package.json index 02974e473d0..bfd7f0ff2fd 100644 --- a/apps/extension/package.json +++ b/apps/extension/package.json @@ -41,7 +41,7 @@ "react-native-web": "0.19.13", "react-qr-code": "2.0.12", "react-redux": "8.0.5", - "react-router": "7.6.3", + "react-router": "7.12.0", "redux": "4.2.1", "redux-logger": "3.0.6", "redux-persist": "6.0.0", diff --git a/apps/web/package.json b/apps/web/package.json index b06f96e8d51..59eaf8abdbb 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -132,7 +132,7 @@ "resize-observer-polyfill": "1.5.1", "source-map-explorer": "2.5.3", "start-server-and-test": "2.0.0", - "storybook": "8.5.2", + "storybook": "8.6.15", "storybook-addon-pseudo-states": "4.0.2", "swc-loader": "0.2.6", "terser": "5.24.0", @@ -222,7 +222,7 @@ "fancy-canvas": "2.1.0", "framer-motion": "10.17.6", "graphql": "16.8.1", - "hono": "4.10.3", + "hono": "4.11.4", "html-entities": "2.6.0", "i18next": "23.10.0", "jotai": "1.3.7", @@ -239,7 +239,7 @@ "polished": "3.3.2", "polyfill-object.fromentries": "1.0.1", "porto": "0.0.80", - "qs": "6.11.0", + "qs": "6.14.1", "react": "18.3.1", "react-dom": "18.3.1", "react-feather": "2.0.10", @@ -250,7 +250,7 @@ "react-native-reanimated": "3.16.7", "react-popper": "2.3.0", "react-redux": "8.0.5", - "react-router": "7.6.3", + "react-router": "7.12.0", "react-scroll-sync": "0.11.2", "react-virtualized-auto-sizer": "1.0.20", "react-window": "1.8.9", diff --git a/packages/uniswap/package.json b/packages/uniswap/package.json index 9d4bae0b472..cae4f05b97a 100644 --- a/packages/uniswap/package.json +++ b/packages/uniswap/package.json @@ -82,7 +82,7 @@ "lodash": "4.17.21", "ms": "2.1.3", "poisson-disk-sampling": "2.3.1", - "qs": "6.11.0", + "qs": "6.14.1", "react": "18.3.1", "react-i18next": "14.1.0", "react-infinite-scroll-component": "6.1.0", @@ -98,7 +98,7 @@ "react-native-svg": "15.11.2", "react-native-webview": "13.13.5", "react-redux": "8.0.5", - "react-router": "7.6.3", + "react-router": "7.12.0", "react-test-renderer": "18.3.1", "react-virtualized-auto-sizer": "1.0.20", "react-window": "1.8.9", From 7593b13233641506460546ad308abeb1fe25569b Mon Sep 17 00:00:00 2001 From: Dargon789 <64915515+Dargon789@users.noreply.github.com> Date: Mon, 19 Jan 2026 22:41:24 +0700 Subject: [PATCH 29/34] Potential fix for code scanning alert no. 24: Incomplete multi-character sanitization Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Signed-off-by: Dargon789 <64915515+Dargon789@users.noreply.github.com> --- apps/cli/src/lib/pr-body-cleaner.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/cli/src/lib/pr-body-cleaner.ts b/apps/cli/src/lib/pr-body-cleaner.ts index 5213447eeb4..d759f98ae0a 100644 --- a/apps/cli/src/lib/pr-body-cleaner.ts +++ b/apps/cli/src/lib/pr-body-cleaner.ts @@ -87,7 +87,11 @@ function removeHTMLCommentsExceptCursorSummary(text: string): string { }) // Remove all other HTML comments - textWithProtection = textWithProtection.replace(//g, '') + let previousTextWithProtection: string + do { + previousTextWithProtection = textWithProtection + textWithProtection = textWithProtection.replace(//g, '') + } while (textWithProtection !== previousTextWithProtection) // Restore CURSOR_SUMMARY content (without comment markers and footers) summaries.forEach((summary, index) => { From cc5ba3edd91403f1e652b19c1778a7b29a7e23f1 Mon Sep 17 00:00:00 2001 From: Dargon789 <64915515+Dargon789@users.noreply.github.com> Date: Mon, 19 Jan 2026 22:41:54 +0700 Subject: [PATCH 30/34] Potential fix for code scanning alert no. 23: Incomplete regular expression for hostnames Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Signed-off-by: Dargon789 <64915515+Dargon789@users.noreply.github.com> --- apps/web/src/pages/Landing/Landing.e2e.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/pages/Landing/Landing.e2e.test.ts b/apps/web/src/pages/Landing/Landing.e2e.test.ts index 0fdce8d788a..dd3d96bbe0e 100644 --- a/apps/web/src/pages/Landing/Landing.e2e.test.ts +++ b/apps/web/src/pages/Landing/Landing.e2e.test.ts @@ -62,7 +62,7 @@ test.describe( await page.unrouteAll({ behavior: 'ignoreErrors' }) }) test('renders UK compliance banner in UK', async ({ page }) => { - await page.route(/(?:interface|beta).gateway.uniswap.org\/v1\/amplitude-proxy/, async (route) => { + await page.route(/(?:interface|beta)\.gateway\.uniswap\.org\/v1\/amplitude-proxy/, async (route) => { const requestBody = JSON.stringify(await route.request().postDataJSON()) const originalResponse = await route.fetch() const byteSize = new Blob([requestBody]).size From 639048855270daf22615259cf35b73f46acda194 Mon Sep 17 00:00:00 2001 From: Dargon789 <64915515+Dargon789@users.noreply.github.com> Date: Mon, 19 Jan 2026 22:42:25 +0700 Subject: [PATCH 31/34] Potential fix for code scanning alert no. 21: Incomplete URL substring sanitization Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Signed-off-by: Dargon789 <64915515+Dargon789@users.noreply.github.com> --- .../turnstileSolver.integration.test.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/sessions/src/challenge-solvers/turnstileSolver.integration.test.ts b/packages/sessions/src/challenge-solvers/turnstileSolver.integration.test.ts index 21e5144d8c2..6327cbb39a2 100644 --- a/packages/sessions/src/challenge-solvers/turnstileSolver.integration.test.ts +++ b/packages/sessions/src/challenge-solvers/turnstileSolver.integration.test.ts @@ -26,7 +26,16 @@ beforeAll(() => { }) vi.spyOn(document.head, 'appendChild').mockImplementation((node) => { - if (node instanceof HTMLScriptElement && node.src.includes('challenges.cloudflare.com')) { + let isTurnstileScript = false + if (node instanceof HTMLScriptElement && node.src) { + try { + const url = new URL(node.src, window.location.origin) + isTurnstileScript = url.hostname === 'challenges.cloudflare.com' + } catch { + isTurnstileScript = false + } + } + if (isTurnstileScript) { // Simulate script load immediately setTimeout(() => { // Set up the mock turnstile API From cc6e79c10208d5d24ab78cc3b6683be8485a59c0 Mon Sep 17 00:00:00 2001 From: Dargon789 <64915515+Dargon789@users.noreply.github.com> Date: Mon, 19 Jan 2026 22:46:36 +0700 Subject: [PATCH 32/34] Potential fix for code scanning alert no. 22: Incomplete URL substring sanitization Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Signed-off-by: Dargon789 <64915515+Dargon789@users.noreply.github.com> --- .../turnstileSolver.integration.test.ts | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/packages/sessions/src/challenge-solvers/turnstileSolver.integration.test.ts b/packages/sessions/src/challenge-solvers/turnstileSolver.integration.test.ts index 6327cbb39a2..7b53ae30e37 100644 --- a/packages/sessions/src/challenge-solvers/turnstileSolver.integration.test.ts +++ b/packages/sessions/src/challenge-solvers/turnstileSolver.integration.test.ts @@ -253,12 +253,19 @@ describe('Turnstile Solver Integration Tests', () => { it('handles script loading failures', async () => { // Mock script loading failure vi.spyOn(document.head, 'appendChild').mockImplementationOnce((node) => { - if (node instanceof HTMLScriptElement && node.src.includes('challenges.cloudflare.com')) { - setTimeout(() => { - if (node.onerror) { - node.onerror({} as Event) + if (node instanceof HTMLScriptElement && node.src) { + try { + const url = new URL(node.src, window.location.origin) + if (url.hostname === 'challenges.cloudflare.com') { + setTimeout(() => { + if (node.onerror) { + node.onerror({} as Event) + } + }, 0) } - }, 0) + } catch { + // If the URL is invalid, do not treat it as the Turnstile script + } } return node }) From 6ad0d16e6ae6498541b20414a8d4a6de60b7dffc Mon Sep 17 00:00:00 2001 From: Dargon789 <64915515+Dargon789@users.noreply.github.com> Date: Tue, 20 Jan 2026 00:05:38 +0700 Subject: [PATCH 33/34] Potential fix for code scanning alert no. 9: Incomplete regular expression for hostnames (#100) Signed-off-by: Dargon789 <64915515+Dargon789@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- apps/web/cypress/support/commands.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/cypress/support/commands.ts b/apps/web/cypress/support/commands.ts index 476905a0daa..f35f18e9b4b 100644 --- a/apps/web/cypress/support/commands.ts +++ b/apps/web/cypress/support/commands.ts @@ -144,7 +144,7 @@ export function registerCommands() { Cypress.Commands.add('interceptGraphqlOperation', (operationName, fixturePath) => { const graphqlInterceptions = Cypress.env('graphqlInterceptions') - cy.intercept(/(?:interface|beta).gateway.uniswap.org\/v1\/graphql/, (req) => { + cy.intercept(/(?:interface|beta)\.gateway\.uniswap\.org\/v1\/graphql/, (req) => { req.headers['origin'] = 'https://app.uniswap.org' const currentOperationName = req.body.operationName From 91141c905840ade578131bca82306c4913b7901c Mon Sep 17 00:00:00 2001 From: Dargon789 <64915515+Dargon789@users.noreply.github.com> Date: Tue, 20 Jan 2026 08:50:02 +0700 Subject: [PATCH 34/34] Revert "Merge branch 'main' into uniswap/main" This reverts commit a6419ef0b34611a7abe10104e6b1201caeb1cae1, reversing changes made to cc6e79c10208d5d24ab78cc3b6683be8485a59c0. --- .circleci/docker.yml | 100 - .github/ISSUE_TEMPLATE/bug_report.md | 2 - .github/ISSUE_TEMPLATE/custom.md | 10 - .github/ISSUE_TEMPLATE/feature_request.md | 20 - .github/workflows/jekyll-gh-pages.yml | 51 - .github/workflows/static.yml | 43 - .github/workflows/tag_and_release.yml | 33 +- RELEASE | 64 +- SECURITY.md | 21 - VERSION | 2 +- apps/extension/package.json | 39 +- .../src/app/components/AutoLockProvider.tsx | 125 +- .../SignTypedDataRequestContent.tsx | 156 +- .../src/app/features/home/HomeScreen.tsx | 46 +- apps/extension/tsconfig.json | 3 - apps/mobile/Gemfile | 11 +- apps/mobile/Gemfile.lock | 146 +- apps/mobile/package.json | 131 +- apps/mobile/src/app/App.tsx | 25 + .../PriceExplorer/PriceExplorer.tsx | 7 +- .../src/components/PriceExplorer/Text.tsx | 69 +- .../__snapshots__/Text.test.tsx.snap | 609 ++- .../PriceExplorer/usePriceHistory.ts | 45 +- .../components/activity/ActivityContent.tsx | 62 +- .../introCards/OnboardingIntroCardStack.tsx | 89 +- .../src/features/deepLinking/configUtils.ts | 21 +- .../src/features/deepLinking/deepLinkUtils.ts | 1 - apps/mobile/src/screens/ActivityScreen.tsx | 41 +- .../src/screens/HomeScreen/HomeScreen.tsx | 58 +- apps/mobile/tsconfig.json | 3 - apps/web/cypress/support/commands.ts | 168 - apps/web/package.json | 63 +- apps/web/project.json | 27 +- apps/web/public/pools-sitemap.xml | 20 - apps/web/public/tokens-sitemap.xml | 90 - .../images/portfolio-page-promo/dark.svg | 778 ---- .../images/portfolio-page-promo/light.svg | 778 ---- apps/web/src/assets/svg/Emblem/A.svg | 4 - apps/web/src/assets/svg/Emblem/B.svg | 3 - apps/web/src/assets/svg/Emblem/C.svg | 18 - apps/web/src/assets/svg/Emblem/D.svg | 4 - apps/web/src/assets/svg/Emblem/E.svg | 3 - apps/web/src/assets/svg/Emblem/F.svg | 3 - apps/web/src/assets/svg/Emblem/G.svg | 3 - apps/web/src/assets/svg/Emblem/default.svg | 3 - .../ActivityTable/ActivityAddressCell.tsx | 27 - .../ActivityTable/ActivityAmountCell.tsx | 255 -- .../ActivityTable/ActivityTable.tsx | 123 - .../ActivityTable/AddressWithAvatar.tsx | 42 - .../src/components/ActivityTable/TimeCell.tsx | 15 - .../ActivityTable/TokenAmountDisplay.tsx | 32 - .../ActivityTable/TransactionTypeCell.tsx | 30 - .../ActivityTable/activityTableModels.ts | 61 - .../src/components/ActivityTable/registry.ts | 246 -- .../BridgingPopularTokensBanner.tsx | 108 - .../Banner/shared/OutageBanners.tsx | 30 +- .../Charts/LiquidityChart/index.tsx | 261 +- .../LiquidityPositionRangeChart.tsx | 9 +- .../LiquidityRangeInput.tsx | 3 +- .../components/Charts/PriceChart/index.tsx | 213 +- .../FeatureFlagModal/FeatureFlagModal.tsx | 156 +- .../Liquidity/Create/RangeSelectionStep.tsx | 4 +- .../src/components/Liquidity/Create/types.ts | 7 +- .../Liquidity/LiquidityPositionCard.tsx | 201 +- .../Liquidity/utils/priceRangeInfo.ts | 17 +- apps/web/src/components/Logo/DoubleLogo.tsx | 2 +- .../NavBar/CompanyMenu/MenuDropdown.tsx | 6 +- .../NavBar/CompanyMenu/MobileMenuDrawer.tsx | 7 +- .../NavBar/NavDropdown/NavDropdown.tsx | 5 +- .../src/components/NavBar/SearchBar/index.tsx | 14 +- .../components/NavBar/Tabs/TabsContent.tsx | 52 +- .../Pools/PoolDetails/PoolDetailsHeader.tsx | 95 +- apps/web/src/components/Table/index.tsx | 331 +- .../components/Tokens/TokenDetails/Delta.tsx | 8 +- .../__snapshots__/Delta.test.tsx.snap | 10 +- apps/web/src/index.tsx | 130 +- .../pages/App/WalletConnection.e2e.test.ts | 84 +- .../CreatePosition.anvil.e2e.test.ts | 222 +- .../CreatePosition/CreatePosition.e2e.test.ts | 787 ++-- .../CreatePosition/CreatePositionModal.tsx | 284 +- .../CreatePositionTxContext.tsx | 35 +- apps/web/src/pages/Errors.anvil.e2e.test.ts | 180 +- .../web/src/pages/Landing/Landing.e2e.test.ts | 138 +- .../pages/Migrate/hooks/useInitialPosition.ts | 3 +- .../src/pages/Portfolio/Activity/Activity.tsx | 235 +- .../pages/Portfolio/Activity/Filters/utils.ts | 75 +- .../pages/Portfolio/ConnectWalletBanner.tsx | 123 +- .../Portfolio/ConnectWalletBottomOverlay.tsx | 47 - .../web/src/pages/Portfolio/Header/Header.tsx | 73 +- .../ConnectedAddressDisplay.tsx | 45 +- .../DemoAddressDisplay.tsx | 8 +- .../PortfolioAddressDisplay.tsx | 8 +- .../Portfolio/Header/hooks/useIsConnected.ts | 8 +- apps/web/src/pages/Portfolio/NFTs/NFTCard.tsx | 271 +- apps/web/src/pages/Portfolio/NFTs/Nfts.tsx | 164 +- .../Portfolio/NFTs/utils/filterNfts.test.ts | 257 -- .../pages/Portfolio/NFTs/utils/filterNfts.ts | 32 - apps/web/src/pages/Portfolio/Portfolio.tsx | 59 +- .../src/pages/Portfolio/PortfolioContent.tsx | 21 +- .../Portfolio/PortfolioDisconnectedView.tsx | 221 +- .../Tokens/Table/TokensContextMenuWrapper.tsx | 57 +- .../Portfolio/Tokens/Table/TokensTable.tsx | 57 +- .../Tokens/Table/TokensTableInner.tsx | 212 +- .../Tokens/Table/columns/Balance.tsx | 28 +- .../web/src/pages/Portfolio/Tokens/Tokens.tsx | 117 +- .../hooks/useTransformTokenTableData.ts | 68 +- .../Portfolio/hooks/usePortfolioAddress.ts | 13 - apps/web/src/pages/Positions/index.tsx | 16 +- apps/web/src/pages/Swap/Buy/Buy.e2e.test.ts | 108 +- .../web/src/pages/Swap/Fees.anvil.e2e.test.ts | 71 +- apps/web/src/pages/Swap/Fees.e2e.test.ts | 29 - .../src/pages/Swap/Logging.anvil.e2e.test.ts | 140 +- .../web/src/pages/Swap/Swap.anvil.e2e.test.ts | 467 ++- .../web/src/playwright/anvil/anvil-manager.ts | 30 +- apps/web/src/playwright/fixtures/anvil.ts | 45 +- apps/web/src/playwright/fixtures/graphql.ts | 2 +- .../web/src/playwright/fixtures/tradingApi.ts | 14 +- .../state/sagas/liquidity/liquiditySaga.ts | 69 +- .../src/state/sagas/transactions/solana.ts | 23 +- .../src/state/sagas/transactions/swapSaga.ts | 66 +- .../src/state/sagas/transactions/uniswapx.ts | 49 +- .../web/src/state/sagas/transactions/utils.ts | 97 +- apps/web/src/state/swap/hooks.tsx | 4 +- apps/web/tsconfig.json | 3 - bun.lock | 3569 ++++++++++------- config/jest-presets/jest/jest-preset.js | 18 +- nx.json | 39 +- package.json | 78 +- packages/api/package.json | 23 +- .../api/src/clients/graphql/schema.graphql | 48 +- .../createNotificationsApiClient.ts | 35 +- .../api/src/clients/notifications/types.ts | 92 +- .../api/src/clients/trading/tradeTypes.ts | 78 +- packages/api/src/index.ts | 69 +- packages/gating/package.json | 4 +- packages/gating/src/index.ts | 5 +- packages/gating/src/sdk/statsig.ts | 32 +- packages/notifications/README.md | 26 +- packages/notifications/package.json | 21 +- packages/notifications/project.json | 14 +- packages/notifications/tsconfig.json | 15 +- .../graphics/bridged-assets-v2-web-banner.png | Bin 128449 -> 137674 bytes packages/ui/src/assets/index.ts | 9 +- packages/uniswap/package.json | 61 +- .../TokenSelector/TokenSelector.tsx | 1 - .../uniswap/src/components/nfts/NftsList.tsx | 24 + .../src/components/nfts/NftsList.web.tsx | 221 +- .../apiClients/tradingApi/TradingApiClient.ts | 57 +- .../src/features/behaviorHistory/slice.ts | 6 + .../src/features/chains/evm/info/monad.ts | 66 +- .../uniswap/src/features/dataApi/types.ts | 21 +- .../priceChart/priceChartConversion.test.ts | 1 - .../gating/StatsigProviderWrapper.tsx | 23 +- .../nfts/hooks/useNftContextMenuItems.tsx | 56 +- .../PortfolioBalance/PortfolioBalance.tsx | 22 +- .../SearchModal/hooks/useFilterCallbacks.ts | 4 +- .../telemetry/constants/trace/element.ts | 51 +- .../telemetry/constants/trace/section.ts | 7 +- .../features/telemetry/constants/uniswap.ts | 6 + .../src/features/transactions/steps/types.ts | 56 +- .../TradeRoutingPreferenceScreen.tsx | 28 +- .../hooks/receiptFetching/utils.ts | 2 + .../transactions/swap/plan/planSaga.ts | 235 +- .../swap/plan/planStepTransformer.ts | 46 +- .../features/transactions/swap/plan/utils.ts | 257 +- .../services/swapTxAndGasInfoService/hooks.ts | 22 +- .../src/i18n/locales/source/en-US.json | 287 +- .../src/i18n/locales/translations/es-ES.json | 360 +- .../src/i18n/locales/translations/fil-PH.json | 354 +- .../src/i18n/locales/translations/fr-FR.json | 354 +- .../src/i18n/locales/translations/id-ID.json | 364 +- .../src/i18n/locales/translations/ja-JP.json | 356 +- .../src/i18n/locales/translations/ko-KR.json | 354 +- .../src/i18n/locales/translations/nl-NL.json | 336 +- .../src/i18n/locales/translations/pt-PT.json | 356 +- .../src/i18n/locales/translations/ru-RU.json | 358 +- .../src/i18n/locales/translations/tr-TR.json | 358 +- .../src/i18n/locales/translations/vi-VN.json | 356 +- .../src/i18n/locales/translations/zh-CN.json | 356 +- .../src/i18n/locales/translations/zh-TW.json | 360 +- packages/uniswap/src/test/fixtures/testIDs.ts | 10 +- .../parseChainFromTokenSearchQuery.test.ts | 22 +- .../search/parseChainFromTokenSearchQuery.ts | 17 +- packages/utilities/package.json | 24 +- packages/wallet/package.json | 31 +- .../transactions/swap/executeSwapSaga.ts | 117 +- .../swap/hooks/useSwapHandlers.ts | 1 - scripts/clean.sh | 45 +- tsconfig.base.json | 16 +- tsconfig.json | 3 + 190 files changed, 11424 insertions(+), 10882 deletions(-) delete mode 100644 .circleci/docker.yml delete mode 100644 .github/ISSUE_TEMPLATE/custom.md delete mode 100644 .github/ISSUE_TEMPLATE/feature_request.md delete mode 100644 .github/workflows/jekyll-gh-pages.yml delete mode 100644 .github/workflows/static.yml delete mode 100644 SECURITY.md delete mode 100644 apps/web/cypress/support/commands.ts delete mode 100644 apps/web/src/assets/images/portfolio-page-promo/dark.svg delete mode 100644 apps/web/src/assets/images/portfolio-page-promo/light.svg delete mode 100644 apps/web/src/assets/svg/Emblem/A.svg delete mode 100644 apps/web/src/assets/svg/Emblem/B.svg delete mode 100644 apps/web/src/assets/svg/Emblem/C.svg delete mode 100644 apps/web/src/assets/svg/Emblem/D.svg delete mode 100644 apps/web/src/assets/svg/Emblem/E.svg delete mode 100644 apps/web/src/assets/svg/Emblem/F.svg delete mode 100644 apps/web/src/assets/svg/Emblem/G.svg delete mode 100644 apps/web/src/assets/svg/Emblem/default.svg delete mode 100644 apps/web/src/components/ActivityTable/ActivityAddressCell.tsx delete mode 100644 apps/web/src/components/ActivityTable/ActivityAmountCell.tsx delete mode 100644 apps/web/src/components/ActivityTable/ActivityTable.tsx delete mode 100644 apps/web/src/components/ActivityTable/AddressWithAvatar.tsx delete mode 100644 apps/web/src/components/ActivityTable/TimeCell.tsx delete mode 100644 apps/web/src/components/ActivityTable/TokenAmountDisplay.tsx delete mode 100644 apps/web/src/components/ActivityTable/TransactionTypeCell.tsx delete mode 100644 apps/web/src/components/ActivityTable/activityTableModels.ts delete mode 100644 apps/web/src/components/ActivityTable/registry.ts delete mode 100644 apps/web/src/components/Banner/BridgingPopularTokens/BridgingPopularTokensBanner.tsx delete mode 100644 apps/web/src/pages/Portfolio/ConnectWalletBottomOverlay.tsx delete mode 100644 apps/web/src/pages/Portfolio/NFTs/utils/filterNfts.test.ts delete mode 100644 apps/web/src/pages/Portfolio/NFTs/utils/filterNfts.ts delete mode 100644 apps/web/src/pages/Portfolio/hooks/usePortfolioAddress.ts diff --git a/.circleci/docker.yml b/.circleci/docker.yml deleted file mode 100644 index e994f94e708..00000000000 --- a/.circleci/docker.yml +++ /dev/null @@ -1,100 +0,0 @@ -name: Docker - -# This workflow uses actions that are not certified by GitHub. -# They are provided by a third-party and are governed by -# separate terms of service, privacy policy, and support -# documentation. - -on: - schedule: - - cron: '21 12 * * *' - push: - branches: [ "master" ] - # Publish semver tags as releases. - tags: [ 'v*.*.*' ] - pull_request: - branches: [ "master" ] - -env: - # Use docker.io for Docker Hub if empty - REGISTRY: ghcr.io - # github.repository as / - IMAGE_NAME: ${{ github.repository }} - - -jobs: - build: - - name: Build the Docker image - run: docker build . --file path/to/Dockerfile --tag my-image-name:$(date +%s) - - runs-on: ubuntu-latest - permissions: - contents: read - packages: write - # This is used to complete the identity challenge - # with sigstore/fulcio when running outside of PRs. - id-token: write - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - # Install the cosign tool except on PR - # https://github.com/sigstore/cosign-installer - - name: Install cosign - if: github.event_name != 'pull_request' - uses: sigstore/cosign-installer@59acb6260d9c0ba8f4a2f9d9b48431a222b68e20 #v3.5.0 - with: - cosign-release: 'v2.2.4' - - # Set up BuildKit Docker container builder to be able to build - # multi-platform images and export cache - # https://github.com/docker/setup-buildx-action - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0 - - # Login against a Docker registry except on PR - # https://github.com/docker/login-action - - name: Log into registry ${{ env.REGISTRY }} - if: github.event_name != 'pull_request' - uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d # v3.0.0 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - # Extract metadata (tags, labels) for Docker - # https://github.com/docker/metadata-action - - name: Extract Docker metadata - id: meta - uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0 - with: - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - - # Build and push Docker image with Buildx (don't push on PR) - # https://github.com/docker/build-push-action - - name: Build and push Docker image - id: build-and-push - uses: docker/build-push-action@v5.0.0 - with: - context: ./ - push: ${{ github.event_name != 'pull_request' }} - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max - - # Sign the resulting Docker image digest except on PRs. - # This will only write to the public Rekor transparency log when the Docker - # repository is public to avoid leaking data. If you would like to publish - # transparency data even for private images, pass --force to cosign below. - # https://github.com/sigstore/cosign - - name: Sign the published Docker image - if: ${{ github.event_name != 'pull_request' }} - env: - # https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-an-intermediate-environment-variable - TAGS: ${{ steps.meta.outputs.tags }} - DIGEST: ${{ steps.build-and-push.outputs.digest }} - # This step uses the identity token to provision an ephemeral certificate - # against the sigstore community Fulcio instance. - run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST} diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index feac4b614be..b01ad10c152 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -3,8 +3,6 @@ name: Bug Report about: Report a bug or unexpected behavior in the Uniswap interfaces. title: "[Bug] " labels: bug -assignees: '' - --- ## 📱 Interface Affected diff --git a/.github/ISSUE_TEMPLATE/custom.md b/.github/ISSUE_TEMPLATE/custom.md deleted file mode 100644 index 48d5f81fa42..00000000000 --- a/.github/ISSUE_TEMPLATE/custom.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -name: Custom issue template -about: Describe this issue template's purpose here. -title: '' -labels: '' -assignees: '' - ---- - - diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md deleted file mode 100644 index 36014cde565..00000000000 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -name: Feature request -about: Suggest an idea for this project -title: '' -labels: 'enhancement' -assignees: '' - ---- - -**Is your feature request related to a problem? Please describe.** -A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] - -**Describe the solution you'd like** -A clear and concise description of what you want to happen. - -**Describe alternatives you've considered** -A clear and concise description of any alternative solutions or features you've considered. - -**Additional context** -Add any other context or screenshots about the feature request here. diff --git a/.github/workflows/jekyll-gh-pages.yml b/.github/workflows/jekyll-gh-pages.yml deleted file mode 100644 index e31d81c5864..00000000000 --- a/.github/workflows/jekyll-gh-pages.yml +++ /dev/null @@ -1,51 +0,0 @@ -# Sample workflow for building and deploying a Jekyll site to GitHub Pages -name: Deploy Jekyll with GitHub Pages dependencies preinstalled - -on: - # Runs on pushes targeting the default branch - push: - branches: ["main"] - - # Allows you to run this workflow manually from the Actions tab - workflow_dispatch: - -# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages -permissions: - contents: read - pages: write - id-token: write - -# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. -# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. -concurrency: - group: "pages" - cancel-in-progress: false - -jobs: - # Build job - build: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Setup Pages - uses: actions/configure-pages@v5 - - name: Build with Jekyll - uses: actions/jekyll-build-pages@v1 - with: - source: ./ - destination: ./_site - - name: Upload artifact - uses: actions/upload-pages-artifact@v3 - - # Deployment job - deploy: - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - runs-on: ubuntu-latest - needs: build - steps: - - name: Deploy to GitHub Pages - id: deployment - uses: actions/deploy-pages@v4 diff --git a/.github/workflows/static.yml b/.github/workflows/static.yml deleted file mode 100644 index f2c9e97c91d..00000000000 --- a/.github/workflows/static.yml +++ /dev/null @@ -1,43 +0,0 @@ -# Simple workflow for deploying static content to GitHub Pages -name: Deploy static content to Pages - -on: - # Runs on pushes targeting the default branch - push: - branches: ["main"] - - # Allows you to run this workflow manually from the Actions tab - workflow_dispatch: - -# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages -permissions: - contents: read - pages: write - id-token: write - -# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. -# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. -concurrency: - group: "pages" - cancel-in-progress: false - -jobs: - # Single deploy job since we're just deploying - deploy: - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Setup Pages - uses: actions/configure-pages@v5 - - name: Upload artifact - uses: actions/upload-pages-artifact@v3 - with: - # Upload entire repository - path: '.' - - name: Deploy to GitHub Pages - id: deployment - uses: actions/deploy-pages@v4 diff --git a/.github/workflows/tag_and_release.yml b/.github/workflows/tag_and_release.yml index a6a805c3878..3bfcc2933d0 100644 --- a/.github/workflows/tag_and_release.yml +++ b/.github/workflows/tag_and_release.yml @@ -3,10 +3,7 @@ on: push: branches: - 'main' -permissions: - contents: write - issues: write - pull-requests: write + jobs: deploy-to-prod: runs-on: ubuntu-latest @@ -47,27 +44,11 @@ jobs: custom_tag: ${{ steps.version.outputs.content }} tag_prefix: "" - - name: 🪽 Create release - - on: - push: - tags: - - v* - - permissions: - contents: write - - jobs: - release: - name: Release pushed tag - runs-on: ubuntu-24.04 - steps: - - name: Create release + - name: 🪽 Release + uses: actions/create-release@c9ba6969f07ed90fae07e2e66100dd03f9b1a50e env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - tag: ${{ github.ref_name }} - run: | - gh release create "$tag" \ - --repo="$GITHUB_REPOSITORY" \ - --title="${GITHUB_REPOSITORY#*/} ${tag#v}" \ - --generate-notes + with: + tag_name: ${{ steps.github-tag-action.outputs.new_tag }} + release_name: Release ${{ steps.github-tag-action.outputs.new_tag }} + body: ${{ steps.release-notes.outputs.content }} diff --git a/RELEASE b/RELEASE index a302b26e29b..af0456c226c 100644 --- a/RELEASE +++ b/RELEASE @@ -1,63 +1 @@ -IPFS hash of the deployment: -- CIDv0: `QmUF5sYAgjigJ6D7nE6p12SG1rFrwYLidKxeX52qBN1Pce` -- CIDv1: `bafybeicxxeljdmh6f2s3xo43piks2h7ibgm6hbfoo3ftc5wjfmqfsngghm` - -The latest release is always mirrored at [app.uniswap.org](https://app.uniswap.org). - -You can also access the Uniswap Interface from an IPFS gateway. -**BEWARE**: The Uniswap interface uses [`localStorage`](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage) to remember your settings, such as which tokens you have imported. -**You should always use an IPFS gateway that enforces origin separation**, or our hosted deployment of the latest release at [app.uniswap.org](https://app.uniswap.org). -Your Uniswap settings are never remembered across different URLs. - -IPFS gateways: -- https://bafybeicxxeljdmh6f2s3xo43piks2h7ibgm6hbfoo3ftc5wjfmqfsngghm.ipfs.dweb.link/ -- [ipfs://QmUF5sYAgjigJ6D7nE6p12SG1rFrwYLidKxeX52qBN1Pce/](ipfs://QmUF5sYAgjigJ6D7nE6p12SG1rFrwYLidKxeX52qBN1Pce/) - -## 5.116.0 (2025-10-28) - - -### Features - -* **web:** add activity table to the tab with real data (#23506) f00228c -* **web:** Add createRejectableMockConnector util to force tx rejection (#24574) 3b3b2b7 -* **web:** add demo account support for activity tab (#24639) 9ec0194 -* **web:** add disconnected portfolio view (#23690) 7a1b085 -* **web:** add fiat to price chart (#23577) fab99ce -* **web:** add hidden tokens table rows (#23535) 291fab3 -* **web:** add loading state to tokens table (#23544) ed5ced8 -* **web:** add more & better filtering + transaction parsing (#24579) 205c03d -* **web:** add v2 bridged asset banner (#24734) 4666868 -* **web:** disconnected view B version (#24630) 46ca828 -* **web:** Help Modal styling nits (#24547) ae252e6 -* **web:** NFTs tab (#23604) a438b54 -* **web:** small style nits for Company menu (#24318) 4d71e08 -* **web:** special case metamask dual vm connection flow (#24756) faabc72 -* **web:** tokens table search (#23509) b83fc75 -* **web:** update CompanyMenu arrangement on tablet width (#24312) 758f68d - - -### Bug Fixes - -* **web:** default to mainnet for limits flow [STAGING] (#24885) 5a8e150 -* **web:** Fix CreatePosition e2e anvil test (#24573) d68b011 -* **web:** Fix e2e anvil tests missing quote stub (#24590) 838d5bd -* **web:** Fix limit order chain switch bug (#23064) b11176d -* **web:** Fix Swap e2e anvil tests (#24662) 26adf5c -* **web:** fixes pools tab loader skeletons (#24472) 2f887aa -* **web:** Increase anvil manager timeout (#24623) 466eb69 -* **web:** log interface swap finalization results for flashblocks (#24869) bf30270 -* **web:** support chain filtering query params (#24754) 4bc3729 -* **web:** update the create flow to display the latest dependnet amount (#24676) 168c20a -* **web:** Use Mainnet instead of Base for e2e test commands (#24589) ff7dfee - - -### Continuous Integration - -* **web:** update sitemaps 4e8124b - - -### Tests - -* **web:** Disable anvil snapshots by default (#24666) 1a2903c - - +Various bug fixes and performance improvements diff --git a/SECURITY.md b/SECURITY.md deleted file mode 100644 index 034e8480320..00000000000 --- a/SECURITY.md +++ /dev/null @@ -1,21 +0,0 @@ -# Security Policy - -## Supported Versions - -Use this section to tell people about which versions of your project are -currently being supported with security updates. - -| Version | Supported | -| ------- | ------------------ | -| 5.1.x | :white_check_mark: | -| 5.0.x | :x: | -| 4.0.x | :white_check_mark: | -| < 4.0 | :x: | - -## Reporting a Vulnerability - -Use this section to tell people how to report a vulnerability. - -Tell them where to go, how often they can expect to get an update on a -reported vulnerability, what to expect if the vulnerability is accepted or -declined, etc. diff --git a/VERSION b/VERSION index a4fafce7ea0..d82ad472983 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -web/5.116.0 \ No newline at end of file +mobile/1.64.1 \ No newline at end of file diff --git a/apps/extension/package.json b/apps/extension/package.json index bfd7f0ff2fd..afe0d102858 100644 --- a/apps/extension/package.json +++ b/apps/extension/package.json @@ -3,7 +3,7 @@ "version": "0.0.0", "browserslist": "last 2 chrome versions", "dependencies": { - "@apollo/client": "3.10.4", + "@apollo/client": "3.11.10", "@datadog/browser-rum": "5.23.3", "@ethersproject/bignumber": "5.7.0", "@ethersproject/providers": "5.7.2", @@ -12,17 +12,18 @@ "@metamask/rpc-errors": "6.2.1", "@reduxjs/toolkit": "1.9.3", "@svgr/webpack": "8.0.1", - "@tamagui/core": "1.125.17", + "@tamagui/core": "1.136.1", "@tanstack/react-query": "5.77.2", "@types/uuid": "9.0.1", "@uniswap/analytics-events": "2.43.0", "@uniswap/client-embeddedwallet": "0.0.16", - "@uniswap/uniswapx-sdk": "3.0.0-beta.7", + "@uniswap/sdk-core": "7.9.0", "@uniswap/universal-router-sdk": "4.19.5", "@uniswap/v3-sdk": "3.25.2", "@uniswap/v4-sdk": "1.21.2", "@universe/api": "workspace:^", "@universe/gating": "workspace:^", + "@universe/sessions": "workspace:^", "@wxt-dev/module-react": "1.1.3", "confusing-browser-globals": "1.0.11", "dotenv-webpack": "8.0.1", @@ -31,17 +32,17 @@ "eventemitter3": "5.0.1", "i18next": "23.10.0", "node-polyfill-webpack-plugin": "2.0.1", - "react": "18.3.1", - "react-dom": "18.3.1", + "react": "19.0.0", + "react-dom": "19.0.0", "react-i18next": "14.1.0", - "react-native": "0.77.2", - "react-native-gesture-handler": "2.22.1", - "react-native-reanimated": "3.16.7", - "react-native-svg": "15.11.2", + "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.12.0", + "react-router": "7.6.3", "redux": "4.2.1", "redux-logger": "3.0.6", "redux-persist": "6.0.0", @@ -67,24 +68,24 @@ "@playwright/test": "1.49.1", "@pmmmwh/react-refresh-webpack-plugin": "0.5.11", "@testing-library/dom": "10.4.0", - "@testing-library/react": "16.1.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": "18.3.18", - "@types/react-dom": "18.3.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", "@uniswap/eslint-config": "workspace:^", - "@welldone-software/why-did-you-render": "8.0.1", + "@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": "8.44.0", + "eslint": "8.57.1", "jest": "29.7.0", "jest-chrome": "0.8.0", "jest-environment-jsdom": "29.5.0", @@ -95,11 +96,11 @@ "serve": "14.2.4", "style-loader": "3.3.2", "swc-loader": "0.2.6", - "tamagui-loader": "1.125.17", - "typescript": "5.3.3", - "webpack": "5.94.0", + "tamagui-loader": "1.136.1", + "typescript": "5.8.3", + "webpack": "5.90.0", "webpack-cli": "5.1.4", - "webpack-dev-server": "5.2.1" + "webpack-dev-server": "4.15.1" }, "private": true, "scripts": { diff --git a/apps/extension/src/app/components/AutoLockProvider.tsx b/apps/extension/src/app/components/AutoLockProvider.tsx index 8f26d940352..68c18d819e6 100644 --- a/apps/extension/src/app/components/AutoLockProvider.tsx +++ b/apps/extension/src/app/components/AutoLockProvider.tsx @@ -1,73 +1,98 @@ -import { FeatureFlags, useFeatureFlag } from '@universe/gating' -import { PropsWithChildren, useEffect } from 'react' +import { PropsWithChildren, useEffect, useRef } from 'react' import { useSelector } from 'react-redux' -import { ExtensionState } from 'src/store/extensionReducer' -import { useIsChromeWindowFocusedWithTimeout } from 'uniswap/src/extension/useIsChromeWindowFocused' -import { deviceAccessTimeoutToMs } from 'uniswap/src/features/settings/constants' -import { ExtensionEventName } from 'uniswap/src/features/telemetry/constants' -import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +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' -import { Keyring } from 'wallet/src/features/wallet/Keyring/Keyring' -const AUTO_LOCK_ALARM_NAME = 'AutoLockAlarm' +export const AUTO_LOCK_ALARM_NAME = 'AutoLockAlarm' /** - * AutoLockProvider monitors window focus and automatically locks the wallet - * after the configured inactivity timeout period. + * 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. * - * This component should be placed high in the component tree to ensure - * it's always active when the extension is running. + * 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 deviceAccessTimeout = useSelector((state: ExtensionState) => state.userSettings.deviceAccessTimeout) - const useAlarmsApi = useFeatureFlag(FeatureFlags.UseAlarmsApi) - const timeoutMs = deviceAccessTimeoutToMs(deviceAccessTimeout) + const delayInMinutes = useSelector(selectDeviceAccessTimeoutMinutes) + const isWalletUnlocked = useIsWalletUnlocked() + const isChromeWindowFocused = useIsChromeWindowFocused() - // Use the window focus hook with the configured timeout - // If timeoutMs is undefined (Never setting), use a very large number to effectively disable - const isChromeWindowFocused = useIsChromeWindowFocusedWithTimeout(timeoutMs ?? Number.MAX_SAFE_INTEGER) + // Ref to track previous focus state + const prevFocusedRef = useRef(true) + // Ref to track previous unlock state + const prevUnlockedRef = useRef(null) - // Maintain chrome.alarms usage behind feature flag + // On mount: Clear any existing alarm (sidebar just opened) useEffect(() => { - if (useAlarmsApi) { - chrome.alarms.create(AUTO_LOCK_ALARM_NAME, { - delayInMinutes: 1000, - }) + 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 } - return () => { - chrome.alarms.clear(AUTO_LOCK_ALARM_NAME) + const prevFocused = prevFocusedRef.current + const prevUnlocked = prevUnlockedRef.current + prevFocusedRef.current = isChromeWindowFocused + prevUnlockedRef.current = isWalletUnlocked + + // Skip first render for unlock state + if (prevUnlocked === null) { + return } - }, [useAlarmsApi]) - useEffect(() => { - // Only lock if timeout is configured (not "Never") - if (timeoutMs === undefined) { + // Clear alarm when wallet state changes (locked or unlocked) + if (prevUnlocked !== isWalletUnlocked) { + clearAutoLockAlarm(`Cleared auto-lock alarm (wallet ${isWalletUnlocked ? 'unlocked' : 'locked'})`) return } - if (!isChromeWindowFocused) { - const lockWallet = async (): Promise => { - try { - logger.debug('AutoLockProvider', 'lockWallet', 'Locking wallet due to inactivity') - await Keyring.lock() - sendAnalyticsEvent(ExtensionEventName.ChangeLockedState, { - locked: true, - location: 'background', - }) - } catch (error) { - logger.error(error, { - tags: { - file: 'AutoLockProvider.tsx', - function: 'lockWallet', - }, - }) - } - } + // When window loses focus AND wallet is unlocked: schedule alarm + if (prevFocused && !isChromeWindowFocused && isWalletUnlocked) { + createAutoLockAlarm(delayInMinutes) + return + } - lockWallet() + // When window regains focus: clear alarm + if (!prevFocused && isChromeWindowFocused) { + clearAutoLockAlarm('Cleared auto-lock alarm (window focused)') } - }, [isChromeWindowFocused, timeoutMs]) + }, [isChromeWindowFocused, isWalletUnlocked, delayInMinutes]) return <>{children} } diff --git a/apps/extension/src/app/features/dappRequests/requestContent/SignTypeData/SignTypedDataRequestContent.tsx b/apps/extension/src/app/features/dappRequests/requestContent/SignTypeData/SignTypedDataRequestContent.tsx index b015627b61b..8954eefedf2 100644 --- a/apps/extension/src/app/features/dappRequests/requestContent/SignTypeData/SignTypedDataRequestContent.tsx +++ b/apps/extension/src/app/features/dappRequests/requestContent/SignTypeData/SignTypedDataRequestContent.tsx @@ -1,28 +1,33 @@ 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 { DomainContent } from 'src/app/features/dappRequests/requestContent/SignTypeData/DomainContent' -import { MaybeExplorerLinkedAddress } from 'src/app/features/dappRequests/requestContent/SignTypeData/MaybeExplorerLinkedAddress' import { NonStandardTypedDataRequestContent } from 'src/app/features/dappRequests/requestContent/SignTypeData/NonStandardTypedDataRequestContent' -import { Permit2RequestContent } from 'src/app/features/dappRequests/requestContent/SignTypeData/Permit2/Permit2RequestContent' import { SignTypedDataRequest } from 'src/app/features/dappRequests/types/DappRequestTypes' -import { EIP712Message, isEIP712TypedData } from 'src/app/features/dappRequests/types/EIP712Types' -import { isPermit2, isUniswapXSwapRequest } from 'src/app/features/dappRequests/types/Permit2Types' -import { Flex, Text } from 'ui/src' +import { Flex } from 'ui/src' import { toSupportedChainId } from 'uniswap/src/features/chains/utils' import { useHasAccountMismatchCallback } from 'uniswap/src/features/smartWallet/mismatch/hooks' -import { ExplorerDataType, getExplorerLink } from 'uniswap/src/utils/linking' -import { isEVMAddressWithChecksum } from 'utilities/src/addresses/evm/evm' 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 { + const blockaidTransactionScanning = useFeatureFlag(FeatureFlags.BlockaidTransactionScanning) + return ( } @@ -38,12 +43,76 @@ export function SignTypedDataRequestContent({ dappRequest }: SignTypedDataReques } }} > - + {blockaidTransactionScanning ? ( + + ) : ( + + )} ) } -function SignTypedDataRequestContentInner({ dappRequest }: SignTypedDataRequestProps): JSX.Element | null { +/** + * Implementation with Blockaid scanning + */ +function SignTypedDataRequestContentWithScanning({ 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 + 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 ( + + + + ) +} + +/** + * Legacy implementation (existing behavior when feature flag is off) + */ +function SignTypedDataRequestContentLegacy({ dappRequest }: SignTypedDataRequestProps): JSX.Element | null { const { t } = useTranslation() const enablePermitMismatchUx = useFeatureFlag(FeatureFlags.EnablePermitMismatchUX) const getHasMismatch = useHasAccountMismatchCallback() @@ -54,7 +123,7 @@ function SignTypedDataRequestContentInner({ dappRequest }: SignTypedDataRequestP return } - const { name, version, chainId: domainChainId, verifyingContract, salt } = parsedTypedData.domain || {} + const { chainId: domainChainId } = parsedTypedData.domain || {} const chainId = toSupportedChainId(domainChainId) const hasMismatch = chainId ? getHasMismatch(chainId) : false @@ -66,59 +135,13 @@ function SignTypedDataRequestContentInner({ dappRequest }: SignTypedDataRequestP return } - if (isPermit2(parsedTypedData)) { - return - } - - // todo(EXT-883): remove this when we start rejecting unsupported chain signTypedData requests - const renderMessageContent = ( - message: EIP712Message | EIP712Message[keyof EIP712Message], - i = 1, - ): Maybe => { - if (message === null || message === undefined) { - return ( - - {String(message)} - - ) - } - if (typeof message === 'string' && isEVMAddressWithChecksum(message) && chainId) { - const href = getExplorerLink({ chainId, data: message, type: ExplorerDataType.ADDRESS }) - return - } - if (typeof message === 'string' || typeof message === 'number' || typeof message === 'boolean') { - return ( - - {message.toString()} - - ) - } else if (Array.isArray(message)) { - return ( - - {JSON.stringify(message)} - - ) - } else if (typeof message === 'object') { - return Object.entries(message).map(([key, value], index) => ( - - - {key} - - - {renderMessageContent(value, i + 1)} - - - )) - } - - return undefined - } + const isPermit2Request = isPermit2(parsedTypedData) return ( - - {renderMessageContent(parsedTypedData.message)} + {isPermit2Request ? ( + + ) : ( + + )} ) diff --git a/apps/extension/src/app/features/home/HomeScreen.tsx b/apps/extension/src/app/features/home/HomeScreen.tsx index 00145f31da7..d1a7e928009 100644 --- a/apps/extension/src/app/features/home/HomeScreen.tsx +++ b/apps/extension/src/app/features/home/HomeScreen.tsx @@ -21,7 +21,12 @@ import { HomeQueryParams, HomeTabs } from 'src/app/navigation/constants' import { navigate } from 'src/app/navigation/state' 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' @@ -72,6 +77,25 @@ export const HomeScreen = memo(function _HomeScreen(): JSX.Element { const [isSmartWalletEnabledModalOpen, setIsSmartWalletEnabledModalOpen] = useState(false) const dispatch = useDispatch() + // UniswapWrapped2025 banner state + const isWrappedBannerEnabled = useFeatureFlag(FeatureFlags.UniswapWrapped2025) + const hasDismissedWrappedBanner = useSelector(selectHasDismissedUniswapWrapped2025Banner) + const shouldShowWrappedBanner = isWrappedBannerEnabled && !hasDismissedWrappedBanner + + 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, { @@ -169,12 +193,32 @@ export const HomeScreen = memo(function _HomeScreen(): JSX.Element { )} + {shouldShowWrappedBanner && ( + + + + + )} - + diff --git a/apps/extension/tsconfig.json b/apps/extension/tsconfig.json index fe4165ad91e..72cc9db7534 100644 --- a/apps/extension/tsconfig.json +++ b/apps/extension/tsconfig.json @@ -25,9 +25,6 @@ }, { "path": "../../packages/api" - }, - { - "path": "../../packages/gating" } ], "compilerOptions": { diff --git a/apps/mobile/Gemfile b/apps/mobile/Gemfile index 9ca02152c75..a914ea80d6f 100644 --- a/apps/mobile/Gemfile +++ b/apps/mobile/Gemfile @@ -1,11 +1,16 @@ source "https://rubygems.org" -gem 'fastlane', '2.215.1' -# Exclude problematic versions of cocoapods and activesupport that causes build failures. -gem 'cocoapods', '1.15.0' +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 has removed some libraries from the standard library. +gem 'bigdecimal' +gem 'logger' +gem 'benchmark' +gem 'mutex_m' + 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 index 0de72b95649..6e79e1283b8 100644 --- a/apps/mobile/Gemfile.lock +++ b/apps/mobile/Gemfile.lock @@ -1,7 +1,8 @@ GEM remote: https://rubygems.org/ specs: - CFPropertyList (3.0.9) + CFPropertyList (3.0.6) + rexml activesupport (7.1.2) base64 bigdecimal @@ -12,16 +13,16 @@ GEM minitest (>= 5.1) mutex_m tzinfo (~> 2.0) - addressable (2.8.8) - public_suffix (>= 2.0.2, < 8.0) + addressable (2.8.6) + public_suffix (>= 2.0.2, < 6.0) 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.1198.0) - aws-sdk-core (3.240.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) @@ -29,23 +30,24 @@ GEM bigdecimal jmespath (~> 1, >= 1.6.1) logger - aws-sdk-kms (1.118.0) - aws-sdk-core (~> 3, >= 3.239.1) + aws-sdk-kms (1.112.0) + aws-sdk-core (~> 3, >= 3.231.0) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.209.0) - aws-sdk-core (~> 3, >= 3.234.0) + 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) - base64 (0.3.0) - bigdecimal (4.0.1) + base64 (0.2.0) + benchmark (0.4.1) + bigdecimal (3.1.9) claide (1.1.0) - cocoapods (1.15.0) + cocoapods (1.16.2) addressable (~> 2.8) claide (>= 1.0.2, < 2.0) - cocoapods-core (= 1.15.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) @@ -59,8 +61,8 @@ GEM molinillo (~> 0.8.0) nap (~> 1.0) ruby-macho (>= 2.3.0, < 3.0) - xcodeproj (>= 1.23.0, < 2.0) - cocoapods-core (1.15.0) + xcodeproj (>= 1.27.0, < 2.0) + cocoapods-core (1.16.2) activesupport (>= 5.0, < 8) addressable (~> 2.8) algoliasearch (~> 1.0) @@ -84,13 +86,13 @@ GEM commander (4.6.0) highline (~> 2.0.0) concurrent-ruby (1.3.4) - connection_pool (2.5.4) + connection_pool (2.5.0) 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) - drb (2.2.3) + drb (2.2.1) emoji_regex (3.2.3) escape (0.0.4) ethon (0.15.0) @@ -108,9 +110,9 @@ GEM faraday-rack (~> 1.0) faraday-retry (~> 1.0) ruby2_keywords (>= 0.0.4) - faraday-cookie_jar (0.0.8) + faraday-cookie_jar (0.0.7) faraday (>= 0.8.0) - http-cookie (>= 1.0.0) + http-cookie (~> 1.0.0) faraday-em_http (1.0.0) faraday-em_synchrony (1.0.1) faraday-excon (1.1.0) @@ -125,14 +127,14 @@ GEM faraday_middleware (1.2.1) faraday (~> 1.0) fastimage (2.4.0) - fastlane (2.215.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 + colored (~> 1.2) commander (~> 4.6) dotenv (>= 2.1.1, < 3.0.0) emoji_regex (>= 0.1, < 4.0) @@ -141,9 +143,11 @@ GEM 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) @@ -152,10 +156,10 @@ GEM mini_magick (>= 4.9.4, < 5.0.0) multipart-post (>= 2.0.0, < 3.0.0) naturally (~> 2.2) - optparse (~> 0.1.1) + optparse (>= 0.1.1, < 1.0.0) plist (>= 3.1.0, < 4.0.0) rubyzip (>= 2.0.0, < 3.0.0) - security (= 0.1.3) + security (= 0.1.5) simctl (~> 1.6.3) terminal-notifier (>= 2.0.0, < 3.0.0) terminal-table (~> 3) @@ -163,92 +167,90 @@ GEM tty-spinner (>= 0.8.0, < 1.0.0) word_wrap (~> 1.0.0) xcodeproj (>= 1.13.0, < 2.0.0) - xcpretty (~> 0.3.0) - xcpretty-travis-formatter (>= 0.0.3) - ffi (1.17.2) + xcpretty (~> 0.4.1) + xcpretty-travis-formatter (>= 0.0.3, < 2.0.0) + 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) fourflusher (2.3.1) fuzzy_match (2.0.4) gh_inspector (1.1.3) - google-apis-androidpublisher_v3 (0.93.0) - google-apis-core (>= 0.15.0, < 2.a) - google-apis-core (0.18.0) + 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 (~> 1.9) - httpclient (>= 2.8.3, < 3.a) + googleauth (>= 0.16.2, < 2.a) + httpclient (>= 2.8.1, < 3.a) mini_mime (~> 1.0) - mutex_m representable (~> 3.0) retriable (>= 2.0, < 4.a) - google-apis-iamcredentials_v1 (0.26.0) - google-apis-core (>= 0.15.0, < 2.a) - google-apis-playcustomapp_v1 (0.17.0) - google-apis-core (>= 0.15.0, < 2.a) - google-apis-storage_v1 (0.58.0) - google-apis-core (>= 0.15.0, < 2.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 (2.3.1) - base64 (~> 0.2) - faraday (>= 1.0, < 3.a) + google-cloud-env (1.6.0) + faraday (>= 0.17.3, < 3.0) google-cloud-errors (1.5.0) - google-cloud-storage (1.57.1) + google-cloud-storage (1.47.0) addressable (~> 2.8) digest-crc (~> 0.4) - google-apis-core (>= 0.18, < 2) - google-apis-iamcredentials_v1 (~> 0.18) - google-apis-storage_v1 (>= 0.42) + google-apis-iamcredentials_v1 (~> 0.1) + google-apis-storage_v1 (~> 0.31.0) google-cloud-core (~> 1.6) - googleauth (~> 1.9) + googleauth (>= 0.16.2, < 2.a) mini_mime (~> 1.0) - google-logging-utils (0.2.0) - googleauth (1.16.0) - faraday (>= 1.0, < 3.a) - google-cloud-env (~> 2.2) - google-logging-utils (~> 0.1) - jwt (>= 1.4, < 4.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) - httpclient (2.9.0) - mutex_m + httpclient (2.8.3) i18n (1.14.7) concurrent-ruby (~> 1.0) jmespath (1.6.2) - json (2.18.0) + json (2.7.1) jwt (2.10.2) base64 logger (1.7.0) mini_magick (4.13.2) mini_mime (1.1.5) - minitest (5.26.0) + minitest (5.25.4) molinillo (0.8.0) - multi_json (1.18.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) - optparse (0.1.1) + optparse (0.6.0) os (1.1.4) plist (3.7.2) public_suffix (4.0.7) - rake (13.3.1) + 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) - rexml (3.4.4) - rouge (2.0.7) + rexml (3.4.1) + rouge (3.28.0) ruby-macho (2.5.1) ruby2_keywords (0.0.5) rubyzip (2.4.1) - security (0.1.3) + security (0.1.5) signet (0.21.0) addressable (~> 2.8) faraday (>= 0.17.5, < 3.a) @@ -257,6 +259,7 @@ GEM 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) @@ -279,20 +282,27 @@ GEM colored2 (~> 3.1) nanaimo (~> 0.4.0) rexml (>= 3.3.6, < 4.0) - xcpretty (0.3.0) - rouge (~> 2.0.7) + xcpretty (0.4.1) + rouge (~> 3.28.0) xcpretty-travis-formatter (1.0.1) xcpretty (~> 0.2, >= 0.0.7) PLATFORMS - ruby + arm64-darwin-23 + arm64-darwin-24 DEPENDENCIES activesupport (= 7.1.2) - cocoapods (= 1.15.0) + benchmark + bigdecimal + cocoapods (= 1.16.2) concurrent-ruby (= 1.3.4) - fastlane (= 2.215.1) + fastlane (= 2.228.0) + fastlane-plugin-get_version_name + fastlane-plugin-versioning_android + logger + mutex_m xcodeproj (= 1.27.0) BUNDLED WITH - 2.3.27 + 2.4.10 diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 18f214ecb22..74e1b431ded 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -3,6 +3,7 @@ "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", @@ -66,11 +67,12 @@ }, "dependencies": { "@amplitude/analytics-react-native": "1.4.11", - "@apollo/client": "3.10.4", - "@datadog/mobile-react-native": "2.8.2", - "@datadog/mobile-react-navigation": "2.8.2", + "@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", @@ -79,43 +81,39 @@ "@formatjs/intl-relativetimeformat": "11.1.2", "@gorhom/bottom-sheet": "4.6.4", "@legendapp/list": "1.1.4", - "@react-native-async-storage/async-storage": "1.23.1", + "@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.77.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.2.8", - "@rnef/cli": "0.7.18", - "@rnef/platform-android": "0.7.18", - "@rnef/platform-ios": "0.7.18", - "@rnef/plugin-metro": "0.7.18", - "@rnef/provider-github": "0.7.18", - "@shopify/flash-list": "1.7.3", + "@reown/walletkit": "1.4.1", + "@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": "1.12.4", + "@shopify/react-native-skia": "2.2.4", "@sparkfabrik/react-native-idfa-aaid": "1.2.0", "@tanstack/react-query": "5.77.2", - "@testing-library/react": "16.1.0", + "@testing-library/react": "16.3.0", "@uniswap/analytics": "1.7.2", "@uniswap/analytics-events": "2.43.0", "@uniswap/client-explore": "0.0.17", "@uniswap/ethers-rs-mobile": "0.0.5", - "@uniswap/sdk-core": "7.7.2", + "@uniswap/sdk-core": "7.9.0", "@universe/api": "workspace:^", "@universe/gating": "workspace:^", - "@walletconnect/core": "2.21.4", - "@walletconnect/react-native-compat": "2.21.4", - "@walletconnect/types": "2.21.4", - "@walletconnect/utils": "2.21.4", + "@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", @@ -123,51 +121,53 @@ "d3-shape": "3.2.0", "dayjs": "1.11.7", "dotenv": "16.0.3", + "eas-build-cache-provider": "16.4.2", "eslint-plugin-rulesdir": "0.2.2", "ethers": "5.7.2", - "expo": "52.0.46", - "expo-blur": "14.0.3", - "expo-camera": "16.0.18", - "expo-clipboard": "7.0.1", - "expo-linear-gradient": "14.0.2", - "expo-linking": "7.0.5", - "expo-local-authentication": "15.0.2", - "expo-localization": "16.0.1", - "expo-screen-capture": "7.0.1", + "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-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.0.1", - "expo-web-browser": "14.0.2", + "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": "18.3.1", + "react": "19.0.0", "react-freeze": "1.0.3", "react-i18next": "14.1.0", - "react-native": "0.77.2", + "react-native": "0.79.5", "react-native-appsflyer": "6.13.1", - "react-native-bootsplash": "6.3.1", + "react-native-bootsplash": "6.3.10", "react-native-context-menu-view": "1.15.0", "react-native-device-info": "10.11.0", "react-native-dotenv": "3.2.0", "react-native-fast-image": "8.6.3", - "react-native-gesture-handler": "2.22.1", + "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.11.0", + "react-native-mmkv": "2.10.1", "react-native-onesignal": "5.2.9", - "react-native-pager-view": "6.5.1", + "react-native-pager-view": "6.7.1", "react-native-passkey": "3.1.0", "react-native-permissions": "4.1.5", - "react-native-reanimated": "3.16.7", + "react-native-reanimated": "3.19.3", "react-native-restart": "0.0.27", - "react-native-safe-area-context": "5.1.0", - "react-native-screens": "4.11.0", + "react-native-safe-area-context": "5.4.0", + "react-native-screens": "4.11.1", "react-native-sortables": "1.7.1", - "react-native-svg": "15.11.2", + "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", @@ -196,61 +196,48 @@ "@babel/plugin-proposal-numeric-separator": "7.16.7", "@babel/runtime": "7.26.0", "@datadog/datadog-ci": "2.48.0", - "@react-native-community/datetimepicker": "8.2.0", - "@react-native-community/slider": "4.5.5", + "@react-native-community/cli": "18.0.0", + "@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.125.17", - "@testing-library/react-native": "13.0.0", + "@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": "18.3.18", + "@types/react": "19.0.10", "@types/redux-mock-store": "1.0.6", "@uniswap/eslint-config": "workspace:^", - "@welldone-software/why-did-you-render": "8.0.1", + "@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": "12.0.6", + "babel-preset-expo": "13.0.0", "core-js": "2.6.12", "esbuild": "0.25.9", - "eslint": "8.44.0", - "expo-modules-core": "2.2.3", + "eslint": "8.57.1", + "expo-modules-core": "2.5.0", "inquirer": "8.2.6", "jest": "29.7.0", - "jest-expo": "52.0.3", + "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": "18.3.1", + "react-dom": "19.0.0", "react-native-asset": "2.1.1", "react-native-clean-project": "4.0.1", - "react-native-monorepo-tools": "1.2.1", "react-native-svg-transformer": "1.3.0", - "react-test-renderer": "18.3.1", - "reactotron-react-native": "5.1.10", - "reactotron-react-native-mmkv": "0.2.7", - "reactotron-redux": "3.1.10", + "react-test-renderer": "19.0.0", + "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.3.3" - }, - "expo": { - "install": { - "exclude": [ - "react-native@~0.76.9", - "react-native-reanimated@~3.16.7", - "react-native-gesture-handler@~2.20.0", - "react-native-screens@~4.4.0", - "react-native-safe-area-context@~4.12.0", - "react-native-webview@~13.12.5" - ] - }, - "autolinking": { - "exclude": ["expo-constants"] - } + "typescript": "5.8.3" } } diff --git a/apps/mobile/src/app/App.tsx b/apps/mobile/src/app/App.tsx index da7c8559205..f846bf04019 100644 --- a/apps/mobile/src/app/App.tsx +++ b/apps/mobile/src/app/App.tsx @@ -3,17 +3,26 @@ import { loadDevMessages, loadErrorMessages } from '@apollo/client/dev' import { DdRum, RumActionType } from '@datadog/mobile-react-native' import { BottomSheetModalProvider } from '@gorhom/bottom-sheet' import { PerformanceProfiler, RenderPassReport } from '@shopify/react-native-performance' +import { ApiInit, getEntryGatewayUrl, provideSessionService } from '@universe/api' import { DatadogSessionSampleRateKey, DynamicConfigs, Experiments, getDynamicConfigValue, + getIsSessionServiceEnabled, + getIsSessionUpgradeAutoEnabled, getStatsigClient, StatsigCustomAppValue, StatsigUser, Storage, + useIsSessionServiceEnabled, WALLET_FEATURE_FLAG_NAMES, } from '@universe/gating' +import { + createChallengeSolverService, + createSessionInitializationService, + 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' @@ -128,6 +137,17 @@ initAppsFlyer() initializePortfolioQueryOverrides({ store }) +const provideSessionInitializationService = (): SessionInitializationService => + createSessionInitializationService({ + getSessionService: () => + provideSessionService({ + getBaseUrl: getEntryGatewayUrl, + getIsSessionServiceEnabled, + }), + challengeSolverService: createChallengeSolverService(), + getIsSessionUpgradeAutoEnabled, + }) + function App(): JSX.Element | null { useEffect(() => { if (!__DEV__) { @@ -342,6 +362,7 @@ function DataUpdaters(): JSX.Element { const { locale } = useCurrentLanguageInfo() const { code } = useAppFiatCurrencyInfo() const finishedOnboarding = useSelector(selectFinishedOnboarding) + const isSessionServiceEnabled = useIsSessionServiceEnabled() useDatadogUserAttributesTracking({ isOnboarded: !!finishedOnboarding }) useHeartbeatReporter({ isOnboarded: !!finishedOnboarding }) @@ -366,6 +387,10 @@ function DataUpdaters(): JSX.Element { return ( <> + ) diff --git a/apps/mobile/src/components/PriceExplorer/PriceExplorer.tsx b/apps/mobile/src/components/PriceExplorer/PriceExplorer.tsx index 9517ca0e609..6bc7ac3a944 100644 --- a/apps/mobile/src/components/PriceExplorer/PriceExplorer.tsx +++ b/apps/mobile/src/components/PriceExplorer/PriceExplorer.tsx @@ -14,6 +14,7 @@ import { PriceNumberOfDigits, TokenSpotData, useTokenPriceHistory } from 'src/co 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' @@ -32,7 +33,7 @@ const LOW_VARIANCE_Y_PADDING = 100 type PriceTextProps = { loading: boolean - relativeChange?: SharedValue + relativeChange?: SharedValue numberOfDigits: PriceNumberOfDigits spotPrice?: SharedValue startingPrice?: number @@ -42,6 +43,7 @@ type PriceTextProps = { const PriceTextSection = memo(function PriceTextSection({ loading, numberOfDigits, + relativeChange, spotPrice, startingPrice, shouldTreatAsStablecoin, @@ -68,6 +70,7 @@ const PriceTextSection = memo(function PriceTextSection({ */} @@ -144,6 +147,8 @@ const PriceExplorerInner = memo(function _PriceExplorerInner(): JSX.Element { 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 ( diff --git a/apps/mobile/src/components/PriceExplorer/Text.tsx b/apps/mobile/src/components/PriceExplorer/Text.tsx index 8cddaffd8e7..7970a5a3096 100644 --- a/apps/mobile/src/components/PriceExplorer/Text.tsx +++ b/apps/mobile/src/components/PriceExplorer/Text.tsx @@ -1,10 +1,11 @@ import React from 'react' -import { useAnimatedStyle, useDerivedValue } from 'react-native-reanimated' -import { useLineChartDatetime } from 'react-native-wagmi-charts' +import { SharedValue, useAnimatedStyle, useDerivedValue } 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' @@ -42,26 +43,54 @@ export function PriceText({ maxWidth }: { loading: boolean; maxWidth?: number }) 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 relativeChange = 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 + ? numberToPercentWorklet(spotRelativeChange.value, { precision: 2, absolute: true }) + : calculatedRelativeChange.formatted.value + } + return calculatedRelativeChange.formatted.value + }) + const changeColor = useDerivedValue(() => { - if (relativeChange.value.value === 0) { + // 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.value > 0 ? colors.statusSuccess.val : colors.statusCritical.val + return relativeChange.value > 0 ? colors.statusSuccess.val : colors.statusCritical.val }) const styles = useAnimatedStyle(() => ({ @@ -69,16 +98,20 @@ export function RelativeChangeText({ })) const caretStyle = useAnimatedStyle(() => ({ color: changeColor.value, - transform: [{ rotate: relativeChange.value.value >= 0 ? '180deg' : '0deg' }], + 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} (${relativeChange.formatted.value})` + return `${delta} (${relativeChangeFormatted.value})` } - return relativeChange.formatted.value + return relativeChangeFormatted.value }) return ( @@ -89,25 +122,17 @@ export function RelativeChangeText({ mt={isAndroid ? '$none' : '$spacing2'} testID={TestID.RelativePriceChange} > - {loading ? ( + {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. - ) : ( - <> - = 0 ? -1 : 1 }, - ]} - /> - - )} + {/* Must always mount this component to avoid stale values on initial render */} + + + + ) } diff --git a/apps/mobile/src/components/PriceExplorer/__snapshots__/Text.test.tsx.snap b/apps/mobile/src/components/PriceExplorer/__snapshots__/Text.test.tsx.snap index 4b14e906407..219e745507e 100644 --- a/apps/mobile/src/components/PriceExplorer/__snapshots__/Text.test.tsx.snap +++ b/apps/mobile/src/components/PriceExplorer/__snapshots__/Text.test.tsx.snap @@ -16,26 +16,48 @@ exports[`DatetimeText renders without error 1`] = ` allowFontScaling={true} collapsable={false} editable={false} + jestAnimatedProps={ + { + "value": { + "text": "Thursday, November 1st, 2023", + }, + } + } jestAnimatedStyle={ { "value": {}, } } + jestInlineStyle={ + [ + { + "padding": 0, + }, + { + "color": "rgba(19, 19, 19, 0.63)", + "fontFamily": "Basel Grotesk", + "fontSize": 15, + "fontWeight": "400", + "lineHeight": 19.5, + }, + undefined, + ] + } maxFontSizeMultiplier={1.4} style={ - { - "color": { - "dynamic": { - "dark": "rgba(255, 255, 255, 0.65)", - "light": "rgba(19, 19, 19, 0.63)", - }, + [ + { + "padding": 0, }, - "fontFamily": "Basel Grotesk", - "fontSize": 15, - "fontWeight": "400", - "lineHeight": 19.5, - "padding": 0, - } + { + "color": "rgba(19, 19, 19, 0.63)", + "fontFamily": "Basel Grotesk", + "fontSize": 15, + "fontWeight": "400", + "lineHeight": 19.5, + }, + undefined, + ] } text="Thursday, November 1st, 2023" underlineColorAndroid="transparent" @@ -57,6 +79,13 @@ exports[`PriceText renders loading state 1`] = ` allowFontScaling={true} collapsable={false} editable={false} + jestAnimatedProps={ + { + "value": { + "text": "-", + }, + } + } jestAnimatedStyle={ { "value": { @@ -64,16 +93,43 @@ exports[`PriceText renders loading state 1`] = ` }, } } + jestInlineStyle={ + [ + { + "padding": 0, + }, + { + "fontFamily": "Basel Grotesk", + "fontSize": 53, + "fontWeight": "400", + "lineHeight": 50.879999999999995, + }, + [ + { + "color": "#131313", + }, + ], + ] + } maxFontSizeMultiplier={1.2} style={ - { - "color": "#131313", - "fontFamily": "Basel Grotesk", - "fontSize": 106, - "fontWeight": "400", - "lineHeight": 50.879999999999995, - "padding": 0, - } + [ + { + "padding": 0, + }, + { + "fontFamily": "Basel Grotesk", + "fontSize": 53, + "fontWeight": "400", + "lineHeight": 50.879999999999995, + }, + { + "color": "#131313", + }, + { + "fontSize": 106, + }, + ] } testID="wholePart" text="-" @@ -96,6 +152,13 @@ exports[`PriceText renders without error 1`] = ` allowFontScaling={true} collapsable={false} editable={false} + jestAnimatedProps={ + { + "value": { + "text": "$55", + }, + } + } jestAnimatedStyle={ { "value": { @@ -103,16 +166,43 @@ exports[`PriceText renders without error 1`] = ` }, } } + jestInlineStyle={ + [ + { + "padding": 0, + }, + { + "fontFamily": "Basel Grotesk", + "fontSize": 53, + "fontWeight": "400", + "lineHeight": 50.879999999999995, + }, + [ + { + "color": "#131313", + }, + ], + ] + } maxFontSizeMultiplier={1.2} style={ - { - "color": "#131313", - "fontFamily": "Basel Grotesk", - "fontSize": 106, - "fontWeight": "400", - "lineHeight": 50.879999999999995, - "padding": 0, - } + [ + { + "padding": 0, + }, + { + "fontFamily": "Basel Grotesk", + "fontSize": 53, + "fontWeight": "400", + "lineHeight": 50.879999999999995, + }, + { + "color": "#131313", + }, + { + "fontSize": 106, + }, + ] } testID="wholePart" text="$55" @@ -123,6 +213,13 @@ exports[`PriceText renders without error 1`] = ` allowFontScaling={true} collapsable={false} editable={false} + jestAnimatedProps={ + { + "value": { + "text": ".00", + }, + } + } jestAnimatedStyle={ { "value": { @@ -131,16 +228,39 @@ exports[`PriceText renders without error 1`] = ` }, } } + jestInlineStyle={ + [ + { + "padding": 0, + }, + { + "fontFamily": "Basel Grotesk", + "fontSize": 53, + "fontWeight": "400", + "lineHeight": 50.879999999999995, + }, + [], + ] + } maxFontSizeMultiplier={1.2} style={ - { - "color": "rgba(19, 19, 19, 0.35)", - "fontFamily": "Basel Grotesk", - "fontSize": 106, - "fontWeight": "400", - "lineHeight": 50.879999999999995, - "padding": 0, - } + [ + { + "padding": 0, + }, + { + "fontFamily": "Basel Grotesk", + "fontSize": 53, + "fontWeight": "400", + "lineHeight": 50.879999999999995, + }, + { + "color": "rgba(19, 19, 19, 0.35)", + }, + { + "fontSize": 106, + }, + ] } testID="decimalPart" text=".00" @@ -163,6 +283,13 @@ exports[`PriceText renders without error less than a dollar 1`] = ` allowFontScaling={true} collapsable={false} editable={false} + jestAnimatedProps={ + { + "value": { + "text": "$0", + }, + } + } jestAnimatedStyle={ { "value": { @@ -170,16 +297,43 @@ exports[`PriceText renders without error less than a dollar 1`] = ` }, } } + jestInlineStyle={ + [ + { + "padding": 0, + }, + { + "fontFamily": "Basel Grotesk", + "fontSize": 53, + "fontWeight": "400", + "lineHeight": 50.879999999999995, + }, + [ + { + "color": "#131313", + }, + ], + ] + } maxFontSizeMultiplier={1.2} style={ - { - "color": "#131313", - "fontFamily": "Basel Grotesk", - "fontSize": 106, - "fontWeight": "400", - "lineHeight": 50.879999999999995, - "padding": 0, - } + [ + { + "padding": 0, + }, + { + "fontFamily": "Basel Grotesk", + "fontSize": 53, + "fontWeight": "400", + "lineHeight": 50.879999999999995, + }, + { + "color": "#131313", + }, + { + "fontSize": 106, + }, + ] } testID="wholePart" text="$0" @@ -190,6 +344,13 @@ exports[`PriceText renders without error less than a dollar 1`] = ` allowFontScaling={true} collapsable={false} editable={false} + jestAnimatedProps={ + { + "value": { + "text": ".0500", + }, + } + } jestAnimatedStyle={ { "value": { @@ -198,16 +359,39 @@ exports[`PriceText renders without error less than a dollar 1`] = ` }, } } + jestInlineStyle={ + [ + { + "padding": 0, + }, + { + "fontFamily": "Basel Grotesk", + "fontSize": 53, + "fontWeight": "400", + "lineHeight": 50.879999999999995, + }, + [], + ] + } maxFontSizeMultiplier={1.2} style={ - { - "color": "#131313", - "fontFamily": "Basel Grotesk", - "fontSize": 106, - "fontWeight": "400", - "lineHeight": 50.879999999999995, - "padding": 0, - } + [ + { + "padding": 0, + }, + { + "fontFamily": "Basel Grotesk", + "fontSize": 53, + "fontWeight": "400", + "lineHeight": 50.879999999999995, + }, + { + "color": "#131313", + }, + { + "fontSize": 106, + }, + ] } testID="decimalPart" text=".0500" @@ -256,12 +440,7 @@ exports[`RelativeChangeText renders loading state 1`] = ` maxFontSizeMultiplier={1.4} style={ { - "color": { - "dynamic": { - "dark": "transparent", - "light": "transparent", - }, - }, + "color": "transparent", "fontFamily": "Basel Grotesk", "fontSize": 19, "fontWeight": "400", @@ -277,12 +456,7 @@ exports[`RelativeChangeText renders loading state 1`] = ` + + + + + + + + `; @@ -313,88 +613,131 @@ exports[`RelativeChangeText renders without error 1`] = ` } testID="relative-price-change" > - - - - - - + + + + + jestInlineStyle={ + [ + { + "padding": 0, + }, + { + "fontFamily": "Basel Grotesk", + "fontSize": 19, + "fontWeight": "400", + "lineHeight": 24.7, + }, + ] + } + maxFontSizeMultiplier={1.4} + style={ + [ + { + "padding": 0, + }, + { + "fontFamily": "Basel Grotesk", + "fontSize": 19, + "fontWeight": "400", + "lineHeight": 24.7, + }, + { + "color": "#E10F0F", + }, + ] + } + testID="relative-change-text" + text="10.00%" + underlineColorAndroid="transparent" + value="10.00%" + /> + `; diff --git a/apps/mobile/src/components/PriceExplorer/usePriceHistory.ts b/apps/mobile/src/components/PriceExplorer/usePriceHistory.ts index 62775d74aee..b472493f045 100644 --- a/apps/mobile/src/components/PriceExplorer/usePriceHistory.ts +++ b/apps/mobile/src/components/PriceExplorer/usePriceHistory.ts @@ -4,12 +4,15 @@ import { type Dispatch, type SetStateAction, useCallback, useMemo, useRef, useSt 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 + relativeChange: SharedValue } export type PriceNumberOfDigits = { @@ -63,18 +66,48 @@ export function useTokenPriceHistory({ 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] - const onChainData = priceData?.tokenProjects?.[0]?.tokens[0]?.market - const price = offChainData?.price?.value ?? onChainData?.price?.value ?? lastPrice.current + // 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 - const priceHistory = offChainData?.priceHistory ?? onChainData?.priceHistory + + // 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(() => pricePercentChange24h) + const spotRelativeChange = useDerivedValue(() => priceChange) + // biome-ignore lint/correctness/useExhaustiveDependencies: ensure spot updates when price changes const spot = useMemo( () => price !== undefined @@ -83,7 +116,7 @@ export function useTokenPriceHistory({ relativeChange: spotRelativeChange, } : undefined, - [price], + [price, priceChange, spotValue, spotRelativeChange], ) const formattedPriceHistory = useMemo(() => { diff --git a/apps/mobile/src/components/activity/ActivityContent.tsx b/apps/mobile/src/components/activity/ActivityContent.tsx index ed12c5ecaaa..6aafda789bd 100644 --- a/apps/mobile/src/components/activity/ActivityContent.tsx +++ b/apps/mobile/src/components/activity/ActivityContent.tsx @@ -3,7 +3,7 @@ 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 } 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' @@ -16,7 +16,7 @@ import { useBiometricAppSettings } from 'src/features/biometrics/useBiometricApp import { useBiometricPrompt } from 'src/features/biometricsSettings/hooks' import { openModal } from 'src/features/modals/modalSlice' import { removePendingSession } from 'src/features/walletConnect/walletConnectSlice' -import { Flex, useSporeColors } from 'ui/src' +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' @@ -29,6 +29,7 @@ import { useActivityDataWallet } from 'wallet/src/features/activity/useActivityD 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 _ActivityTab( @@ -61,7 +62,16 @@ export const ActivityContent = memo( dispatch(openModal({ name: ModalName.WalletConnectScan, initialState: ScannerModalState.WalletQr })) }) - const { maybeEmptyComponent, renderActivityItem, sectionData, keyExtractor } = useActivityDataWallet({ + const { + maybeEmptyComponent, + renderActivityItem, + sectionData, + keyExtractor, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + refetch, + } = useActivityDataWallet({ evmOwner: owner, authTrigger: requiresBiometrics ? biometricsTrigger : undefined, isExternalProfile, @@ -71,6 +81,20 @@ export const ActivityContent = memo( 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 @@ -79,12 +103,12 @@ export const ActivityContent = memo( return ( ) - }, [isBottomTabsEnabled, insets.top, headerHeight, refreshing, colors.neutral3, onRefresh]) + }, [isBottomTabsEnabled, insets.top, headerHeight, refreshingAll, colors.neutral3, handleRefresh]) const List = renderedInModal ? AnimatedBottomSheetFlatList : AnimatedFlatList @@ -103,11 +127,20 @@ export const ActivityContent = memo( estimatedItemSize={ESTIMATED_ITEM_SIZE} drawDistance={ESTIMATED_ITEM_SIZE * AMOUNT_TO_DRAW} ListEmptyComponent={maybeEmptyComponent} - ListFooterComponent={isExternalProfile ? null : adaptiveFooter} + ListFooterComponent={ + isExternalProfile ? null : ( + + {isFetchingNextPage && } + {adaptiveFooter} + + ) + } contentContainerStyle={containerProps?.contentContainerStyle} refreshControl={refreshControl} - refreshing={refreshing} + refreshing={refreshingAll} onContentSizeChange={onContentSizeChange} + onEndReached={hasNextPage && !isFetchingNextPage ? fetchNextPage : undefined} + onEndReachedThreshold={ON_END_REACHED_THRESHOLD} /> ) : ( + {isFetchingNextPage && } + {adaptiveFooter} + + ) + } onScroll={scrollHandler} onContentSizeChange={onContentSizeChange} + onEndReached={hasNextPage && !isFetchingNextPage ? fetchNextPage : undefined} + onEndReachedThreshold={ON_END_REACHED_THRESHOLD} {...containerProps} /> )} diff --git a/apps/mobile/src/components/home/introCards/OnboardingIntroCardStack.tsx b/apps/mobile/src/components/home/introCards/OnboardingIntroCardStack.tsx index 910a539088f..73f8117bb61 100644 --- a/apps/mobile/src/components/home/introCards/OnboardingIntroCardStack.tsx +++ b/apps/mobile/src/components/home/introCards/OnboardingIntroCardStack.tsx @@ -1,16 +1,23 @@ import { SharedEventName } from '@uniswap/analytics-events' import { FeatureFlags, useFeatureFlag } from '@universe/gating' -import React, { useCallback, useMemo } from 'react' +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 { useAppStackNavigation } from 'src/app/navigation/types' import { NotificationPermission, useNotificationOSPermissionsEnabled, } from 'src/features/notifications/hooks/useNotificationOSPermissionsEnabled' -import { Flex } from 'ui/src' -import { BRIDGED_ASSETS_CARD_BANNER, PUSH_NOTIFICATIONS_CARD_BANNER } from 'ui/src/assets' +import { Flex, useIsDarkMode } from 'ui/src' +import { + BRIDGED_ASSETS_CARD_BANNER, + BRIDGED_ASSETS_V2_CARD_BANNER_DARK, + BRIDGED_ASSETS_V2_CARD_BANNER_LIGHT, + PUSH_NOTIFICATIONS_CARD_BANNER, +} from 'ui/src/assets' import { Buy } from 'ui/src/components/icons' +import { MonadAnnouncementModal } from 'uniswap/src/components/notifications/MonadAnnouncementModal' import { AccountType } from 'uniswap/src/features/accounts/types' import { UniverseChainId } from 'uniswap/src/features/chains/types' import { ElementName, ModalName, WalletEventName } from 'uniswap/src/features/telemetry/constants' @@ -30,9 +37,14 @@ import { useSharedIntroCards } from 'wallet/src/components/introCards/useSharedI import { useWalletNavigation } from 'wallet/src/contexts/WalletNavigationContext' import { selectHasViewedBridgedAssetsCard, + selectHasViewedBridgedAssetsV2Card, selectHasViewedNotificationsCard, } from 'wallet/src/features/behaviorHistory/selectors' -import { setHasViewedBridgedAssetsCard, setHasViewedNotificationsCard } from 'wallet/src/features/behaviorHistory/slice' +import { + setHasViewedBridgedAssetsCard, + setHasViewedBridgedAssetsV2Card, + setHasViewedNotificationsCard, +} from 'wallet/src/features/behaviorHistory/slice' import { useActiveAccountWithThrow } from 'wallet/src/features/wallet/hooks' type OnboardingIntroCardStackProps = { @@ -44,10 +56,12 @@ export function OnboardingIntroCardStack({ isLoading = false, }: OnboardingIntroCardStackProps): JSX.Element | null { const { t } = useTranslation() + const isDarkMode = useIsDarkMode() const dispatch = useDispatch() const activeAccount = useActiveAccountWithThrow() const address = activeAccount.address const isSignerAccount = activeAccount.type === AccountType.SignerMnemonic + const [isMonadModalOpen, setIsMonadModalOpen] = useState(false) const { notificationPermissionsEnabled } = useNotificationOSPermissionsEnabled() const notificationOnboardingCardEnabled = useFeatureFlag(FeatureFlags.NotificationOnboardingCard) @@ -60,7 +74,12 @@ export function OnboardingIntroCardStack({ const hasViewedBridgedAssetCard = useSelector(selectHasViewedBridgedAssetsCard) const shouldShowBridgedAssetCard = useFeatureFlag(FeatureFlags.BridgedAssetsBanner) && !hasViewedBridgedAssetCard + const hasViewedBridgedAssetsV2Card = useSelector(selectHasViewedBridgedAssetsV2Card) + const shouldShowBridgedAssetsV2Card = + useFeatureFlag(FeatureFlags.BridgedAssetsBannerV2) && !hasViewedBridgedAssetsV2Card + const { navigateToSwapFlow } = useWalletNavigation() + const navigation = useAppStackNavigation() const navigateToUnitagClaim = useCallback(() => { navigate(MobileScreens.UnitagStack, { @@ -93,10 +112,21 @@ export function OnboardingIntroCardStack({ navigateToSwapFlow({ openTokenSelector: CurrencyField.OUTPUT, inputChainId: UniverseChainId.Unichain }) }, [navigateToSwapFlow]) + const handleMonadExplorePress = useCallback(() => { + navigation.navigate(ModalName.Explore, { + screen: MobileScreens.Explore, + params: { + chainId: UniverseChainId.Monad, + }, + }) + setIsMonadModalOpen(false) + }, [navigation]) + const { cards: sharedCards } = useSharedIntroCards({ navigateToUnitagClaim, navigateToUnitagIntro, navigateToBackupFlow, + onMonadAnnouncementPress: () => setIsMonadModalOpen(true), }) const cards = useMemo((): IntroCardProps[] => { @@ -151,6 +181,26 @@ export function OnboardingIntroCardStack({ }) } + if (shouldShowBridgedAssetsV2Card) { + output.push({ + loggingName: OnboardingCardLoggingName.BridgedAsset, + graphic: { + type: IntroCardGraphicType.Image, + image: isDarkMode ? BRIDGED_ASSETS_V2_CARD_BANNER_DARK : BRIDGED_ASSETS_V2_CARD_BANNER_LIGHT, + }, + title: t('onboarding.home.intro.bridgedAssets.title'), + description: t('onboarding.home.intro.bridgedAssets.description.v2'), + cardType: CardType.Dismissible, + onPress: () => { + navigateToBridgedAssetSwap() + dispatch(setHasViewedBridgedAssetsV2Card(true)) + }, + onClose: () => { + dispatch(setHasViewedBridgedAssetsV2Card(true)) + }, + }) + } + if (shouldShowBridgedAssetCard) { output.push({ loggingName: OnboardingCardLoggingName.BridgedAsset, @@ -177,9 +227,11 @@ export function OnboardingIntroCardStack({ isSignerAccount, sharedCards, t, + isDarkMode, dispatch, navigateToBridgedAssetSwap, shouldShowBridgedAssetCard, + shouldShowBridgedAssetsV2Card, showEnableNotificationsCard, ]) @@ -195,13 +247,24 @@ export function OnboardingIntroCardStack({ [cards], ) - if (cards.length) { - return ( - - {isLoading ? : } - - ) - } - - return null + return ( + <> + {!!cards.length && ( + + {isLoading ? ( + + ) : ( + + )} + + )} + {isMonadModalOpen && ( + setIsMonadModalOpen(false)} + onExplorePress={handleMonadExplorePress} + /> + )} + + ) } diff --git a/apps/mobile/src/features/deepLinking/configUtils.ts b/apps/mobile/src/features/deepLinking/configUtils.ts index 19231b35d75..e182bed83f6 100644 --- a/apps/mobile/src/features/deepLinking/configUtils.ts +++ b/apps/mobile/src/features/deepLinking/configUtils.ts @@ -1,25 +1,6 @@ -import { - DeepLinkUrlAllowlist, - DeepLinkUrlAllowlistConfigKey, - DynamicConfigs, - getDynamicConfigValue, - UwULinkAllowlist, - UwuLinkConfigKey, -} from '@universe/gating' +import { DynamicConfigs, getDynamicConfigValue, UwULinkAllowlist, UwuLinkConfigKey } from '@universe/gating' import { isUwULinkAllowlistType } from 'uniswap/src/features/gating/typeGuards' -/** - * Gets the in-app browser allowlist from dynamic config. - * This function wraps getDynamicConfigValue for easier testing. - */ -export function getInAppBrowserAllowlist(): DeepLinkUrlAllowlist { - return getDynamicConfigValue({ - config: DynamicConfigs.DeepLinkUrlAllowlist, - key: DeepLinkUrlAllowlistConfigKey.AllowedUrls, - defaultValue: { allowedUrls: [] }, - }) -} - /** * Gets the UwuLink allowlist from dynamic config. * This function wraps getDynamicConfigValue for easier testing. diff --git a/apps/mobile/src/features/deepLinking/deepLinkUtils.ts b/apps/mobile/src/features/deepLinking/deepLinkUtils.ts index 25082908907..6bcb8ba2605 100644 --- a/apps/mobile/src/features/deepLinking/deepLinkUtils.ts +++ b/apps/mobile/src/features/deepLinking/deepLinkUtils.ts @@ -1,4 +1,3 @@ -import { DeepLinkUrlAllowlist } from '@universe/gating' import { getScantasticQueryParams } from 'src/components/Requests/ScanSheet/util' import { UNISWAP_URL_SCHEME_UWU_LINK } from 'src/components/Requests/Uwulink/utils' import { diff --git a/apps/mobile/src/screens/ActivityScreen.tsx b/apps/mobile/src/screens/ActivityScreen.tsx index 5ab318ab1fa..68e8a23175a 100644 --- a/apps/mobile/src/screens/ActivityScreen.tsx +++ b/apps/mobile/src/screens/ActivityScreen.tsx @@ -1,44 +1,19 @@ -import { useApolloClient } from '@apollo/client' import { useScrollToTop } from '@react-navigation/native' -import { useQuery } from '@tanstack/react-query' -import { GQLQueries } from '@universe/api' import { FeatureFlags, useFeatureFlag } from '@universe/gating' -import { useCallback, useEffect, useMemo, useRef } from 'react' +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 { useAppStateTrigger } from 'src/utils/useAppStateTrigger' 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 { ReactQueryCacheKey } from 'utilities/src/reactQuery/cache' import { useActiveAccountWithThrow } from 'wallet/src/features/wallet/hooks' -const useRefreshActivityData = (owner: Address): { refreshing: boolean; onRefreshActivityData: () => void } => { - const apolloClient = useApolloClient() - - const refreshFn = useCallback( - () => - apolloClient.refetchQueries({ - include: [GQLQueries.TransactionList], - }), - [apolloClient], - ) - - const { refetch, isRefetching } = useQuery({ - queryKey: [ReactQueryCacheKey.ActivityScreenRefresh, owner], - enabled: false, - retry: 0, - queryFn: refreshFn, - }) - - return { refreshing: isRefetching, onRefreshActivityData: refetch } -} - export function ActivityScreen(): JSX.Element { const { t } = useTranslation() const activeAccount = useActiveAccountWithThrow() @@ -47,15 +22,6 @@ export function ActivityScreen(): JSX.Element { useScrollToTop(scrollRef) - const { refreshing, onRefreshActivityData } = useRefreshActivityData(activeAccount.address) - - // Automatically refresh activity data when app comes to foreground - useAppStateTrigger({ - from: 'background', - to: 'active', - callback: onRefreshActivityData, - }) - const insets = useAppInsets() const isBottomTabsEnabled = useFeatureFlag(FeatureFlags.BottomTabs) @@ -84,10 +50,9 @@ export function ActivityScreen(): JSX.Element { ) diff --git a/apps/mobile/src/screens/HomeScreen/HomeScreen.tsx b/apps/mobile/src/screens/HomeScreen/HomeScreen.tsx index e9d87ab7df3..43b49e91c68 100644 --- a/apps/mobile/src/screens/HomeScreen/HomeScreen.tsx +++ b/apps/mobile/src/screens/HomeScreen/HomeScreen.tsx @@ -49,10 +49,15 @@ import { SMART_WALLET_UPGRADE_FALLBACK, SMART_WALLET_UPGRADE_VIDEO } from 'ui/sr import { AnimatedFlex } from 'ui/src/components/layout/AnimatedFlex' import { useDeviceDimensions } from 'ui/src/hooks/useDeviceDimensions' import { spacing } from 'ui/src/theme' +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 { getPortfolioQuery } from 'uniswap/src/data/rest/getPortfolio' import { getListTransactionsQuery } from 'uniswap/src/data/rest/listTransactions' import { AccountType } from 'uniswap/src/features/accounts/types' +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' @@ -60,6 +65,7 @@ import { ModalName, SectionName } from 'uniswap/src/features/telemetry/constants import { useAppInsets } from 'uniswap/src/hooks/useAppInsets' import { TestID } from 'uniswap/src/test/fixtures/testIDs' import { MobileScreens } from 'uniswap/src/types/screens/mobile' +import { openUri } from 'uniswap/src/utils/linking' import { logger } from 'utilities/src/logger/logger' import { useEvent } from 'utilities/src/react/hooks' import { SmartWalletCreatedModal } from 'wallet/src/components/smartWallet/modals/SmartWalletCreatedModal' @@ -98,6 +104,7 @@ function HomeScreen(props?: AppStackScreenProp): JSX.Element const activeAccount = useActiveAccountWithThrow() const { t } = useTranslation() const colors = useSporeColors() + const darkColors = useSporeColors('dark') const media = useMedia() const insets = useAppInsets() const dimensions = useDeviceDimensions() @@ -111,6 +118,10 @@ function HomeScreen(props?: AppStackScreenProp): JSX.Element const { requiredForTransactions: requiresBiometrics } = useBiometricAppSettings() const isBottomTabsEnabled = useFeatureFlag(FeatureFlags.BottomTabs) + const isWrappedBannerEnabled = useFeatureFlag(FeatureFlags.UniswapWrapped2025) + + const hasDismissedWrappedBanner = useSelector(selectHasDismissedUniswapWrapped2025Banner) + const shouldShowWrappedBanner = isWrappedBannerEnabled && !hasDismissedWrappedBanner const { showEmptyWalletState, isTabsDataLoaded } = useHomeScreenState() @@ -144,7 +155,9 @@ function HomeScreen(props?: AppStackScreenProp): JSX.Element const tabs: Array = [ { key: SectionName.HomeTokensTab, title: tokensTitle }, { key: SectionName.HomeNFTsTab, title: nftsTitle }, - ...(!isBottomTabsEnabled ? [{ key: SectionName.HomeActivityTab, title: activityTitle }] : []), + ...(!isBottomTabsEnabled + ? [{ key: SectionName.HomeActivityTab, title: activityTitle, enableNotificationBadge: true }] + : []), ] return tabs @@ -284,6 +297,20 @@ function HomeScreen(props?: AppStackScreenProp): JSX.Element const viewOnlyLabel = t('home.warning.viewOnly') + const handleDismissWrappedBanner = useCallback(() => { + dispatch(setHasDismissedUniswapWrapped2025Banner(true)) + }, [dispatch]) + + const handlePressWrappedBanner = useCallback(async () => { + try { + const url = buildWrappedUrl(UNISWAP_WEB_URL, activeAccount.address) + await openUri({ uri: url, openExternalBrowser: true }) + dispatch(setHasDismissedUniswapWrapped2025Banner(true)) + } catch (error) { + logger.error(error, { tags: { file: 'HomeScreen', function: 'handlePressWrappedBanner' } }) + } + }, [activeAccount.address, dispatch]) + const promoBanner = useMemo( () => , [showEmptyWalletState, isTabsDataLoaded], @@ -296,9 +323,25 @@ function HomeScreen(props?: AppStackScreenProp): JSX.Element pb={showEmptyWalletState ? '$spacing8' : '$spacing16'} px={isBottomTabsEnabled ? '$none' : '$spacing12'} > + {shouldShowWrappedBanner && ( + + + + + )} - + {isSignerAccount ? ( @@ -317,6 +360,9 @@ function HomeScreen(props?: AppStackScreenProp): JSX.Element }, [ showEmptyWalletState, isBottomTabsEnabled, + shouldShowWrappedBanner, + handleDismissWrappedBanner, + handlePressWrappedBanner, activeAccount.address, isSignerAccount, onPressViewOnlyLabel, @@ -393,11 +439,9 @@ function HomeScreen(props?: AppStackScreenProp): JSX.Element ) const statusBarStyle = useAnimatedStyle(() => ({ - backgroundColor: interpolateColor( - currentScrollValue.value, - [0, headerHeightDiff], - [colors.surface1.val, colors.surface1.val], - ), + backgroundColor: shouldShowWrappedBanner + ? darkColors.surface1.val + : interpolateColor(currentScrollValue.value, [0, headerHeightDiff], [colors.surface1.val, colors.surface1.val]), })) const apolloClient = useApolloClient() diff --git a/apps/mobile/tsconfig.json b/apps/mobile/tsconfig.json index 9a7067e0a47..14599dc3084 100644 --- a/apps/mobile/tsconfig.json +++ b/apps/mobile/tsconfig.json @@ -21,9 +21,6 @@ }, { "path": "../../packages/api" - }, - { - "path": "../../packages/gating" } ], "compilerOptions": { diff --git a/apps/web/cypress/support/commands.ts b/apps/web/cypress/support/commands.ts deleted file mode 100644 index f35f18e9b4b..00000000000 --- a/apps/web/cypress/support/commands.ts +++ /dev/null @@ -1,168 +0,0 @@ -import 'cypress-hardhat/lib/browser' - -import { Eip1193 } from 'cypress-hardhat/lib/browser/eip1193' -import { FeatureFlagClient, FeatureFlags, getFeatureFlagName } from 'uniswap/src/features/gating/flags' -import { ALLOW_ANALYTICS_ATOM_KEY } from 'utilities/src/telemetry/analytics/constants' -import { UserState, initialState } from '../../src/state/user/reducer' -import { setInitialUserState } from '../utils/user-state' - -declare global { - // eslint-disable-next-line @typescript-eslint/no-namespace - namespace Cypress { - interface ApplicationWindow { - ethereum: Eip1193 - } - interface Chainable { - /** - * Wait for a specific event to be sent to amplitude. If the event is found, the subject will be the event. - * - * @param {string} eventName - The type of the event to search for e.g. SwapEventName.SWAP_TRANSACTION_COMPLETED - * @param {number} timeout - The maximum amount of time (in ms) to wait for the event. - * @returns {Chainable} - */ - waitForAmplitudeEvent(eventName: string, requiredProperties?: string[]): Chainable - /** - * Intercepts a specific graphql operation and responds with the given fixture. - * @param {string} operationName - The name of the graphql operation to intercept. - * @param {string} fixturePath - The path to the fixture to respond with. - */ - interceptGraphqlOperation(operationName: string, fixturePath: string): Chainable - /** - * Intercepts a quote request and responds with the given fixture. - * @param {string} fixturePath - The path to the fixture to respond with. - */ - interceptQuoteRequest(fixturePath: string): Chainable - } - interface Cypress { - eagerlyConnect?: boolean - } - interface VisitOptions { - featureFlags?: Array<{ flag: FeatureFlags; value: boolean }> - /** - * Initial user state. - * @default {@type import('../utils/user-state').CONNECTED_WALLET_USER_STATE} - */ - userState?: Partial - /** - * If false, prevents the app from eagerly connecting to the injected provider. - * @default true - */ - eagerlyConnect?: false - } - } -} - -export function registerCommands() { - // sets up the injected provider to be a mock ethereum provider with the given mnemonic/index - // eslint-disable-next-line no-undef - Cypress.Commands.overwrite( - 'visit', - (original, url: string | Partial, options?: Partial) => { - if (typeof url !== 'string') { - throw new Error('Invalid arguments. The first argument to cy.visit must be the path.') - } - - // Parse overrides - const flagsOn: FeatureFlags[] = [] - const flagsOff: FeatureFlags[] = [] - options?.featureFlags?.forEach((f) => { - if (f.value) { - flagsOn.push(f.flag) - } else { - flagsOff.push(f.flag) - } - }) - - // Format into URL parameters - const overrideParams = new URLSearchParams() - if (flagsOn.length > 0) { - overrideParams.append( - 'featureFlagOverride', - flagsOn.map((flag) => getFeatureFlagName(flag, FeatureFlagClient.Web)).join(','), - ) - } - if (flagsOff.length > 0) { - overrideParams.append( - 'featureFlagOverrideOff', - flagsOn.map((flag) => getFeatureFlagName(flag, FeatureFlagClient.Web)).join(','), - ) - } - - return cy.provider().then((provider) => - original({ - ...options, - url: - [...overrideParams.entries()].length === 0 - ? url - : url.includes('?') - ? `${url}&${overrideParams.toString()}` - : `${url}?${overrideParams.toString()}`, - onBeforeLoad(win) { - options?.onBeforeLoad?.(win) - - setInitialUserState(win, { - ...initialState, - ...(options?.userState ?? {}), - }) - - win.ethereum = provider - win.Cypress.eagerlyConnect = options?.eagerlyConnect ?? true - win.localStorage.setItem(ALLOW_ANALYTICS_ATOM_KEY, 'true') - win.localStorage.setItem('showUniswapExtensionLaunchAtom', 'false') - }, - }), - ) - }, - ) - - Cypress.Commands.add('waitForAmplitudeEvent', (eventName, requiredProperties) => { - function findAndDiscardEventsUpToTarget() { - const events = Cypress.env('amplitudeEventCache') - const targetEventIndex = events.findIndex((event) => { - if (event.event_type !== eventName) { - return false - } - if (requiredProperties) { - return requiredProperties.every((prop) => event.event_properties[prop]) - } - return true - }) - - if (targetEventIndex !== -1) { - const event = events[targetEventIndex] - Cypress.env('amplitudeEventCache', events.slice(targetEventIndex + 1)) - return cy.wrap(event) - } else { - // If not found, retry after waiting for more events to be sent. - return cy.wait('@amplitude').then(findAndDiscardEventsUpToTarget) - } - } - return findAndDiscardEventsUpToTarget() - }) - - Cypress.env('graphqlInterceptions', new Map()) - - Cypress.Commands.add('interceptGraphqlOperation', (operationName, fixturePath) => { - const graphqlInterceptions = Cypress.env('graphqlInterceptions') - cy.intercept(/(?:interface|beta)\.gateway\.uniswap\.org\/v1\/graphql/, (req) => { - req.headers['origin'] = 'https://app.uniswap.org' - const currentOperationName = req.body.operationName - - if (graphqlInterceptions.has(currentOperationName)) { - const fixturePath = graphqlInterceptions.get(currentOperationName) - req.reply({ fixture: fixturePath }) - } else { - req.continue() - } - }).as(operationName) - - graphqlInterceptions.set(operationName, fixturePath) - }) - - Cypress.Commands.add('interceptQuoteRequest', (fixturePath) => { - return cy.intercept(/(?:interface|beta)\.gateway\.uniswap\.org\/v2\/quote/, (req) => { - req.headers['origin'] = 'https://app.uniswap.org' - req.reply({ fixture: fixturePath }) - }) - }) -} diff --git a/apps/web/package.json b/apps/web/package.json index 59eaf8abdbb..247b5e9febb 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -17,9 +17,13 @@ "build:production": "nx build:production web", "build:staging": "nx build:staging web", "build:staging:preview": "nx build:staging:preview web", + "build:e2e": "nx build:e2e web", "preview": "nx preview web", "preview:staging": "nx preview:staging web", "preview:e2e": "nx preview:e2e web", + "e2e": "nx e2e web --outputStyle tui", + "e2e:anvil": "nx e2e:anvil web --outputStyle tui", + "e2e:no-anvil": "nx e2e:no-anvil web --outputStyle tui", "analyze": "nx analyze web", "lint:biome": "nx lint:biome web", "lint:biome:fix": "nx lint:biome:fix web", @@ -75,8 +79,8 @@ "@storybook/test-runner": "0.21.0", "@swc/core": "1.5.7", "@swc/plugin-styled-components": "1.5.97", - "@testing-library/jest-dom": "6.6.3", - "@testing-library/react": "16.1.0", + "@testing-library/jest-dom": "6.8.0", + "@testing-library/react": "16.3.0", "@testing-library/user-event": "14.5.1", "@typechain/ethers-v5": "7.2.0", "@types/array.prototype.flat": "1.2.5", @@ -87,17 +91,17 @@ "@types/multicodec": "1.0.0", "@types/node": "22.13.1", "@types/qs": "6.9.2", - "@types/react": "18.3.18", - "@types/react-dom": "18.3.1", + "@types/react": "19.0.10", + "@types/react-dom": "19.0.6", "@types/react-redux": "7.1.30", "@types/react-virtualized-auto-sizer": "1.0.0", "@types/react-window": "1.8.2", "@types/rebass": "4.0.7", - "@types/styled-components": "5.1.25", + "@types/styled-components": "5.1.34", "@types/uuid": "9.0.1", "@types/wcag-contrast": "3.0.0", "@types/xml2js": "0.4.14", - "@uniswap/client-trading": "0.1.0", + "@uniswap/client-trading": "0.1.2", "@uniswap/default-token-list": "11.19.0", "@uniswap/eslint-config": "workspace:^", "@vercel/og": "0.5.8", @@ -114,7 +118,7 @@ "dotenv": "16.0.3", "dotenv-cli": "7.1.0", "esbuild-register": "3.6.0", - "eslint": "8.44.0", + "eslint": "8.57.1", "eslint-plugin-import": "2.27.5", "eslint-plugin-storybook": "0.8.0", "http-server": "14.1.1", @@ -123,7 +127,7 @@ "jest-styled-components": "7.2.0", "lint-staged": "14.0.1", "madge": "6.1.0", - "playwright": "1.55.1", + "playwright": "1.49.1", "postinstall-postinstall": "2.1.0", "process": "0.11.10", "prop-types": "15.8.1", @@ -132,13 +136,13 @@ "resize-observer-polyfill": "1.5.1", "source-map-explorer": "2.5.3", "start-server-and-test": "2.0.0", - "storybook": "8.6.15", + "storybook": "8.5.2", "storybook-addon-pseudo-states": "4.0.2", "swc-loader": "0.2.6", "terser": "5.24.0", "terser-webpack-plugin": "5.3.9", "tsafe": "1.6.4", - "typescript": "5.3.3", + "typescript": "5.8.3", "vite": "npm:rolldown-vite@7.0.10", "vite-plugin-bundlesize": "0.2.0", "vite-plugin-commonjs": "0.10.4", @@ -148,12 +152,12 @@ "vitest": "3.2.1", "vitest-fetch-mock": "0.4.5", "wait-on": "8.0.2", - "webpack": "5.94.0", + "webpack": "5.90.0", "wrangler": "4.28.0" }, "dependencies": { "@amplitude/analytics-browser": "1.12.1", - "@apollo/client": "3.10.4", + "@apollo/client": "3.11.10", "@binance/w3w-wagmi-connector-v2": "1.2.5", "@cloudflare/vite-plugin": "1.11.1", "@datadog/browser-logs": "5.20.0", @@ -177,9 +181,9 @@ "@solana/wallet-adapter-react": "0.15.39", "@solana/web3.js": "1.92.0", "@svgr/webpack": "8.0.1", - "@tamagui/core": "1.125.17", - "@tamagui/react-native-svg": "1.125.17", - "@tamagui/vite-plugin": "1.125.17", + "@tamagui/core": "1.136.1", + "@tamagui/react-native-svg": "1.136.1", + "@tamagui/vite-plugin": "1.136.1", "@tanstack/query-sync-storage-persister": "5.75.0", "@tanstack/react-query": "5.77.2", "@tanstack/react-query-persist-client": "5.77.2", @@ -187,12 +191,14 @@ "@types/react-scroll-sync": "0.9.0", "@uniswap/analytics": "1.7.2", "@uniswap/analytics-events": "2.43.0", - "@uniswap/client-data-api": "0.0.18", + "@uniswap/client-data-api": "0.0.24", "@uniswap/client-explore": "0.0.17", + "@uniswap/client-liquidity": "0.0.6", + "@uniswap/client-notification-service": "0.0.3", "@uniswap/merkle-distributor": "1.0.1", "@uniswap/permit2-sdk": "1.3.0", "@uniswap/router-sdk": "2.0.2", - "@uniswap/sdk-core": "7.7.2", + "@uniswap/sdk-core": "7.9.0", "@uniswap/token-lists": "1.0.0-beta.33", "@uniswap/uniswapx-sdk": "3.0.0-beta.7", "@uniswap/universal-router-sdk": "4.19.5", @@ -204,6 +210,8 @@ "@uniswap/v4-sdk": "1.21.2", "@universe/api": "workspace:^", "@universe/gating": "workspace:^", + "@universe/notifications": "workspace:^", + "@universe/sessions": "workspace:^", "@visx/group": "2.17.0", "@visx/responsive": "3.12.0", "@visx/shape": "2.18.0", @@ -221,8 +229,8 @@ "ethers": "5.7.2", "fancy-canvas": "2.1.0", "framer-motion": "10.17.6", - "graphql": "16.8.1", - "hono": "4.11.4", + "graphql": "16.6.0", + "hono": "4.8.4", "html-entities": "2.6.0", "i18next": "23.10.0", "jotai": "1.3.7", @@ -239,18 +247,17 @@ "polished": "3.3.2", "polyfill-object.fromentries": "1.0.1", "porto": "0.0.80", - "qs": "6.14.1", - "react": "18.3.1", - "react-dom": "18.3.1", + "qs": "6.11.0", + "react": "19.0.0", + "react-dom": "19.0.0", "react-feather": "2.0.10", "react-helmet-async": "2.0.4", "react-i18next": "14.1.0", - "react-is": "18.3.1", - "react-native-gesture-handler": "2.22.1", - "react-native-reanimated": "3.16.7", + "react-native-gesture-handler": "2.24.0", + "react-native-reanimated": "3.19.3", "react-popper": "2.3.0", "react-redux": "8.0.5", - "react-router": "7.12.0", + "react-router": "7.6.3", "react-scroll-sync": "0.11.2", "react-virtualized-auto-sizer": "1.0.20", "react-window": "1.8.9", @@ -259,7 +266,7 @@ "redux-persist": "6.0.0", "redux-saga": "1.2.2", "sonner": "1.7.2", - "styled-components": "5.3.11", + "styled-components": "6.1.19", "tiny-invariant": "1.3.1", "ts-loader": "9.5.1", "typed-redux-saga": "1.5.0", @@ -279,7 +286,7 @@ "engines": { "npm": "please-use-bun", "node": "=22.13.1", - "bun": ">=1.2.0" + "bun": ">=1.3.1" }, "sideEffects": ["*.css", "src/sideEffects.ts", "src/tracing/index.ts"] } diff --git a/apps/web/project.json b/apps/web/project.json index a49be623dc7..589e9cd881d 100644 --- a/apps/web/project.json +++ b/apps/web/project.json @@ -10,7 +10,7 @@ "wait-for-webserver": { "executor": "nx:run-commands", "options": { - "command": "wait-on http://localhost:3000 --timeout 60000" + "command": "wait-on http://localhost:3000 --timeout 60000 --interval 1000 --delay 5000" } }, "e2e": { @@ -19,7 +19,7 @@ "command": "nx playwright:test web", "cwd": "{projectRoot}" }, - "dependsOn": ["anvil:mainnet", "preview", "wait-for-webserver"] + "dependsOn": ["preview:e2e", "wait-for-webserver"] }, "e2e:anvil": { "executor": "nx:run-commands", @@ -27,7 +27,7 @@ "command": "nx playwright:test:anvil web", "cwd": "{projectRoot}" }, - "dependsOn": ["anvil:mainnet", "preview", "wait-for-webserver"] + "dependsOn": ["preview:e2e", "wait-for-webserver"] }, "e2e:no-anvil": { "executor": "nx:run-commands", @@ -35,7 +35,7 @@ "command": "nx playwright:test:no-anvil web", "cwd": "{projectRoot}" }, - "dependsOn": ["preview", "wait-for-webserver"] + "dependsOn": ["preview:e2e", "wait-for-webserver"] }, "prepare": { "command": "bun ajv", @@ -56,14 +56,14 @@ } }, "anvil:mainnet": { - "command": "dotenv -- bash -c 'RUST_LOG=debug anvil --print-traces --fork-url https://${REACT_APP_QUICKNODE_ENDPOINT_NAME}.quiknode.pro/${REACT_APP_QUICKNODE_ENDPOINT_TOKEN} --hardfork prague --no-rate-limit'", + "command": "dotenv -- bash -c './scripts/start-anvil.sh \"https://${REACT_APP_QUICKNODE_ENDPOINT_NAME}.quiknode.pro/${REACT_APP_QUICKNODE_ENDPOINT_TOKEN}\" 8545'", "options": { "cwd": "{projectRoot}" }, "continuous": true }, "anvil:base": { - "command": "dotenv -- bash -c 'RUST_LOG=debug anvil --print-traces --fork-url https://${REACT_APP_QUICKNODE_ENDPOINT_NAME}.base-mainnet.quiknode.pro/${REACT_APP_QUICKNODE_ENDPOINT_TOKEN} --hardfork prague --port 8546'", + "command": "dotenv -- bash -c './scripts/start-anvil.sh \"https://${REACT_APP_QUICKNODE_ENDPOINT_NAME}.base-mainnet.quiknode.pro/${REACT_APP_QUICKNODE_ENDPOINT_TOKEN}\" 8546'", "options": { "cwd": "{projectRoot}" }, @@ -131,12 +131,13 @@ }, "defaultConfiguration": "staging" }, - "preview:e2e": { - "command": "ROLLDOWN_OPTIONS_VALIDATION=loose vite preview --port 3000", + "build:e2e": { + "command": "REACT_APP_SKIP_CSP=1 VITE_DISABLE_SOURCEMAP=true nx run web:build:production", "options": { "cwd": "{projectRoot}" }, - "continuous": true + "defaultConfiguration": "production", + "inputs": ["default", "^production", "!{projectRoot}/build"] }, "preview": { "command": "ROLLDOWN_OPTIONS_VALIDATION=loose vite preview --port 3000", @@ -154,6 +155,14 @@ "continuous": true, "dependsOn": ["build:staging:preview"] }, + "preview:e2e": { + "command": "REACT_APP_SKIP_CSP=1 VITE_DISABLE_SOURCEMAP=true ROLLDOWN_OPTIONS_VALIDATION=loose vite preview --port 3000", + "options": { + "cwd": "{projectRoot}" + }, + "continuous": true, + "dependsOn": ["build:e2e"] + }, "analyze": { "command": "source-map-explorer 'build/assets/*.js' --no-border-checks --gzip", "options": { diff --git a/apps/web/public/pools-sitemap.xml b/apps/web/public/pools-sitemap.xml index 279edd061cb..ae63050287b 100644 --- a/apps/web/public/pools-sitemap.xml +++ b/apps/web/public/pools-sitemap.xml @@ -8145,24 +8145,4 @@ 2025-03-20T21:32:51.328Z 0.8 - - https://app.uniswap.org/explore/pools/ethereum/0xa3ccaf08a54cf31649f91ae1570a0720c8d4eb1e - 2025-10-24T19:06:27.459Z - 0.8 - - - https://app.uniswap.org/explore/pools/arbitrum/0xd13040d4fe917ee704158cfcb3338dcd2838b245 - 2025-10-24T19:06:27.459Z - 0.8 - - - https://app.uniswap.org/explore/pools/bnb/0x75c5fbf77c1cd517544487aca4cc41e1ad95aced - 2025-10-24T19:06:27.459Z - 0.8 - - - https://app.uniswap.org/explore/pools/bnb/0x8c5a402ede3a33998604c8ba5fe6510896cb3821 - 2025-10-24T19:06:27.459Z - 0.8 - \ No newline at end of file diff --git a/apps/web/public/tokens-sitemap.xml b/apps/web/public/tokens-sitemap.xml index a2201a012ea..72c1eed8bad 100644 --- a/apps/web/public/tokens-sitemap.xml +++ b/apps/web/public/tokens-sitemap.xml @@ -3380,94 +3380,4 @@ 2025-03-20T21:28:30.528Z 0.8 - - https://app.uniswap.org/explore/tokens/solana/METvsvVRapdj9cFLzq4Tr43xK4tAjQfwX76z3n6mWQL - 2025-10-24T19:06:27.459Z - 0.8 - - - https://app.uniswap.org/explore/tokens/solana/Dfh5DzRgSvvCFDoYc2ciTkMrbDfRKybA4SoFbPmApump - 2025-10-24T19:06:27.459Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0x815269d17c10f0f3df7249370e0c1b9efe781aa8 - 2025-10-24T19:06:27.459Z - 0.8 - - - https://app.uniswap.org/explore/tokens/solana/SarosY6Vscao718M4A778z4CGtvcwcGef5M9MEH1LGL - 2025-10-24T19:06:27.459Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0x93d6afa0e6f11f4f7e9521ec6243f839526af7a6 - 2025-10-24T19:06:27.459Z - 0.8 - - - https://app.uniswap.org/explore/tokens/bnb/0x4c9027e10c5271efca82379d3123917ae3f2374e - 2025-10-24T19:06:27.459Z - 0.8 - - - https://app.uniswap.org/explore/tokens/solana/USDSwr9ApdHk5bvJKMjzff41FfuX8bSxdKcR81vTwcA - 2025-10-24T19:06:27.459Z - 0.8 - - - https://app.uniswap.org/explore/tokens/solana/SW1TCHLmRGTfW5xZknqQdpdarB8PD95sJYWpNp9TbFx - 2025-10-24T19:06:27.459Z - 0.8 - - - https://app.uniswap.org/explore/tokens/solana/3wPQhXYqy861Nhoc4bahtpf7G3e89XCLfZ67ptEfZUSA - 2025-10-24T19:06:27.459Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0xdcaa5e062b2be18e52ea6ed7ba232538621ddc10 - 2025-10-24T19:06:27.459Z - 0.8 - - - https://app.uniswap.org/explore/tokens/solana/METAwkXcqyXKy1AtsSgJ8JiUHwGCafnZL38n3vYmeta - 2025-10-24T19:06:27.459Z - 0.8 - - - https://app.uniswap.org/explore/tokens/solana/oreoU2P8bN6jkk3jbaiVxYnG1dCXcYxwhwyK9jSybcp - 2025-10-24T19:06:27.459Z - 0.8 - - - https://app.uniswap.org/explore/tokens/solana/6nR8wBnfsmXfcdDr1hovJKjvFQxNSidN6XFyfAFZpump - 2025-10-24T19:06:27.459Z - 0.8 - - - https://app.uniswap.org/explore/tokens/solana/AjPzK6Sf1G27jFkFe4HViSNqMxa3JLE4D1fm6Pzouq2q - 2025-10-24T19:06:27.459Z - 0.8 - - - https://app.uniswap.org/explore/tokens/bnb/0x0a8d6c86e1bce73fe4d0bd531e1a567306836ea5 - 2025-10-24T19:06:27.459Z - 0.8 - - - https://app.uniswap.org/explore/tokens/solana/JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN - 2025-10-24T19:06:27.459Z - 0.8 - - - https://app.uniswap.org/explore/tokens/solana/pSo1f9nQXWgXibFtKf7NWYxb5enAM4qfP6UJSiXRQfL - 2025-10-24T19:06:27.459Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0xf245964bd0a73128e10c4f7c96d0664ea2e436d8 - 2025-10-24T19:06:27.459Z - 0.8 - \ No newline at end of file diff --git a/apps/web/src/assets/images/portfolio-page-promo/dark.svg b/apps/web/src/assets/images/portfolio-page-promo/dark.svg deleted file mode 100644 index 1ae6e3cb76e..00000000000 --- a/apps/web/src/assets/images/portfolio-page-promo/dark.svg +++ /dev/null @@ -1,778 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/web/src/assets/images/portfolio-page-promo/light.svg b/apps/web/src/assets/images/portfolio-page-promo/light.svg deleted file mode 100644 index 8e9c0579fe4..00000000000 --- a/apps/web/src/assets/images/portfolio-page-promo/light.svg +++ /dev/null @@ -1,778 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/web/src/assets/svg/Emblem/A.svg b/apps/web/src/assets/svg/Emblem/A.svg deleted file mode 100644 index 46c5ecdf931..00000000000 --- a/apps/web/src/assets/svg/Emblem/A.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/apps/web/src/assets/svg/Emblem/B.svg b/apps/web/src/assets/svg/Emblem/B.svg deleted file mode 100644 index 9ba0cad2077..00000000000 --- a/apps/web/src/assets/svg/Emblem/B.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/apps/web/src/assets/svg/Emblem/C.svg b/apps/web/src/assets/svg/Emblem/C.svg deleted file mode 100644 index df525ee3977..00000000000 --- a/apps/web/src/assets/svg/Emblem/C.svg +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - - - - - - - - - - diff --git a/apps/web/src/assets/svg/Emblem/D.svg b/apps/web/src/assets/svg/Emblem/D.svg deleted file mode 100644 index 6673c60e7b6..00000000000 --- a/apps/web/src/assets/svg/Emblem/D.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/apps/web/src/assets/svg/Emblem/E.svg b/apps/web/src/assets/svg/Emblem/E.svg deleted file mode 100644 index f1d262aa1fd..00000000000 --- a/apps/web/src/assets/svg/Emblem/E.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/apps/web/src/assets/svg/Emblem/F.svg b/apps/web/src/assets/svg/Emblem/F.svg deleted file mode 100644 index f7f9944dbfa..00000000000 --- a/apps/web/src/assets/svg/Emblem/F.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/apps/web/src/assets/svg/Emblem/G.svg b/apps/web/src/assets/svg/Emblem/G.svg deleted file mode 100644 index 44e41f65357..00000000000 --- a/apps/web/src/assets/svg/Emblem/G.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/apps/web/src/assets/svg/Emblem/default.svg b/apps/web/src/assets/svg/Emblem/default.svg deleted file mode 100644 index 1839d363511..00000000000 --- a/apps/web/src/assets/svg/Emblem/default.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/apps/web/src/components/ActivityTable/ActivityAddressCell.tsx b/apps/web/src/components/ActivityTable/ActivityAddressCell.tsx deleted file mode 100644 index b47c5acf5cd..00000000000 --- a/apps/web/src/components/ActivityTable/ActivityAddressCell.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { AddressWithAvatar } from 'components/ActivityTable/AddressWithAvatar' -import { buildActivityRowFragments } from 'components/ActivityTable/registry' -import { Flex } from 'ui/src' -import { ArrowRight } from 'ui/src/components/icons/ArrowRight' -import { TransactionDetails } from 'uniswap/src/features/transactions/types/transactionDetails' -import { getValidAddress } from 'uniswap/src/utils/addresses' - -interface ActivityAddressCellProps { - transaction: TransactionDetails -} - -export function ActivityAddressCell({ transaction }: ActivityAddressCellProps) { - const { counterparty } = buildActivityRowFragments(transaction) - - // Use counterparty from adapter if available, otherwise fall back to from address - const rawAddress = counterparty ?? transaction.from - const otherPartyAddress = rawAddress ? getValidAddress({ address: rawAddress, chainId: transaction.chainId }) : null - - return ( - - {otherPartyAddress && } - - - - - ) -} diff --git a/apps/web/src/components/ActivityTable/ActivityAmountCell.tsx b/apps/web/src/components/ActivityTable/ActivityAmountCell.tsx deleted file mode 100644 index b3cddbac36b..00000000000 --- a/apps/web/src/components/ActivityTable/ActivityAmountCell.tsx +++ /dev/null @@ -1,255 +0,0 @@ -import { buildActivityRowFragments } from 'components/ActivityTable/registry' -import { TokenAmountDisplay } from 'components/ActivityTable/TokenAmountDisplay' -import { useTranslation } from 'react-i18next' -import { Flex, Text } from 'ui/src' -import { ArrowRight } from 'ui/src/components/icons/ArrowRight' -import { useFormattedCurrencyAmountAndUSDValue } from 'uniswap/src/components/activity/hooks/useFormattedCurrencyAmountAndUSDValue' -import { CurrencyInfo } from 'uniswap/src/features/dataApi/types' -import { useLocalizationContext } from 'uniswap/src/features/language/LocalizationContext' -import { - useCurrencyInfo, - useNativeCurrencyInfo, - useWrappedNativeCurrencyInfo, -} from 'uniswap/src/features/tokens/useCurrencyInfo' -import { TransactionDetails } from 'uniswap/src/features/transactions/types/transactionDetails' -import { getSymbolDisplayText } from 'uniswap/src/utils/currency' -import { NumberType } from 'utilities/src/format/types' - -interface ActivityAmountCellProps { - transaction: TransactionDetails -} - -function EmptyCell() { - return ( - - — - - ) -} - -interface DualTokenLayoutProps { - inputCurrency: CurrencyInfo | null | undefined - outputCurrency: CurrencyInfo | null | undefined - inputFormattedAmount: string | null - outputFormattedAmount: string | null - inputUsdValue: string | null - outputUsdValue: string | null - separator?: React.ReactNode -} - -function Separator({ children }: { children: React.ReactNode }) { - return ( - - {typeof children === 'string' ? ( - - {children} - - ) : ( - children - )} - - ) -} - -function DualTokenLayout({ - inputCurrency, - outputCurrency, - inputFormattedAmount, - outputFormattedAmount, - inputUsdValue, - outputUsdValue, - separator = , -}: DualTokenLayoutProps) { - return ( - - - {separator} - - - ) -} - -function formatAmountWithSymbol(amount: string | undefined, symbol: string | undefined): string | null { - return amount ? `${amount}${getSymbolDisplayText(symbol)}` : null -} - -function getUsdValue(value: string | undefined): string | null { - return value !== '-' ? (value ?? null) : null -} - -export function ActivityAmountCell({ transaction }: ActivityAmountCellProps) { - const formatter = useLocalizationContext() - const { t } = useTranslation() - const { chainId } = transaction - const { amount } = buildActivityRowFragments(transaction) - - // Hook up currency info based on amount model - const inputCurrencyInfo = useCurrencyInfo(amount?.kind === 'pair' ? amount.inputCurrencyId : undefined) - const outputCurrencyInfo = useCurrencyInfo(amount?.kind === 'pair' ? amount.outputCurrencyId : undefined) - const singleCurrencyInfo = useCurrencyInfo( - amount?.kind === 'single' || amount?.kind === 'approve' ? amount.currencyId : undefined, - ) - const currency0Info = useCurrencyInfo(amount?.kind === 'liquidity-pair' ? amount.currency0Id : undefined) - const currency1Info = useCurrencyInfo(amount?.kind === 'liquidity-pair' ? amount.currency1Id : undefined) - - const nativeCurrencyInfo = useNativeCurrencyInfo(chainId) - const wrappedCurrencyInfo = useWrappedNativeCurrencyInfo(chainId) - - // Format amounts based on kind - const inputFormattedData = useFormattedCurrencyAmountAndUSDValue({ - currency: inputCurrencyInfo?.currency, - currencyAmountRaw: amount?.kind === 'pair' ? (amount.inputAmountRaw ?? '') : '', - formatter, - isApproximateAmount: false, - }) - - const outputFormattedData = useFormattedCurrencyAmountAndUSDValue({ - currency: outputCurrencyInfo?.currency, - currencyAmountRaw: amount?.kind === 'pair' ? (amount.outputAmountRaw ?? '') : '', - formatter, - isApproximateAmount: false, - }) - - const singleFormattedData = useFormattedCurrencyAmountAndUSDValue({ - currency: singleCurrencyInfo?.currency, - currencyAmountRaw: amount?.kind === 'single' ? (amount.amountRaw ?? '') : '', - formatter, - isApproximateAmount: false, - }) - - const wrapAmountRaw = amount?.kind === 'wrap' ? (amount.amountRaw ?? '') : '' - const wrapInputCurrency = amount?.kind === 'wrap' && amount.unwrapped ? wrappedCurrencyInfo : nativeCurrencyInfo - const wrapOutputCurrency = amount?.kind === 'wrap' && amount.unwrapped ? nativeCurrencyInfo : wrappedCurrencyInfo - - const wrapInputFormattedData = useFormattedCurrencyAmountAndUSDValue({ - currency: wrapInputCurrency?.currency, - currencyAmountRaw: wrapAmountRaw, - formatter, - isApproximateAmount: false, - }) - - const wrapOutputFormattedData = useFormattedCurrencyAmountAndUSDValue({ - currency: wrapOutputCurrency?.currency, - currencyAmountRaw: wrapAmountRaw, - formatter, - isApproximateAmount: false, - }) - - const currency0FormattedData = useFormattedCurrencyAmountAndUSDValue({ - currency: currency0Info?.currency, - currencyAmountRaw: amount?.kind === 'liquidity-pair' ? amount.currency0AmountRaw : '', - formatter, - isApproximateAmount: false, - }) - - const currency1FormattedData = useFormattedCurrencyAmountAndUSDValue({ - currency: currency1Info?.currency, - currencyAmountRaw: amount?.kind === 'liquidity-pair' ? (amount.currency1AmountRaw ?? '') : '', - formatter, - isApproximateAmount: false, - }) - - if (!amount) { - return - } - - // Guard against missing currency data before formatting - if (amount.kind === 'pair' && (!inputCurrencyInfo || !outputCurrencyInfo)) { - return - } - - if (amount.kind === 'liquidity-pair' && (!currency0Info || !currency1Info)) { - return - } - - switch (amount.kind) { - case 'pair': { - // Dual token layout for swaps and bridges: Token1 → Token2 - return ( - - ) - } - - case 'approve': { - // Single token layout for approvals - let formattedAmount: string | null = null - - if (singleCurrencyInfo && amount.approvalAmount !== undefined) { - const amountText = - amount.approvalAmount === 'INF' - ? t('transaction.amount.unlimited') - : amount.approvalAmount && amount.approvalAmount !== '0.0' - ? formatter.formatNumberOrString({ value: amount.approvalAmount, type: NumberType.TokenNonTx }) - : '' - - formattedAmount = `${amountText ? amountText + ' ' : ''}${getSymbolDisplayText(singleCurrencyInfo.currency.symbol) ?? ''}` - } - - return - } - - case 'wrap': { - // Dual token layout for wraps: ETH ↔ WETH - return ( - - ) - } - - case 'single': { - // Single token layout for transfers - return ( - - ) - } - - case 'liquidity-pair': { - // Dual token layout for liquidity: Token0 and Token1 - return ( - - ) - } - } -} diff --git a/apps/web/src/components/ActivityTable/ActivityTable.tsx b/apps/web/src/components/ActivityTable/ActivityTable.tsx deleted file mode 100644 index 97b62e13d1d..00000000000 --- a/apps/web/src/components/ActivityTable/ActivityTable.tsx +++ /dev/null @@ -1,123 +0,0 @@ -import { createColumnHelper, Row } from '@tanstack/react-table' -import { ActivityAddressCell } from 'components/ActivityTable/ActivityAddressCell' -import { ActivityAmountCell } from 'components/ActivityTable/ActivityAmountCell' -import { TimeCell } from 'components/ActivityTable/TimeCell' -import { TransactionTypeCell } from 'components/ActivityTable/TransactionTypeCell' -import { Table } from 'components/Table' -import { Cell } from 'components/Table/Cell' -import { HeaderCell } from 'components/Table/styled' -import { memo, useMemo } from 'react' -import { useTranslation } from 'react-i18next' -import { Text } from 'ui/src' -import { TransactionDetails } from 'uniswap/src/features/transactions/types/transactionDetails' - -interface ActivityTableProps { - data: TransactionDetails[] - loading?: boolean - error?: boolean - rowWrapper?: (row: Row, content: JSX.Element) => JSX.Element -} - -function _ActivityTable({ data, loading = false, error = false, rowWrapper }: ActivityTableProps): JSX.Element { - const { t } = useTranslation() - const columnHelper = useMemo(() => createColumnHelper(), []) - const showLoadingSkeleton = loading || error - - const columns = useMemo( - () => [ - // Time Column - columnHelper.accessor('addedTime', { - header: () => ( - - - {t('portfolio.activity.table.column.time')} - - - ), - cell: (info) => { - if (showLoadingSkeleton) { - return - } - return ( - - - - ) - }, - }), - - // Type Column - columnHelper.accessor((row) => row.typeInfo.type, { - id: 'type', - header: () => ( - - - {t('portfolio.activity.table.column.type')} - - - ), - cell: (info) => { - if (showLoadingSkeleton) { - return - } - return ( - - - - ) - }, - }), - - // Amount Column - columnHelper.display({ - id: 'amount', - header: () => ( - - - {t('portfolio.activity.table.column.amount')} - - - ), - cell: (info) => { - if (showLoadingSkeleton) { - return - } - return ( - - - - ) - }, - minSize: 280, - size: 300, - }), - - // Address Column - columnHelper.display({ - id: 'address', - header: () => ( - - - {t('portfolio.activity.table.column.address')} - - - ), - cell: (info) => { - if (showLoadingSkeleton) { - return - } - return ( - - - - ) - }, - }), - ], - [t, columnHelper, showLoadingSkeleton], - ) - - return

-} - -export const ActivityTable = memo(_ActivityTable) diff --git a/apps/web/src/components/ActivityTable/AddressWithAvatar.tsx b/apps/web/src/components/ActivityTable/AddressWithAvatar.tsx deleted file mode 100644 index 4abee3d5de7..00000000000 --- a/apps/web/src/components/ActivityTable/AddressWithAvatar.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { Flex, Text } from 'ui/src' -import { Unitag } from 'ui/src/components/icons/Unitag' -import { useUnitagsAddressQuery } from 'uniswap/src/data/apiClients/unitagsApi/useUnitagsAddressQuery' -import { AccountIcon } from 'uniswap/src/features/accounts/AccountIcon' -import { useENSName } from 'uniswap/src/features/ens/api' -import { shortenAddress } from 'utilities/src/addresses' - -interface AddressWithAvatarProps { - address: Address - size?: number - showAvatar?: boolean -} - -export function AddressWithAvatar({ address, size = 20, showAvatar = true }: AddressWithAvatarProps) { - const { data: ENSName } = useENSName(address) - const { data: unitag } = useUnitagsAddressQuery({ - params: address ? { address } : undefined, - }) - const uniswapUsername = unitag?.username - - const displayName = uniswapUsername ?? ENSName ?? shortenAddress({ address }) - const hasUnitag = Boolean(uniswapUsername) - - return ( - - {showAvatar && ( - - )} - - {displayName} - - {hasUnitag && } - - ) -} diff --git a/apps/web/src/components/ActivityTable/TimeCell.tsx b/apps/web/src/components/ActivityTable/TimeCell.tsx deleted file mode 100644 index f90326cfbb8..00000000000 --- a/apps/web/src/components/ActivityTable/TimeCell.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { TableText } from 'components/Table/styled' -import { useFormattedTimeForActivity } from 'uniswap/src/components/activity/hooks/useFormattedTime' - -interface TimeCellProps { - timestamp: number -} - -export function TimeCell({ timestamp }: TimeCellProps) { - const formattedTime = useFormattedTimeForActivity(timestamp) - return ( - - {formattedTime} - - ) -} diff --git a/apps/web/src/components/ActivityTable/TokenAmountDisplay.tsx b/apps/web/src/components/ActivityTable/TokenAmountDisplay.tsx deleted file mode 100644 index ef04e9384bf..00000000000 --- a/apps/web/src/components/ActivityTable/TokenAmountDisplay.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { TableText } from 'components/Table/styled' -import { Flex } from 'ui/src' -import { CurrencyLogo } from 'uniswap/src/components/CurrencyLogo/CurrencyLogo' -import { useCurrencyInfo } from 'uniswap/src/features/tokens/useCurrencyInfo' - -interface TokenAmountDisplayProps { - currencyInfo: ReturnType - formattedAmount: string | null - usdValue: string | null -} - -export function TokenAmountDisplay({ currencyInfo, formattedAmount, usdValue }: TokenAmountDisplayProps) { - if (!currencyInfo || !formattedAmount) { - return null - } - - return ( - - - - - {formattedAmount} - - {usdValue && ( - - {usdValue} - - )} - - - ) -} diff --git a/apps/web/src/components/ActivityTable/TransactionTypeCell.tsx b/apps/web/src/components/ActivityTable/TransactionTypeCell.tsx deleted file mode 100644 index 5c3f52d05e7..00000000000 --- a/apps/web/src/components/ActivityTable/TransactionTypeCell.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { buildActivityRowFragments } from 'components/ActivityTable/registry' -import { TableText } from 'components/Table/styled' -import { getTransactionTypeFilterOptions } from 'pages/Portfolio/Activity/Filters/utils' -import { useTranslation } from 'react-i18next' -import { Flex } from 'ui/src' -import { TransactionDetails } from 'uniswap/src/features/transactions/types/transactionDetails' - -interface TransactionTypeCellProps { - transaction: TransactionDetails -} - -export function TransactionTypeCell({ transaction }: TransactionTypeCellProps) { - const { t } = useTranslation() - const { typeLabel } = buildActivityRowFragments(transaction) - - // Get the icon from the filter options based on base group - const transactionTypeOptions = getTransactionTypeFilterOptions(t) - const typeOption = typeLabel?.baseGroup ? transactionTypeOptions[typeLabel.baseGroup] : null - const IconComponent = typeOption?.icon - - // Use override label key if provided, otherwise use the base group label - const label = typeLabel?.overrideLabelKey ? t(typeLabel.overrideLabelKey) : (typeOption?.label ?? 'Transaction') - - return ( - - {IconComponent && } - {label} - - ) -} diff --git a/apps/web/src/components/ActivityTable/activityTableModels.ts b/apps/web/src/components/ActivityTable/activityTableModels.ts deleted file mode 100644 index f1fc9d10af1..00000000000 --- a/apps/web/src/components/ActivityTable/activityTableModels.ts +++ /dev/null @@ -1,61 +0,0 @@ -/** - * Models for activity table presentation layer. - * These types describe table-ready data from transaction parsers, without formatting or i18n. - * Each adapter returns raw IDs, amounts, addresses, and translation keys. - */ - -/** - * Represents the amount/token data for different transaction types - */ -type ActivityAmountModel = - | { - kind: 'pair' - inputCurrencyId: string - outputCurrencyId: string - inputAmountRaw?: string - outputAmountRaw?: string - } - | { - kind: 'single' - currencyId?: string - amountRaw?: string - } - | { - kind: 'approve' - currencyId?: string - approvalAmount?: string | 'INF' - } - | { - kind: 'wrap' - unwrapped: boolean - amountRaw?: string - } - | { - kind: 'liquidity-pair' - currency0Id: string - currency1Id: string - currency0AmountRaw: string - currency1AmountRaw?: string - } - -/** - * Represents the type label and grouping for a transaction - */ -interface ActivityTypeLabel { - /** Base group for filtering and icon mapping */ - baseGroup: 'swaps' | 'sent' | 'received' | 'deposits' | null - /** Optional override translation key for custom labels (e.g., "Wrapped"/"Unwrapped") */ - overrideLabelKey?: string -} - -/** - * Complete row data fragments for a single transaction in the activity table - */ -export interface ActivityRowFragments { - /** Amount/token data for the transaction */ - amount?: ActivityAmountModel | null - /** Counterparty address (sender/recipient/spender) */ - counterparty?: Address | null - /** Type label and grouping information */ - typeLabel?: ActivityTypeLabel | null -} diff --git a/apps/web/src/components/ActivityTable/registry.ts b/apps/web/src/components/ActivityTable/registry.ts deleted file mode 100644 index 889b6d0451a..00000000000 --- a/apps/web/src/components/ActivityTable/registry.ts +++ /dev/null @@ -1,246 +0,0 @@ -import { UNI_ADDRESSES } from '@uniswap/sdk-core' -import { ActivityRowFragments } from 'components/ActivityTable/activityTableModels' -import { AssetType } from 'uniswap/src/entities/assets' -import { TransactionDetails, TransactionType } from 'uniswap/src/features/transactions/types/transactionDetails' -import { getValidAddress } from 'uniswap/src/utils/addresses' -import { buildCurrencyId } from 'uniswap/src/utils/currencyId' - -/** - * Builds activity row fragments for a transaction by mapping from parsed typeInfo. - * Returns empty object for unsupported transaction types. - * - * @param details - The transaction details with parsed typeInfo - * @returns Activity row fragments containing amount, counterparty, and type label data - */ -export function buildActivityRowFragments(details: TransactionDetails): ActivityRowFragments { - const { typeInfo, chainId } = details - - switch (typeInfo.type) { - case TransactionType.Swap: - return { - amount: { - kind: 'pair', - inputCurrencyId: typeInfo.inputCurrencyId, - outputCurrencyId: typeInfo.outputCurrencyId, - inputAmountRaw: 'inputCurrencyAmountRaw' in typeInfo ? typeInfo.inputCurrencyAmountRaw : undefined, - outputAmountRaw: 'outputCurrencyAmountRaw' in typeInfo ? typeInfo.outputCurrencyAmountRaw : undefined, - }, - counterparty: null, - typeLabel: { - baseGroup: 'swaps', - overrideLabelKey: 'transaction.status.swap.success', - }, - } - - case TransactionType.Bridge: - return { - amount: { - kind: 'pair', - inputCurrencyId: typeInfo.inputCurrencyId, - outputCurrencyId: typeInfo.outputCurrencyId, - inputAmountRaw: 'inputCurrencyAmountRaw' in typeInfo ? typeInfo.inputCurrencyAmountRaw : undefined, - outputAmountRaw: 'outputCurrencyAmountRaw' in typeInfo ? typeInfo.outputCurrencyAmountRaw : undefined, - }, - counterparty: null, - typeLabel: { - baseGroup: 'swaps', - }, - } - - case TransactionType.Send: { - const currencyId = - typeInfo.assetType === AssetType.Currency ? buildCurrencyId(chainId, typeInfo.tokenAddress) : undefined - - return { - amount: { - kind: 'single', - currencyId, - amountRaw: typeInfo.currencyAmountRaw, - }, - counterparty: typeInfo.recipient ? getValidAddress({ address: typeInfo.recipient, chainId }) : null, - typeLabel: { - baseGroup: 'sent', - }, - } - } - - case TransactionType.Receive: { - const currencyId = - typeInfo.assetType === AssetType.Currency ? buildCurrencyId(chainId, typeInfo.tokenAddress) : undefined - - return { - amount: { - kind: 'single', - currencyId, - amountRaw: typeInfo.currencyAmountRaw, - }, - counterparty: typeInfo.sender ? getValidAddress({ address: typeInfo.sender, chainId }) : null, - typeLabel: { - baseGroup: 'received', - }, - } - } - - case TransactionType.Approve: { - const currencyId = buildCurrencyId(chainId, typeInfo.tokenAddress) - - return { - amount: { - kind: 'approve', - currencyId, - approvalAmount: typeInfo.approvalAmount, - }, - counterparty: typeInfo.spender ? getValidAddress({ address: typeInfo.spender, chainId }) : null, - typeLabel: { - baseGroup: null, - overrideLabelKey: 'common.approved', - }, - } - } - - case TransactionType.Wrap: - return { - amount: { - kind: 'wrap', - unwrapped: typeInfo.unwrapped, - amountRaw: typeInfo.currencyAmountRaw, - }, - counterparty: null, - typeLabel: { - baseGroup: 'swaps', - overrideLabelKey: typeInfo.unwrapped ? 'common.unwrapped' : 'common.wrapped', - }, - } - - case TransactionType.CreatePool: - case TransactionType.CreatePair: - return { - amount: { - kind: 'liquidity-pair', - currency0Id: typeInfo.currency0Id, - currency1Id: typeInfo.currency1Id, - currency0AmountRaw: typeInfo.currency0AmountRaw, - currency1AmountRaw: typeInfo.currency1AmountRaw, - }, - counterparty: typeInfo.dappInfo?.address - ? getValidAddress({ address: typeInfo.dappInfo.address, chainId }) - : null, - typeLabel: { - baseGroup: null, - overrideLabelKey: 'pool.create', - }, - } - - case TransactionType.LiquidityIncrease: - return { - amount: { - kind: 'liquidity-pair', - currency0Id: typeInfo.currency0Id, - currency1Id: typeInfo.currency1Id, - currency0AmountRaw: typeInfo.currency0AmountRaw, - currency1AmountRaw: typeInfo.currency1AmountRaw, - }, - counterparty: typeInfo.dappInfo?.address - ? getValidAddress({ address: typeInfo.dappInfo.address, chainId }) - : null, - typeLabel: { - baseGroup: 'deposits', - overrideLabelKey: 'common.addLiquidity', - }, - } - - case TransactionType.LiquidityDecrease: - return { - amount: { - kind: 'liquidity-pair', - currency0Id: typeInfo.currency0Id, - currency1Id: typeInfo.currency1Id, - currency0AmountRaw: typeInfo.currency0AmountRaw, - currency1AmountRaw: typeInfo.currency1AmountRaw, - }, - counterparty: typeInfo.dappInfo?.address - ? getValidAddress({ address: typeInfo.dappInfo.address, chainId }) - : null, - typeLabel: { - baseGroup: null, - overrideLabelKey: 'pool.removeLiquidity', - }, - } - - case TransactionType.NFTMint: { - const currencyId = typeInfo.purchaseCurrencyId - return { - amount: { - kind: 'single', - currencyId, - amountRaw: typeInfo.purchaseCurrencyAmountRaw, - }, - counterparty: typeInfo.dappInfo?.address - ? getValidAddress({ address: typeInfo.dappInfo.address, chainId }) - : null, - typeLabel: { - baseGroup: null, - overrideLabelKey: 'transaction.status.mint.success', - }, - } - } - - case TransactionType.CollectFees: - return { - amount: typeInfo.currency1Id - ? { - kind: 'liquidity-pair', - currency0Id: typeInfo.currency0Id, - currency1Id: typeInfo.currency1Id, - currency0AmountRaw: typeInfo.currency0AmountRaw, - currency1AmountRaw: typeInfo.currency1AmountRaw, - } - : { - kind: 'single', - currencyId: typeInfo.currency0Id, - amountRaw: typeInfo.currency0AmountRaw, - }, - counterparty: null, - typeLabel: { - baseGroup: null, - overrideLabelKey: 'transaction.status.collected.fees', - }, - } - - case TransactionType.LPIncentivesClaimRewards: { - const currencyId = buildCurrencyId(chainId, typeInfo.tokenAddress) - return { - amount: { - kind: 'single', - currencyId, - amountRaw: undefined, - }, - counterparty: null, - typeLabel: { - baseGroup: null, - overrideLabelKey: 'transaction.status.collected.fees', - }, - } - } - - case TransactionType.ClaimUni: { - const tokenAddress = UNI_ADDRESSES[chainId] - const currencyId = tokenAddress ? buildCurrencyId(chainId, tokenAddress) : undefined - return { - amount: { - kind: 'single', - currencyId, - amountRaw: typeInfo.uniAmountRaw, - }, - counterparty: getValidAddress({ address: typeInfo.recipient, chainId }), - typeLabel: { - baseGroup: null, - overrideLabelKey: 'common.claimed', - }, - } - } - - default: - return {} - } -} diff --git a/apps/web/src/components/Banner/BridgingPopularTokens/BridgingPopularTokensBanner.tsx b/apps/web/src/components/Banner/BridgingPopularTokens/BridgingPopularTokensBanner.tsx deleted file mode 100644 index 2957dd9ef47..00000000000 --- a/apps/web/src/components/Banner/BridgingPopularTokens/BridgingPopularTokensBanner.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import { SharedEventName } from '@uniswap/analytics-events' -import { useCallback } from 'react' -import { useTranslation } from 'react-i18next' -import { useNavigate } from 'react-router' -import { useAppDispatch } from 'state/hooks' -import { Flex, IconButton, Image, styled, Text, TouchableArea } from 'ui/src' -import { BRIDGED_ASSETS_V2_WEB_BANNER } from 'ui/src/assets' -import { X } from 'ui/src/components/icons/X' -import { useUniswapContext } from 'uniswap/src/contexts/UniswapContext' -import { setHasDismissedBridgedAssetsBannerV2 } from 'uniswap/src/features/behaviorHistory/slice' -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 { Trace } from 'uniswap/src/features/telemetry/Trace' - -const BRIDGING_POPULAR_TOKENS_BANNER_HEIGHT = 152 -const GRADIENT_BACKGROUND_HEIGHT = 64 -const BANNER_PADDING = 16 - -const BannerContainer = styled(TouchableArea, { - borderRadius: '$rounded16', - width: 260, - height: BRIDGING_POPULAR_TOKENS_BANNER_HEIGHT, - shadowColor: '$shadowColor', - shadowOffset: { width: 0, height: 4 }, - shadowOpacity: 0.4, - shadowRadius: 10, - overflow: 'hidden', - padding: BANNER_PADDING, - backgroundColor: '$surface1', - borderWidth: 1, - borderColor: '$surface3', - gap: '$spacing16', - - '$platform-web': { - position: 'fixed', - bottom: 29, - left: 40, - }, -}) - -export function BridgingPopularTokensBanner() { - const dispatch = useAppDispatch() - const { t } = useTranslation() - const navigate = useNavigate() - const { setIsSwapTokenSelectorOpen, setSwapOutputChainId } = useUniswapContext() - - const handleBannerClose = useCallback(() => { - dispatch(setHasDismissedBridgedAssetsBannerV2(true)) - sendAnalyticsEvent(SharedEventName.ELEMENT_CLICKED, { - element: ElementName.CloseButton, - modal: ElementName.BridgedAssetsBannerV2, - }) - }, [dispatch]) - - const handleBannerClick = useCallback(() => { - navigate('/swap?outputChain=unichain') - setSwapOutputChainId(UniverseChainId.Unichain) - setIsSwapTokenSelectorOpen(true) - dispatch(setHasDismissedBridgedAssetsBannerV2(true)) - }, [dispatch, navigate, setIsSwapTokenSelectorOpen, setSwapOutputChainId]) - - return ( - - - - - - - - - {t('onboarding.home.intro.bridgedAssets.title')} - - - {t('bridgingPopularTokens.banner.description')} - - - - - ) -} - -function BannerXButton({ handleClose }: { handleClose: () => void }) { - return ( - - { - e.stopPropagation() - handleClose() - }} - hoverStyle={{ opacity: 0.8 }} - icon={} - p={2} - /> - - ) -} diff --git a/apps/web/src/components/Banner/shared/OutageBanners.tsx b/apps/web/src/components/Banner/shared/OutageBanners.tsx index 5e16d747a68..0d38876ea9f 100644 --- a/apps/web/src/components/Banner/shared/OutageBanners.tsx +++ b/apps/web/src/components/Banner/shared/OutageBanners.tsx @@ -1,25 +1,23 @@ -import { manualChainOutageAtom, useChainOutageConfig } from 'featureFlags/flags/outageBanner' -import { FeatureFlags, useFeatureFlag } from '@universe/gating' -import { BridgingPopularTokensBanner } from 'components/Banner/BridgingPopularTokens/BridgingPopularTokensBanner' import { getOutageBannerSessionStorageKey, OutageBanner } from 'components/Banner/Outage/OutageBanner' -import { SOLANA_PROMO_BANNER_STORAGE_KEY, SolanaPromoBanner } from 'components/Banner/SolanaPromo/SolanaPromoBanner' +import { useChainOutageConfig } from 'hooks/useChainOutageConfig' import { useAtomValue } from 'jotai/utils' import { useMemo } from 'react' import { useLocation } from 'react-router' -import { useAppSelector } from 'state/hooks' +import { manualChainOutageAtom } from 'state/outage/atoms' import { UniverseChainId } from 'uniswap/src/features/chains/types' import { InterfacePageName } from 'uniswap/src/features/telemetry/constants' import { getChainIdFromChainUrlParam, isChainUrlParam } from 'utils/chainParams' import { getCurrentPageFromLocation } from 'utils/urlRoutes' -export function Banners() { +/** + * OutageBanners component handles displaying outage banners in the bottom-right corner. + * + * Note: This component only handles OutageBanner. SolanaPromoBanner and BridgingPopularTokensBanner + * have been migrated to the notification system (see createBannersNotificationDataSource). + */ +export function OutageBanners() { const { pathname } = useLocation() const currentPage = getCurrentPageFromLocation(pathname) - const isSolanaPromoEnabled = useFeatureFlag(FeatureFlags.SolanaPromo) - const isBridgedAssetsBannerV2Enabled = useFeatureFlag(FeatureFlags.BridgedAssetsBannerV2) - const hasDismissedBridgedAssetsBannerV2 = useAppSelector( - (state) => state.uniswapBehaviorHistory.hasDismissedBridgedAssetsBannerV2, - ) // Read from both sources: error-detected (from GraphQL failures) and Statsig (manual config) const statsigOutage = useChainOutageConfig() @@ -50,19 +48,9 @@ export function Banners() { ) }, [currentPage, currentPageHasOutage, pageChainId]) - // Outage Banners should take precedence over other promotional banners if (pageChainId && showOutageBanner) { return } - const userAlreadySeenSolanaPromo = localStorage.getItem(SOLANA_PROMO_BANNER_STORAGE_KEY) === 'true' - if (isSolanaPromoEnabled && !userAlreadySeenSolanaPromo) { - return - } - - if (isBridgedAssetsBannerV2Enabled && !hasDismissedBridgedAssetsBannerV2) { - return - } - return null } diff --git a/apps/web/src/components/Charts/LiquidityChart/index.tsx b/apps/web/src/components/Charts/LiquidityChart/index.tsx index b5aff745efb..87d1db3ac47 100644 --- a/apps/web/src/components/Charts/LiquidityChart/index.tsx +++ b/apps/web/src/components/Charts/LiquidityChart/index.tsx @@ -1,21 +1,20 @@ -import { BigNumber } from '@ethersproject/bignumber' import { ProtocolVersion } from '@uniswap/client-data-api/dist/data/v1/poolTypes_pb' -import { Currency, CurrencyAmount, Token } from '@uniswap/sdk-core' -import { FeeAmount, Pool as PoolV3, TICK_SPACINGS, TickMath as TickMathV3, tickToPrice } from '@uniswap/v3-sdk' -import { Pool as PoolV4, tickToPrice as tickToPriceV4 } from '@uniswap/v4-sdk' +import { Currency } from '@uniswap/sdk-core' +import { FeeAmount, TICK_SPACINGS, tickToPrice } from '@uniswap/v3-sdk' +import { tickToPrice as tickToPriceV4 } from '@uniswap/v4-sdk' import { ChartHoverData, ChartModel, ChartModelParams } from 'components/Charts/ChartModel' import { LiquidityBarSeries } from 'components/Charts/LiquidityChart/liquidity-bar-series' import { LiquidityBarData, LiquidityBarProps, LiquidityBarSeriesOptions } from 'components/Charts/LiquidityChart/types' +import { calculateAnchoredLiquidityByTick } from 'components/Charts/LiquidityChart/utils/calculateAnchoredLiquidityByTick' +import { calculateTokensLocked } from 'components/Charts/LiquidityChart/utils/calculateTokensLocked' import { usePoolActiveLiquidity } from 'hooks/usePoolTickData' import JSBI from 'jsbi' import { ISeriesApi, UTCTimestamp } from 'lightweight-charts' import { useEffect, useState } from 'react' import { PositionField } from 'types/position' -import { ZERO_ADDRESS } from 'uniswap/src/constants/misc' import { UniverseChainId } from 'uniswap/src/features/chains/types' import { useLocalizationContext } from 'uniswap/src/features/language/LocalizationContext' import { NumberType } from 'utilities/src/format/types' -import { TickProcessed } from 'utils/computeSurroundingTicks' interface LiquidityBarChartModelParams extends ChartModelParams, LiquidityBarProps {} @@ -90,7 +89,14 @@ export class LiquidityBarChartModel extends ChartModel { lastValueVisible: false, }) - this.series.applyOptions(params) + const seriesOptions: Partial = { + tokenAColor: params.tokenAColor, + tokenBColor: params.tokenBColor, + highlightColor: params.highlightColor, + activeTick: params.activeTick, + activeTickProgress: params.activeTickProgress, + } + this.series.applyOptions(seriesOptions) } override onSeriesHover(hoverData?: ChartHoverData) { @@ -114,187 +120,6 @@ export class LiquidityBarChartModel extends ChartModel { } } -const MAX_UINT128 = BigNumber.from(2).pow(128).sub(1) - -function maxAmount(token: Token) { - return CurrencyAmount.fromRawAmount(token, MAX_UINT128.toString()) -} - -/** Calculates tokens locked in the active tick range based on the current tick */ -// TODO(WEB-7564): determine how to support v4 -async function calculateActiveRangeTokensLocked({ - token0, - token1, - feeTier, - tick, - poolData, -}: { - token0: Token - token1: Token - feeTier: FeeAmount - tick: TickProcessed - poolData: { - sqrtPriceX96?: JSBI - currentTick?: number - liquidity?: JSBI - } -}): Promise<{ amount0Locked: number; amount1Locked: number } | undefined> { - if (!poolData.currentTick || !poolData.sqrtPriceX96 || !poolData.liquidity) { - return undefined - } - - try { - const liqGross = JSBI.greaterThan(tick.liquidityNet, JSBI.BigInt(0)) - ? tick.liquidityNet - : JSBI.multiply(tick.liquidityNet, JSBI.BigInt('-1')) - - const mockTicks = [ - { - index: tick.tick, - liquidityGross: liqGross, - liquidityNet: JSBI.multiply(tick.liquidityNet, JSBI.BigInt('-1')), - }, - { - index: tick.tick + TICK_SPACINGS[feeTier], - liquidityGross: liqGross, - liquidityNet: tick.liquidityNet, - }, - ] - // Initialize pool containing only the active range - const pool1 = new PoolV3( - token0, - token1, - feeTier, - poolData.sqrtPriceX96, - tick.liquidityActive, - poolData.currentTick, - mockTicks, - ) - // Calculate amount of token0 that would need to be swapped to reach the bottom of the range - const bottomOfRangePrice = TickMathV3.getSqrtRatioAtTick(mockTicks[0].index) - const token1Amount = (await pool1.getOutputAmount(maxAmount(token0), bottomOfRangePrice))[0] - const amount0Locked = parseFloat(tick.sdkPrice.invert().quote(token1Amount).toExact()) - - // Calculate amount of token1 that would need to be swapped to reach the top of the range - const topOfRangePrice = TickMathV3.getSqrtRatioAtTick(mockTicks[1].index) - const token0Amount = (await pool1.getOutputAmount(maxAmount(token1), topOfRangePrice))[0] - const amount1Locked = parseFloat(tick.sdkPrice.quote(token0Amount).toExact()) - - return { amount0Locked, amount1Locked } - } catch { - return { amount0Locked: 0, amount1Locked: 0 } - } -} - -/** Returns amounts of tokens locked in the given tick. Reference: https://docs.uniswap.org/sdk/v3/guides/advanced/active-liquidity */ -export async function calculateTokensLockedV3({ - token0, - token1, - feeTier, - tick, -}: { - token0: Token - token1: Token - feeTier: FeeAmount - tick: TickProcessed -}): Promise<{ amount0Locked: number; amount1Locked: number }> { - try { - const tickSpacing = TICK_SPACINGS[feeTier] - const liqGross = JSBI.greaterThan(tick.liquidityNet, JSBI.BigInt(0)) - ? tick.liquidityNet - : JSBI.multiply(tick.liquidityNet, JSBI.BigInt('-1')) - - const sqrtPriceX96 = TickMathV3.getSqrtRatioAtTick(tick.tick) - const mockTicks = [ - { - index: tick.tick, - liquidityGross: liqGross, - liquidityNet: JSBI.multiply(tick.liquidityNet, JSBI.BigInt('-1')), - }, - { - index: tick.tick + TICK_SPACINGS[feeTier], - liquidityGross: liqGross, - liquidityNet: tick.liquidityNet, - }, - ] - - // Initialize pool containing only the current range - const pool = new PoolV3(token0, token1, Number(feeTier), sqrtPriceX96, tick.liquidityActive, tick.tick, mockTicks) - - // Calculate token amounts that would need to be swapped to reach the next range - const nextSqrtX96 = TickMathV3.getSqrtRatioAtTick(tick.tick - tickSpacing) - const maxAmountToken0 = CurrencyAmount.fromRawAmount(token0, MAX_UINT128.toString()) - const token1Amount = (await pool.getOutputAmount(maxAmountToken0, nextSqrtX96))[0] - const amount0Locked = parseFloat(tick.sdkPrice.invert().quote(token1Amount).toExact()) - const amount1Locked = parseFloat(token1Amount.toExact()) - - return { amount0Locked, amount1Locked } - } catch { - return { amount0Locked: 0, amount1Locked: 0 } - } -} - -// TODO(WEB-7564): determine if tick math needs to be converted to support v4 -export async function calculateTokensLockedV4({ - token0, - token1, - feeTier, - tickSpacing, - hooks, - tick, -}: { - token0: Currency - token1: Currency - feeTier: FeeAmount - tickSpacing: number - hooks: string - tick: TickProcessed -}): Promise<{ amount0Locked: number; amount1Locked: number }> { - try { - const liqGross = JSBI.greaterThan(tick.liquidityNet, JSBI.BigInt(0)) - ? tick.liquidityNet - : JSBI.multiply(tick.liquidityNet, JSBI.BigInt('-1')) - - const sqrtPriceX96 = TickMathV3.getSqrtRatioAtTick(tick.tick) - const mockTicks = [ - { - index: tick.tick, - liquidityGross: liqGross, - liquidityNet: JSBI.multiply(tick.liquidityNet, JSBI.BigInt('-1')), - }, - { - index: tick.tick + tickSpacing, - liquidityGross: liqGross, - liquidityNet: tick.liquidityNet, - }, - ] - - // Initialize pool containing only the current range - const pool = new PoolV4( - token0, - token1, - Number(feeTier), - tickSpacing, - hooks, - sqrtPriceX96, - tick.liquidityActive, - tick.tick, - mockTicks, - ) - - // Calculate token amounts that would need to be swapped to reach the next range - const nextSqrtX96 = TickMathV3.getSqrtRatioAtTick(tick.tick - tickSpacing) - const maxAmountToken0 = CurrencyAmount.fromRawAmount(token0, MAX_UINT128.toString()) - const token1Amount = (await pool.getOutputAmount(maxAmountToken0, nextSqrtX96))[0] - const amount0Locked = parseFloat(tick.sdkPrice.invert().quote(token1Amount).toExact()) - const amount1Locked = parseFloat(token1Amount.toExact()) - - return { amount0Locked, amount1Locked } - } catch { - return { amount0Locked: 0, amount1Locked: 0 } - } -} - export function useLiquidityBarData({ sdkCurrencies, feeTier, @@ -332,17 +157,21 @@ export function useLiquidityBarData({ activeRangePercentage?: number }>() - const { data: ticksProcessed, activeTick, currentTick, liquidity, sqrtPriceX96 } = activePoolData + const { data: ticksProcessed, activeTick, currentTick, liquidity } = activePoolData useEffect(() => { async function formatData() { - if (!ticksProcessed) { + if (!ticksProcessed || activeTick === undefined || !liquidity) { return } let activeRangePercentage: number | undefined let activeRangeIndex: number | undefined + // Calculate anchored active liquidity per tick + const activeLiquidityByTick = calculateAnchoredLiquidityByTick({ ticksProcessed, activeTick, liquidity }) + const poolTickSpacing = tickSpacing ?? TICK_SPACINGS[feeTier] + const barData: LiquidityBarData[] = [] for (let index = 0; index < ticksProcessed.length; index++) { const t = ticksProcessed[index] @@ -354,9 +183,9 @@ export function useLiquidityBarData({ let price0 = t.sdkPrice let price1 = t.sdkPrice.invert() - if (isActive && activeTick && currentTick) { + if (isActive && currentTick !== undefined) { activeRangeIndex = index - activeRangePercentage = (currentTick - t.tick) / TICK_SPACINGS[feeTier] + activeRangePercentage = 1 - (currentTick - t.tick) / poolTickSpacing price0 = version === ProtocolVersion.V3 @@ -365,21 +194,14 @@ export function useLiquidityBarData({ price1 = price0.invert() } - const { amount0Locked, amount1Locked } = await (version === ProtocolVersion.V3 - ? calculateTokensLockedV3({ - token0: sdkCurrencies.TOKEN0.wrapped, - token1: sdkCurrencies.TOKEN1.wrapped, - feeTier, - tick: t, - }) - : calculateTokensLockedV4({ - token0: sdkCurrencies.TOKEN0, - token1: sdkCurrencies.TOKEN1, - feeTier, - tickSpacing: tickSpacing ?? TICK_SPACINGS[feeTier], - hooks: hooks ?? ZERO_ADDRESS, - tick: t, - })) + const { amount0Locked, amount1Locked } = calculateTokensLocked({ + token0: sdkCurrencies.TOKEN0, + token1: sdkCurrencies.TOKEN1, + tickSpacing: poolTickSpacing, + currentTick: currentTick ?? 0, + amount: activeLiquidityByTick.get(t.tick) ?? JSBI.BigInt(0), + tick: t, + }) barData.push({ tick: t.tick, @@ -392,34 +214,13 @@ export function useLiquidityBarData({ }) } - // offset the values to line off bars with TVL used to swap across bar - barData.map((entry, i) => { - if (i > 0) { - barData[i - 1].amount0Locked = entry.amount0Locked - barData[i - 1].amount1Locked = entry.amount1Locked - } - }) - const activeRangeData = activeRangeIndex !== undefined ? barData[activeRangeIndex] : undefined - // For active range, adjust amounts locked to adjust for where current tick/price is within the range - if (activeRangeIndex !== undefined && activeRangeData) { - const activeTickTvl = await calculateActiveRangeTokensLocked({ - token0: sdkCurrencies.TOKEN0.wrapped, - token1: sdkCurrencies.TOKEN1.wrapped, - feeTier, - tick: ticksProcessed[activeRangeIndex], - poolData: { currentTick, liquidity, sqrtPriceX96 }, - }) - barData[activeRangeIndex] = { ...activeRangeData, ...activeTickTvl } - } // Reverse data so that token0 is on the left by default if (!isReversed) { barData.reverse() } - - // TODO(WEB-3672): investigate why negative/inaccurate liquidity values that are appearing from computeSurroundingTicks - setTickData({ barData: barData.filter((t) => t.liquidity > 0), activeRangeData, activeRangePercentage }) + setTickData({ barData, activeRangeData, activeRangePercentage }) } formatData() @@ -428,14 +229,12 @@ export function useLiquidityBarData({ activeTick, currentTick, liquidity, - sqrtPriceX96, sdkCurrencies, formatNumberOrString, isReversed, feeTier, version, tickSpacing, - hooks, ]) return { tickData, activeTick: activePoolData.activeTick, loading: activePoolData.isLoading || !tickData } diff --git a/apps/web/src/components/Charts/LiquidityPositionRangeChart/LiquidityPositionRangeChart.tsx b/apps/web/src/components/Charts/LiquidityPositionRangeChart/LiquidityPositionRangeChart.tsx index e820c97f067..155ded1b180 100644 --- a/apps/web/src/components/Charts/LiquidityPositionRangeChart/LiquidityPositionRangeChart.tsx +++ b/apps/web/src/components/Charts/LiquidityPositionRangeChart/LiquidityPositionRangeChart.tsx @@ -1,3 +1,4 @@ +/* eslint-disable max-lines */ import { PositionStatus, ProtocolVersion } from '@uniswap/client-data-api/dist/data/v1/poolTypes_pb' import { Currency, Price } from '@uniswap/sdk-core' import { Pair } from '@uniswap/v2-sdk' @@ -264,8 +265,8 @@ export class LPPriceChartModel extends ChartModel { if (params.positionPriceLower !== undefined && params.positionPriceUpper !== undefined) { if (!this.bandIndicator) { this.bandIndicator = new BandsIndicator({ - lineColor: opacify(40, params.theme.neutral1), - fillColor: params.theme.surface3, + lineColor: opacify(40, params.colors.neutral1.val), + fillColor: params.colors.surface3.val, lineWidth: 1.5, upperValue: this.positionRangeMax, lowerValue: this.positionRangeMin, @@ -273,8 +274,8 @@ export class LPPriceChartModel extends ChartModel { this.rangeBandSeries?.attachPrimitive(this.bandIndicator) } else { this.bandIndicator.updateOptions({ - lineColor: opacify(10, params.theme.neutral1), - fillColor: params.theme.surface3, + lineColor: opacify(10, params.colors.neutral1.val), + fillColor: params.colors.surface3.val, lineWidth: 1, upperValue: this.positionRangeMax, lowerValue: this.positionRangeMin, diff --git a/apps/web/src/components/Charts/LiquidityRangeInput/LiquidityRangeInput.tsx b/apps/web/src/components/Charts/LiquidityRangeInput/LiquidityRangeInput.tsx index 6ad03dc900c..313b17d8e48 100644 --- a/apps/web/src/components/Charts/LiquidityRangeInput/LiquidityRangeInput.tsx +++ b/apps/web/src/components/Charts/LiquidityRangeInput/LiquidityRangeInput.tsx @@ -1,3 +1,4 @@ +/* eslint-disable max-lines */ import { ProtocolVersion } from '@uniswap/client-data-api/dist/data/v1/poolTypes_pb' import { Currency } from '@uniswap/sdk-core' import { GraphQLApi } from '@universe/api' @@ -212,7 +213,7 @@ export function LiquidityRangeInput({ } }, [dataMax, dataMin, midPrice, currentPrice, priceData.entries, zoomFactor]) - const chartModelRef = useRef() + const chartModelRef = useRef(undefined) const containerRef = useRef(null) const sizes = useRangeInputSizes(containerRef.current?.clientWidth) diff --git a/apps/web/src/components/Charts/PriceChart/index.tsx b/apps/web/src/components/Charts/PriceChart/index.tsx index 2313ca3d480..0862f8f1d2e 100644 --- a/apps/web/src/components/Charts/PriceChart/index.tsx +++ b/apps/web/src/components/Charts/PriceChart/index.tsx @@ -34,6 +34,8 @@ export type PriceChartData = CandlestickData & AreaData { type: PriceChartType timePeriod?: GraphQLApi.HistoryDuration + hideYAxis?: boolean + yAxisFormatter?: (price: number) => string } const LOW_PRICE_RANGE_THRESHOLD = 0.2 @@ -51,6 +53,64 @@ export class PriceChartModel extends ChartModel { private min: number private max: number + /** + * Gets the screen coordinates for the last data point + * Returns null for candlestick charts since they don't need a live dot indicator + */ + getLastPointCoordinates(): { x: number; y: number } | null { + // Only show live dot for line charts + if (this.type === PriceChartType.CANDLESTICK) { + return null + } + + if (this.data.length === 0) { + return null + } + + const lastDataPoint = this.data[this.data.length - 1] + const xCoordinate = this.api.timeScale().timeToCoordinate(lastDataPoint.time) + const yCoordinate = this.series.priceToCoordinate((lastDataPoint as AreaData).value) + + if (xCoordinate == null || yCoordinate == null) { + return null + } + + return { + x: Number(xCoordinate) + this.api.priceScale('left').width(), + y: Number(yCoordinate), + } + } + + /** + * Gets the screen coordinates for the hovered data point on the line + * Returns null if not hovering or for candlestick charts + */ + override getHoverCoordinates(): { x: number; y: number } | null { + // Only show custom marker for line charts + if (this.type === PriceChartType.CANDLESTICK) { + return null + } + + const hoverData = (this as any)._hoverData + if (!hoverData || !hoverData.item) { + return null + } + + // Calculate x from time + const xCoordinate = this.api.timeScale().timeToCoordinate(hoverData.item.time) + // Calculate y from the data point's value (not mouse position) + const yCoordinate = this.series.priceToCoordinate((hoverData.item as AreaData).value) + + if (xCoordinate == null || yCoordinate == null) { + return null + } + + return { + x: Number(xCoordinate) + this.api.priceScale('left').width(), + y: Number(yCoordinate), + } + } + constructor(chartDiv: HTMLDivElement, params: PriceChartModelParams) { super(chartDiv, params) this.originalData = this.data @@ -99,7 +159,7 @@ export class PriceChartModel extends ChartModel { } updateOptions(params: PriceChartModelParams) { - const { data, theme, type, locale, format, tokenFormatType } = params + const { data, colors, type, locale, format, tokenFormatType, hideYAxis, yAxisFormatter } = params const { min, max } = getCandlestickPriceBounds(data) // Handles changes in time period @@ -121,28 +181,30 @@ export class PriceChartModel extends ChartModel { localization: { locale, priceFormatter: (price: BarPrice) => { + // Transform price back to original value if it was scaled + const originalPrice = Number(price) / this.lowPriceRangeScaleFactor + + // Use custom y-axis formatter if provided + if (yAxisFormatter) { + return yAxisFormatter(originalPrice) + } + if (tokenFormatType) { return format.formatNumberOrString({ - value: Number(price) / this.lowPriceRangeScaleFactor, + value: originalPrice, type: tokenFormatType, }) } - return format.convertFiatAmountFormatted( - // Transform price back to original value if it was scaled - Number(price) / this.lowPriceRangeScaleFactor, - NumberType.FiatTokenPrice, - ) + return format.convertFiatAmountFormatted(originalPrice, NumberType.FiatTokenPrice) }, }, - grid: { - vertLines: { style: LineStyle.CustomDotGrid, color: theme.neutral3 }, - horzLines: { style: LineStyle.CustomDotGrid, color: theme.neutral3 }, - }, - ...(scaleMargins && { - rightPriceScale: { + rightPriceScale: { + borderVisible: false, + ...(hideYAxis && { visible: false, minimumWidth: 0 }), + ...(scaleMargins && { scaleMargins, - }, - }), + }), + }, }) // Handles changing between line/candlestick view @@ -169,8 +231,8 @@ export class PriceChartModel extends ChartModel { this.fitContent() } - // Use theme.accent1 which will be the token color when inside TokenColorThemeProvider - const lineColor = theme.accent1 + // Use colors.accent1 which will be the token color when inside TokenColorThemeProvider + const lineColor = colors.accent1.val this.series.applyOptions({ priceLineVisible: false, @@ -180,26 +242,25 @@ export class PriceChartModel extends ChartModel { lineType: data.length < 20 ? LineType.WithSteps : LineType.Curved, // Stepped line is visually preferred for smaller datasets lineWidth: 2, lineColor, - topColor: opacify(12, lineColor), - bottomColor: opacify(12, lineColor), - crosshairMarkerRadius: 5, - crosshairMarkerBorderColor: opacify(30, lineColor), - crosshairMarkerBorderWidth: 3, + topColor: lineColor, + bottomColor: opacify(0, colors.surface1.val), + // Hide default marker - we use a custom marker instead + crosshairMarkerRadius: 0, // Candlestick-specific options: - upColor: theme.success, - wickUpColor: theme.success, - downColor: theme.critical, - wickDownColor: theme.critical, + upColor: colors.statusSuccess.val, + wickUpColor: colors.statusSuccess.val, + downColor: colors.statusCritical.val, + wickDownColor: colors.statusCritical.val, borderVisible: false, } as Partial & AreaSeriesPartialOptions) this.priceLineOptions = { - color: theme.surface3, + color: colors.surface3.val, lineWidth: 2, lineStyle: LineStyle.Dashed, - axisLabelColor: theme.surface3Solid, - axisLabelTextColor: theme.neutral1, + axisLabelColor: colors.surface3Solid.val, + axisLabelTextColor: colors.neutral1.val, } this.minPriceLine?.applyOptions({ price: this.min, ...this.priceLineOptions }) this.maxPriceLine?.applyOptions({ price: this.max, ...this.priceLineOptions }) @@ -236,6 +297,10 @@ interface PriceChartDeltaProps { noColor?: boolean shouldIncludeFiatDelta?: boolean shouldTreatAsStablecoin?: boolean + /** Optional price change % for the selected duration (used when not hovering) */ + pricePercentChange?: number + /** Whether the user is currently hovering over the chart */ + isHovering?: boolean } export function PriceChartDelta({ @@ -244,11 +309,17 @@ export function PriceChartDelta({ noColor, shouldIncludeFiatDelta = false, shouldTreatAsStablecoin = false, + pricePercentChange, + isHovering = false, }: PriceChartDeltaProps) { const { formatPercent, convertFiatAmount } = useLocalizationContext() const { formatChartFiatDelta } = useFormatChartFiatDelta() - const delta = calculateDelta(startingPrice, endingPrice) + // When not hovering and we have a percent change, use it + // When hovering, calculate change from starting price to current hover point + const calculatedDelta = calculateDelta(startingPrice, endingPrice) + const delta = !isHovering && pricePercentChange !== undefined ? pricePercentChange : calculatedDelta + const formattedDelta = useMemo(() => { return delta !== undefined ? formatPercent(Math.abs(delta)) : '-' }, [delta, formatPercent]) @@ -258,6 +329,22 @@ export function PriceChartDelta({ return null } + // When using percent change (not hovering), calculate fiat delta from that percentage + // This avoids mixing aggregated chart prices with per-chain current prices + if (!isHovering && pricePercentChange !== undefined) { + const convertedEnd = convertFiatAmount(endingPrice) + const percentAsDecimal = pricePercentChange / 100 + const historicalPrice = convertedEnd.amount / (1 + percentAsDecimal) + const fiatChange = convertedEnd.amount - historicalPrice + + return formatChartFiatDelta({ + startingPrice: convertedEnd.amount - fiatChange, + endingPrice: convertedEnd.amount, + isStablecoin: shouldTreatAsStablecoin, + }) + } + + // When hovering, use chart prices for consistent calculation const convertedStart = convertFiatAmount(startingPrice) const convertedEnd = convertFiatAmount(endingPrice) @@ -273,6 +360,8 @@ export function PriceChartDelta({ endingPrice, convertFiatAmount, shouldTreatAsStablecoin, + pricePercentChange, + isHovering, ]) return ( @@ -289,6 +378,11 @@ interface PriceChartProps { data: PriceChartData[] stale: boolean timePeriod?: GraphQLApi.HistoryDuration + pricePercentChange?: number + overrideColor?: string + headerTotalValueOverride?: number + hideYAxis?: boolean + yAxisFormatter?: (price: number) => string } const CandlestickTooltipRow = styled(Flex, { @@ -323,7 +417,18 @@ function CandlestickTooltip({ data }: { data: PriceChartData }) { ) } -export function PriceChart({ data, height, type, stale, timePeriod }: PriceChartProps) { +export function PriceChart({ + data, + height, + type, + stale, + timePeriod, + pricePercentChange, + overrideColor, + headerTotalValueOverride, + hideYAxis, + yAxisFormatter, +}: PriceChartProps) { const startingPrice = data[0] const lastPrice = data[data.length - 1] const { min, max } = getCandlestickPriceBounds(data) @@ -336,25 +441,39 @@ export function PriceChart({ data, height, type, stale, timePeriod }: PriceChart return ( ({ data, type, stale, timePeriod }), [data, stale, type, timePeriod])} + params={useMemo( + () => ({ data, type, stale, timePeriod, hideYAxis, yAxisFormatter }), + [data, stale, type, timePeriod, hideYAxis, yAxisFormatter], + )} height={height} + overrideColor={overrideColor} TooltipBody={type === PriceChartType.CANDLESTICK ? CandlestickTooltip : undefined} + showDottedBackground={true} + showLeftFadeOverlay={type === PriceChartType.LINE} + showCustomHoverMarker={type === PriceChartType.LINE} > - {(crosshairData) => ( - - } - valueFormatterType={NumberType.FiatTokenPrice} - time={crosshairData?.time} - /> - )} + {(crosshairData) => { + // Use override value when provided, otherwise use chart data value + const headerValue = crosshairData ? crosshairData.value : (headerTotalValueOverride ?? lastPrice.value) + + return ( + + } + valueFormatterType={NumberType.FiatTokenPrice} + time={crosshairData?.time} + /> + ) + }} ) } diff --git a/apps/web/src/components/FeatureFlagModal/FeatureFlagModal.tsx b/apps/web/src/components/FeatureFlagModal/FeatureFlagModal.tsx index 0b3d9913486..a582c727d93 100644 --- a/apps/web/src/components/FeatureFlagModal/FeatureFlagModal.tsx +++ b/apps/web/src/components/FeatureFlagModal/FeatureFlagModal.tsx @@ -8,19 +8,23 @@ import { getOverrideAdapter, Layers, NetworkRequestsConfigKey, + useDynamicConfigValue, useFeatureFlagWithExposureLoggingDisabled, } from '@universe/gating' import { useModalState } from 'hooks/useModalState' -import styledDep from 'lib/styled-components' +import { deprecatedStyled } from 'lib/styled-components' import { useExternallyConnectableExtensionId } from 'pages/ExtensionPasskeyAuthPopUp/useExternallyConnectableExtensionId' import type { ChangeEvent, PropsWithChildren } from 'react' -import { useCallback } from 'react' +import { memo } from 'react' import { Button, Flex, ModalCloseIcon, styled, Text } from 'ui/src' import { ExperimentRow, LayerRow } from 'uniswap/src/components/gating/Rows' import { Modal } from 'uniswap/src/components/modals/Modal' import { ModalName } from 'uniswap/src/features/telemetry/constants' import { isPlaywrightEnv } from 'utilities/src/environment/env' import { TRUSTED_CHROME_EXTENSION_IDS } from 'utilities/src/environment/extensionId' +import { useEvent } from 'utilities/src/react/hooks' + +const FLAG_VARIANTS = ['Enabled', 'Disabled'] as const const CenteredRow = styled(Flex, { flexDirection: 'row', @@ -41,7 +45,10 @@ interface FeatureFlagProps { flag: FeatureFlags } -function FeatureFlagGroup({ name, children }: PropsWithChildren<{ name: string }>) { +const FeatureFlagGroup = memo(function FeatureFlagGroup({ + name, + children, +}: PropsWithChildren<{ name: string }>): JSX.Element { return ( <> @@ -50,9 +57,9 @@ function FeatureFlagGroup({ name, children }: PropsWithChildren<{ name: string } {children} ) -} +}) -const FlagVariantSelection = styledDep.select` +const FlagVariantSelection = deprecatedStyled.select` border-radius: 12px; padding: 8px; background: ${({ theme }) => theme.surface3}; @@ -66,20 +73,17 @@ const FlagVariantSelection = styledDep.select` } ` -function Variant({ option }: { option: string }) { +const Variant = memo(function Variant({ option }: { option: string }): JSX.Element { return -} +}) -function FeatureFlagOption({ flag, label }: FeatureFlagProps) { +const FeatureFlagOption = memo(function FeatureFlagOption({ flag, label }: FeatureFlagProps): JSX.Element { const enabled = useFeatureFlagWithExposureLoggingDisabled(flag) const name = getFeatureFlagName(flag) - const onFlagVariantChange = useCallback( - (e: ChangeEvent) => { - getOverrideAdapter().overrideGate(name, e.target.value === 'Enabled' ? true : false) - }, - [name], - ) + const onFlagVariantChange = useEvent((e: ChangeEvent) => { + getOverrideAdapter().overrideGate(name, e.target.value === 'Enabled' ? true : false) + }) return ( @@ -90,15 +94,15 @@ function FeatureFlagOption({ flag, label }: FeatureFlagProps) { - {['Enabled', 'Disabled'].map((variant) => ( + {FLAG_VARIANTS.map((variant) => ( ))} ) -} +}) -function DynamicConfigDropdown< +const DynamicConfigDropdown = memo(function DynamicConfigDropdown< Conf extends Exclude, Key extends DynamicConfigKeys[Conf], >({ @@ -115,18 +119,16 @@ function DynamicConfigDropdown< label: string options: Array | Record selected: unknown[] - parser: (opt: string) => any + parser: (opt: string) => unknown allowMultiple?: boolean -}) { - const handleSelectChange = useCallback( - (e: React.ChangeEvent) => { - const selectedValues = Array.from(e.target.selectedOptions, (opt) => parser(opt.value)) - getOverrideAdapter().overrideDynamicConfig(config, { - [configKey]: allowMultiple ? selectedValues : selectedValues[0], - }) - }, - [allowMultiple, config, configKey, parser], - ) +}): JSX.Element { + const handleSelectChange = useEvent((e: React.ChangeEvent) => { + const selectedValues = Array.from(e.target.selectedOptions, (opt) => parser(opt.value)) + getOverrideAdapter().overrideDynamicConfig(config, { + [configKey]: allowMultiple ? selectedValues : selectedValues[0], + }) + }) + return ( @@ -135,28 +137,39 @@ function DynamicConfigDropdown< {label} - {Array.isArray(options) ? options.map((opt) => ( - )) : Object.entries(options).map(([key, value]) => ( - ))} ) -} +}) -export default function FeatureFlagModal() { +export default function FeatureFlagModal(): JSX.Element { const { isOpen, closeModal } = useModalState(ModalName.FeatureFlags) - const removeAllOverrides = () => { + const externallyConnectableExtensionId = useExternallyConnectableExtensionId() + + const removeAllOverrides = useEvent(() => { getOverrideAdapter().removeAllOverrides() - } + }) + + const handleReload = useEvent(() => { + window.location.reload() + }) + return ( @@ -170,6 +183,13 @@ export default function FeatureFlagModal() { + + + + + + + @@ -181,13 +201,13 @@ export default function FeatureFlagModal() { /> + - + @@ -224,6 +248,13 @@ export default function FeatureFlagModal() { label="Enable create flow with new PoolInfo endpoint" /> + + + + @@ -233,7 +264,7 @@ export default function FeatureFlagModal() { id} config={DynamicConfigs.ExternallyConnectableExtension} @@ -259,14 +290,7 @@ export default function FeatureFlagModal() { - + @@ -276,9 +300,27 @@ export default function FeatureFlagModal() { - + + + + + + + + + + + + + @@ -293,10 +335,30 @@ export default function FeatureFlagModal() { - ) } + +function NetworkRequestsConfig() { + const currentValue = useDynamicConfigValue({ + config: DynamicConfigs.NetworkRequests, + key: NetworkRequestsConfigKey.BalanceMaxRefetchAttempts, + defaultValue: 30, + }) + + return ( + + ) +} diff --git a/apps/web/src/components/Liquidity/Create/RangeSelectionStep.tsx b/apps/web/src/components/Liquidity/Create/RangeSelectionStep.tsx index 7ad8a3b54b9..7e4636d89b1 100644 --- a/apps/web/src/components/Liquidity/Create/RangeSelectionStep.tsx +++ b/apps/web/src/components/Liquidity/Create/RangeSelectionStep.tsx @@ -1,3 +1,4 @@ +/* eslint-disable max-lines */ import { ProtocolVersion } from '@uniswap/client-data-api/dist/data/v1/poolTypes_pb' import { FeatureFlags, useFeatureFlag } from '@universe/gating' import { D3LiquidityRangeInput } from 'components/Charts/D3LiquidityRangeInput/D3LiquidityRangeInput' @@ -335,7 +336,7 @@ export const SelectPriceRangeStep = ({ ? { baseCurrency, quoteCurrency, - feeAmount: fee.feeAmount, + feeAmount: fee?.feeAmount, tickLower: ticks[0], tickUpper: ticks[1], pool: poolOrPair, @@ -516,6 +517,7 @@ export const SelectPriceRangeStep = ({ )} {baseCurrency && quoteCurrency && + fee && (isD3LiquidityRangeChartEnabled ? ( { const chainInfo = getChainInfo(liquidityPosition.chainId) - const addLiquidityOption = { - onPress: () => { - dispatch(setOpenModal({ name: ModalName.AddLiquidity, initialState: liquidityPosition })) - }, - label: t('common.addLiquidity'), - Icon: Plus, + const options: MenuOptionItem[] = [] + + const isV2Position = liquidityPosition.version === ProtocolVersion.V2 + const isV3Position = liquidityPosition.version === ProtocolVersion.V3 + const showMigrateV3Option = + isV3Position && isOpenLiquidityPosition && !isV4UnsupportedChain(liquidityPosition.chainId) + + if (!isV2Position && isOpenLiquidityPosition) { + options.push({ + onPress: () => { + dispatch( + setOpenModal({ + name: ModalName.ClaimFee, + initialState: liquidityPosition, + }), + ) + }, + label: t('pool.collectFees'), + Icon: Dollar, + }) } - const removeLiquidityOption: MenuOptionItem | undefined = isOpenLiquidityPosition - ? { - onPress: () => { - dispatch(setOpenModal({ name: ModalName.RemoveLiquidity, initialState: liquidityPosition })) - }, - label: t('pool.removeLiquidity'), - Icon: Minus, - } - : undefined + // closed v2 positions cannot re-add liquidity since the erc20 liquidity token is permanently burned when closed, + // whereas v3 positions can be re-opened + if (!isV2Position || isOpenLiquidityPosition) { + options.push({ + onPress: () => { + dispatch(setOpenModal({ name: ModalName.AddLiquidity, initialState: liquidityPosition })) + }, + label: t('common.addLiquidity'), + Icon: Plus, + }) + } - const poolInfoOption = { + if (isOpenLiquidityPosition) { + options.push({ + onPress: () => { + dispatch(setOpenModal({ name: ModalName.RemoveLiquidity, initialState: liquidityPosition })) + }, + label: t('pool.removeLiquidity'), + Icon: Minus, + }) + } + + // Add migration options if relevant + + if (isV2Position && isOpenLiquidityPosition) { + options.push({ + onPress: async () => { + if (liquidityPosition.chainId !== account.chainId) { + await selectChain(liquidityPosition.chainId) + } + navigate(`/migrate/v2/${liquidityPosition.liquidityToken.address}`) + }, + label: t('pool.migrateLiquidity'), + Icon: RightArrow, + }) + } + + if (showMigrateV3Option) { + options.push({ + onPress: () => { + navigate(`/migrate/v3/${chainInfo.urlParam}/${liquidityPosition.tokenId}`) + }, + label: t('pool.migrateLiquidity'), + Icon: RightArrow, + }) + } + + options.push({ onPress: () => { if (!liquidityPosition.poolId) { return @@ -128,90 +183,41 @@ function useDropdownOptions({ }, label: t('pool.info'), Icon: InfoCircleFilled, + }) + + if (showVisibilityOption) { + options.push({ + onPress: () => { + dispatch( + setPositionVisibility({ + poolId: liquidityPosition.poolId, + tokenId: liquidityPosition.tokenId, + chainId: liquidityPosition.chainId, + isVisible: !isVisible, + }), + ) + }, + label: isVisible ? t('common.hide.button') : t('common.unhide'), + Icon: isVisible ? EyeOff : Eye, + showDivider: true, + }) + + if (!liquidityPosition.isHidden) { + options.push({ + onPress: reportPositionHandler, + label: t('nft.reportSpam'), + Icon: Flag, + destructive: true, + }) + } } - const hideOption: MenuOptionItem | undefined = showVisibilityOption - ? { - onPress: () => { - dispatch( - togglePositionVisibility({ - poolId: liquidityPosition.poolId, - tokenId: liquidityPosition.tokenId, - chainId: liquidityPosition.chainId, - }), - ) - }, - label: isVisible ? t('common.hide.button') : t('common.unhide'), - Icon: isVisible ? EyeOff : Eye, - showDivider: true, - } - : undefined - - if (liquidityPosition.version === ProtocolVersion.V2) { - const migrateV2Option = isOpenLiquidityPosition - ? { - onPress: async () => { - if (liquidityPosition.chainId !== account.chainId) { - await selectChain(liquidityPosition.chainId) - } - navigate(`/migrate/v2/${liquidityPosition.liquidityToken.address}`) - }, - label: t('pool.migrateLiquidity'), - Icon: RightArrow, - } - : undefined - - return [ - isOpenLiquidityPosition ? addLiquidityOption : undefined, // closed v2 positions cannot re-add liquidity since the erc20 liquidity token is permanently burned when closed. whereas v3 positions can be re-opened - removeLiquidityOption, - migrateV2Option, - poolInfoOption, - hideOption, - ].filter((o): o is MenuOptionItem => o !== undefined) - } - - const collectFeesOption: MenuOptionItem | undefined = isOpenLiquidityPosition - ? { - onPress: () => { - dispatch( - setOpenModal({ - name: ModalName.ClaimFee, - initialState: liquidityPosition, - }), - ) - }, - label: t('pool.collectFees'), - Icon: Dollar, - } - : undefined - - const showMigrateV3Option = - isOpenLiquidityPosition && - !isV4UnsupportedChain(liquidityPosition.chainId) && - liquidityPosition.version !== ProtocolVersion.V4 - - const migrateV3Option: MenuOptionItem | undefined = showMigrateV3Option - ? { - onPress: () => { - navigate(`/migrate/v3/${chainInfo.urlParam}/${liquidityPosition.tokenId}`) - }, - label: t('pool.migrateLiquidity'), - Icon: RightArrow, - } - : undefined - - return [ - collectFeesOption, - addLiquidityOption, - removeLiquidityOption, - migrateV3Option, - poolInfoOption, - hideOption, - ].filter((o): o is MenuOptionItem => o !== undefined) + return options }, [ account.chainId, dispatch, isOpenLiquidityPosition, + reportPositionHandler, isVisible, liquidityPosition, navigate, @@ -528,7 +534,7 @@ function MiniPositionCard({ const activeStyle: FlexProps = { opacity: 1, pointerEvents: 'auto', backgroundColor: '$scrim' } const PositionDetailsMenuButton = styled(Flex, { - animation: 'fast', + transition: 'all 0.1s ease-in-out', opacity: 0, borderRadius: '$rounded12', p: '$spacing8', @@ -574,7 +580,6 @@ function PositionDropdownMoreMenu({ - {currency?.symbol?.toUpperCase().replace(/\$/g, '').replace(/\s+/g, '').slice(0, 3)} + {currency?.symbol?.toUpperCase().replace('$', '').replace(/\s+/g, '').slice(0, 3)} {showNetworkLogo && ( diff --git a/apps/web/src/components/NavBar/CompanyMenu/MenuDropdown.tsx b/apps/web/src/components/NavBar/CompanyMenu/MenuDropdown.tsx index 6a02dfbab8c..30e8eb8aeb4 100644 --- a/apps/web/src/components/NavBar/CompanyMenu/MenuDropdown.tsx +++ b/apps/web/src/components/NavBar/CompanyMenu/MenuDropdown.tsx @@ -168,7 +168,11 @@ export function MenuDropdown({ close }: { close?: () => void }) { alignItems="center" $xl={{ flexDirection: 'column', gap: '$spacing16', alignItems: 'flex-start' }} > - {isConversionTrackingEnabled && } + {isConversionTrackingEnabled && ( + + + + )} diff --git a/apps/web/src/components/NavBar/CompanyMenu/MobileMenuDrawer.tsx b/apps/web/src/components/NavBar/CompanyMenu/MobileMenuDrawer.tsx index 9c4ec5b3aee..3c62c980eed 100644 --- a/apps/web/src/components/NavBar/CompanyMenu/MobileMenuDrawer.tsx +++ b/apps/web/src/components/NavBar/CompanyMenu/MobileMenuDrawer.tsx @@ -9,12 +9,11 @@ import { CurrencySettings } from 'components/NavBar/PreferencesMenu/Currency' import { LanguageSettings } from 'components/NavBar/PreferencesMenu/Language' import { PreferencesView } from 'components/NavBar/PreferencesMenu/shared' import { useTabsContent } from 'components/NavBar/Tabs/TabsContent' -import { useTheme } from 'lib/styled-components' import { Socials } from 'pages/Landing/sections/Footer' import { useCallback, useEffect, useRef, useState } from 'react' import { ChevronDown } from 'react-feather' import { useTranslation } from 'react-i18next' -import { Accordion, AnimateTransition, Flex, Separator, Square, Text } from 'ui/src' +import { Accordion, AnimateTransition, Flex, Separator, Square, Text, useSporeColors } from 'ui/src' import { TestID } from 'uniswap/src/test/fixtures/testIDs' function MenuSection({ @@ -26,7 +25,7 @@ function MenuSection({ children: JSX.Element | JSX.Element[] collapsible?: boolean }) { - const theme = useTheme() + const colors = useSporeColors() return ( @@ -39,7 +38,7 @@ function MenuSection({ {collapsible && ( - + )} diff --git a/apps/web/src/components/NavBar/NavDropdown/NavDropdown.tsx b/apps/web/src/components/NavBar/NavDropdown/NavDropdown.tsx index ab88496ec07..0f85f6f366d 100644 --- a/apps/web/src/components/NavBar/NavDropdown/NavDropdown.tsx +++ b/apps/web/src/components/NavBar/NavDropdown/NavDropdown.tsx @@ -1,6 +1,6 @@ import { ReactNode, RefObject } from 'react' import { Flex, FlexProps, Popover, styled, useScrollbarStyles, useShadowPropsMedium, WebBottomSheet } from 'ui/src' -import { INTERFACE_NAV_HEIGHT } from 'ui/src/theme' +import { INTERFACE_NAV_HEIGHT, zIndexes } from 'ui/src/theme' const NavDropdownContent = styled(Flex, { borderRadius: '$rounded16', @@ -37,7 +37,7 @@ interface NavDropdownProps { isOpen: boolean width?: number minWidth?: number - dropdownRef?: RefObject + dropdownRef?: RefObject dataTestId?: string padded?: boolean mr?: number @@ -61,6 +61,7 @@ export function NavDropdown({ return ( <> theme.surface3}; color: ${({ theme }) => theme.neutral2}; padding: 0px 8px; @@ -32,7 +32,7 @@ const KeyShortcut = styled.div` backdrop-filter: blur(60px); ` -const SearchIcon = styled.div` +const SearchIcon = deprecatedStyled.div` width: 20px; height: 20px; ` @@ -41,7 +41,7 @@ export const SearchBar = () => { const poolSearchEnabled = useFeatureFlag(FeatureFlags.PoolSearch) const isNavSearchInputVisible = useIsSearchBarVisible() - const theme = useTheme() + const colors = useSporeColors() const { t } = useTranslation() // subscribe to locale changes const { @@ -93,7 +93,7 @@ export const SearchBar = () => { > - + { ) : ( - + )} diff --git a/apps/web/src/components/NavBar/Tabs/TabsContent.tsx b/apps/web/src/components/NavBar/Tabs/TabsContent.tsx index 9d0ff30f2ef..4e3a75e2b94 100644 --- a/apps/web/src/components/NavBar/Tabs/TabsContent.tsx +++ b/apps/web/src/components/NavBar/Tabs/TabsContent.tsx @@ -1,16 +1,20 @@ import { FeatureFlags, useFeatureFlag } from '@universe/gating' -import { CreditCardIcon } from 'components/Icons/CreditCard' import { Limit } from 'components/Icons/Limit' import { SwapV2 } from 'components/Icons/SwapV2' import { MenuItem } from 'components/NavBar/CompanyMenu/Content' -import { useTheme } from 'lib/styled-components' +import { usePortfolioRoutes } from 'pages/Portfolio/Header/hooks/usePortfolioRoutes' +import { PortfolioTab } from 'pages/Portfolio/types' +import { buildPortfolioUrl } from 'pages/Portfolio/utils/portfolioUrls' import { useTranslation } from 'react-i18next' import { useLocation } from 'react-router' +import { useSporeColors } from 'ui/src' import { CoinConvert } from 'ui/src/components/icons/CoinConvert' import { Compass } from 'ui/src/components/icons/Compass' +import { CreditCard } from 'ui/src/components/icons/CreditCard' import { Pools } from 'ui/src/components/icons/Pools' import { ReceiveAlt } from 'ui/src/components/icons/ReceiveAlt' import { Wallet } from 'ui/src/components/icons/Wallet' +import { ElementName } from 'uniswap/src/features/telemetry/constants' export type TabsSection = { title: string @@ -23,15 +27,18 @@ export type TabsSection = { export type TabsItem = MenuItem & { icon?: JSX.Element + elementName?: ElementName } export const useTabsContent = (): TabsSection[] => { const { t } = useTranslation() const { pathname } = useLocation() - const theme = useTheme() + const { chainId: portfolioChainId } = usePortfolioRoutes() + const colors = useSporeColors() const isFiatOffRampEnabled = useFeatureFlag(FeatureFlags.FiatOffRamp) const isPortfolioPageEnabled = useFeatureFlag(FeatureFlags.PortfolioPage) const isToucanEnabled = useFeatureFlag(FeatureFlags.Toucan) + const isPortfolioDefiTabEnabled = useFeatureFlag(FeatureFlags.PortfolioDefiTab) return [ { @@ -42,19 +49,19 @@ export const useTabsContent = (): TabsSection[] => { items: [ { label: t('common.swap'), - icon: , + icon: , href: '/swap', internal: true, }, { label: t('swap.limit'), - icon: , + icon: , href: '/limit', internal: true, }, { label: t('common.buy.label'), - icon: , + icon: , href: '/buy', internal: true, }, @@ -62,7 +69,7 @@ export const useTabsContent = (): TabsSection[] => { ? [ { label: t('common.sell.label'), - icon: , + icon: , href: '/sell', internal: true, }, @@ -83,7 +90,7 @@ export const useTabsContent = (): TabsSection[] => { href: '/explore/transactions', internal: true, }, - ...(isToucanEnabled ? [{ label: 'Toucan', href: '/explore/toucan', internal: true }] : []), + ...(isToucanEnabled ? [{ label: 'Toucan', href: '/explore/auctions', internal: true }] : []), ], }, { @@ -108,34 +115,43 @@ export const useTabsContent = (): TabsSection[] => { ? [ { title: t('common.portfolio'), - href: '/portfolio', + href: buildPortfolioUrl(PortfolioTab.Overview, portfolioChainId), isActive: pathname.startsWith('/portfolio'), icon: , items: [ { label: t('portfolio.overview.title'), - href: '/portfolio', + href: buildPortfolioUrl(PortfolioTab.Overview, portfolioChainId), internal: true, + elementName: ElementName.NavbarPortfolioDropdownOverview, }, { label: t('portfolio.tokens.title'), - href: '/portfolio/tokens', - internal: true, - }, - { - label: t('portfolio.defi.title'), - href: '/portfolio/defi', + href: buildPortfolioUrl(PortfolioTab.Tokens, portfolioChainId), internal: true, + elementName: ElementName.NavbarPortfolioDropdownTokens, }, + ...(isPortfolioDefiTabEnabled + ? [ + { + label: t('portfolio.defi.title'), + href: buildPortfolioUrl(PortfolioTab.Defi, portfolioChainId), + internal: true, + elementName: ElementName.NavbarPortfolioDropdownDefi, + }, + ] + : []), { label: t('portfolio.nfts.title'), - href: '/portfolio/nfts', + href: buildPortfolioUrl(PortfolioTab.Nfts, portfolioChainId), internal: true, + elementName: ElementName.NavbarPortfolioDropdownNfts, }, { label: t('portfolio.activity.title'), - href: '/portfolio/activity', + href: buildPortfolioUrl(PortfolioTab.Activity, portfolioChainId), internal: true, + elementName: ElementName.NavbarPortfolioDropdownActivity, }, ], }, diff --git a/apps/web/src/components/Pools/PoolDetails/PoolDetailsHeader.tsx b/apps/web/src/components/Pools/PoolDetails/PoolDetailsHeader.tsx index 9f7781e2db0..356f6a371de 100644 --- a/apps/web/src/components/Pools/PoolDetails/PoolDetailsHeader.tsx +++ b/apps/web/src/components/Pools/PoolDetails/PoolDetailsHeader.tsx @@ -1,6 +1,7 @@ import { getTokenDetailsURL, gqlToCurrency } from 'appGraphql/data/util' +import { ProtocolVersion } from '@uniswap/client-data-api/dist/data/v1/poolTypes_pb' import { Percent } from '@uniswap/sdk-core' -import { GraphQLApi } from '@universe/api' +import { GraphQLApi, parseRestProtocolVersion } from '@universe/api' import { FeatureFlags, useFeatureFlag } from '@universe/gating' import { BreadcrumbNavContainer, BreadcrumbNavLink, CurrentPageBreadcrumb } from 'components/BreadcrumbNav' import { Dropdown } from 'components/Dropdowns/Dropdown' @@ -13,10 +14,13 @@ import CurrencyLogo from 'components/Logo/CurrencyLogo' import { DoubleCurrencyLogo } from 'components/Logo/DoubleLogo' import { LpIncentivesAprDisplay } from 'components/LpIncentives/LpIncentivesAprDisplay' import { DetailBubble } from 'components/Pools/PoolDetails/shared' +import { POPUP_MEDIUM_DISMISS_MS } from 'components/Popups/constants' +import { popupRegistry } from 'components/Popups/registry' +import { PopupType } from 'components/Popups/types' import ShareButton from 'components/Tokens/TokenDetails/ShareButton' -import { ActionButtonStyle } from 'components/Tokens/TokenDetails/shared' +import { ActionButtonStyle, DropdownAction } from 'components/Tokens/TokenDetails/shared' import { NATIVE_CHAIN_ID } from 'constants/tokens' -import styled, { useTheme } from 'lib/styled-components' +import { deprecatedStyled } from 'lib/styled-components' import React, { useMemo, useState } from 'react' import { ChevronRight, ExternalLink as ExternalLinkIcon } from 'react-feather' import { Trans, useTranslation } from 'react-i18next' @@ -24,16 +28,21 @@ import { Link } from 'react-router' import { ThemedText } from 'theme/components' import { ExternalLink } from 'theme/components/Links' import { ClickableTamaguiStyle, EllipsisTamaguiStyle } from 'theme/components/styles' -import { Flex, Shine, Text, TouchableArea, styled as tamaguiStyled, useIsTouchDevice, useMedia } from 'ui/src' +import { Flex, Shine, styled, Text, TouchableArea, useIsTouchDevice, useMedia, useSporeColors } from 'ui/src' import { ArrowDownArrowUp } from 'ui/src/components/icons/ArrowDownArrowUp' +import { ChartBarCrossed } from 'ui/src/components/icons/ChartBarCrossed' +import { Ellipsis } from 'ui/src/components/icons/Ellipsis' +import { ReportPoolDataModal } from 'uniswap/src/components/reporting/ReportPoolDataModal' import { useEnabledChains } from 'uniswap/src/features/chains/hooks/useEnabledChains' import { UniverseChainId } from 'uniswap/src/features/chains/types' import { toGraphQLChain } from 'uniswap/src/features/chains/utils' import { ExplorerDataType, getExplorerLink } from 'uniswap/src/utils/linking' import { shortenAddress } from 'utilities/src/addresses' +import { useEvent } from 'utilities/src/react/hooks' +import { useBooleanState } from 'utilities/src/react/useBooleanState' import { getChainUrlParam } from 'utils/chainParams' -const StyledExternalLink = styled(ExternalLink)` +const StyledExternalLink = deprecatedStyled(ExternalLink)` &:hover { // Override hover behavior from ExternalLink opacity: 1; @@ -131,7 +140,7 @@ const PoolDetailsTitle = ({ ) } -const ContractsDropdownRowContainer = tamaguiStyled(Flex, { +const ContractsDropdownRowContainer = styled(Flex, { row: true, alignItems: 'center', cursor: 'pointer', @@ -157,7 +166,7 @@ const ContractsDropdownRow = ({ chainId?: number tokens: (GraphQLApi.Token | undefined)[] }) => { - const theme = useTheme() + const colors = useSporeColors() const currency = tokens[0] && gqlToCurrency(tokens[0]) const isPool = tokens.length === 2 const currencies = isPool && tokens[1] ? [currency, gqlToCurrency(tokens[1])] : [currency] @@ -192,7 +201,7 @@ const ContractsDropdownRow = ({ {shortenAddress({ address })} - + ) @@ -206,7 +215,7 @@ const PoolDetailsHeaderActions = ({ token1, protocolVersion, }: { - chainId?: number + chainId?: UniverseChainId poolAddress?: string poolName: string token0?: GraphQLApi.Token @@ -214,20 +223,43 @@ const PoolDetailsHeaderActions = ({ protocolVersion?: GraphQLApi.ProtocolVersion }) => { const { t } = useTranslation() - const theme = useTheme() + const colors = useSporeColors() const isTouchDevice = useIsTouchDevice() const [contractsModalIsOpen, toggleContractsModal] = useState(false) + const [moreDropdownOpen, toggleMoreDropdown] = useState(false) + + const currency0 = token0 && gqlToCurrency(token0) + const currency1 = token1 && gqlToCurrency(token1) + + const { + value: reportDataIssueModalIsOpen, + setTrue: openReportDataIssueModal, + setFalse: closeReportDataIssueModal, + } = useBooleanState(false) + + const onReportDataSuccess = useEvent(() => { + popupRegistry.addPopup( + { type: PopupType.Success, message: t('common.reported') }, + 'report-data-success', + POPUP_MEDIUM_DISMISS_MS, + ) + }) + + const hasReportData = useMemo( + () => Boolean(poolAddress && chainId && currency0 && currency1 && protocolVersion), + [poolAddress, chainId, currency0, currency1, protocolVersion], + ) return ( - + + ) : ( - + ) } tooltipText={isTouchDevice ? undefined : t('pool.explorers')} @@ -245,11 +277,44 @@ const PoolDetailsHeaderActions = ({ - + {hasReportData && ( + } + hideChevron + buttonStyle={ActionButtonStyle} + dropdownStyle={{ minWidth: 235 }} + alignRight + > + + + + {t('reporting.token.data.title')} + + + + )} + {/* typescript wants these to be explicit even though hasReportData should be equivalent */} + {poolAddress && chainId && currency0 && currency1 && protocolVersion && ( + + )} + ) } -const StyledLink = tamaguiStyled(Link, { +const StyledLink = styled(Link, { color: '$neutral1', ...ClickableTamaguiStyle, '$platform-web': { diff --git a/apps/web/src/components/Table/index.tsx b/apps/web/src/components/Table/index.tsx index 7911e20b5ec..cdc6938832b 100644 --- a/apps/web/src/components/Table/index.tsx +++ b/apps/web/src/components/Table/index.tsx @@ -1,188 +1,59 @@ import { ApolloError } from '@apollo/client' -import { - Cell, - CellContext, - ColumnDef, - flexRender, - getCoreRowModel, - Row, - RowData, - Table as TanstackTable, - useReactTable, -} from '@tanstack/react-table' +import { ColumnDef, flexRender, getCoreRowModel, Row, RowData, useReactTable } from '@tanstack/react-table' import { useParentSize } from '@visx/responsive' import Loader from 'components/Icons/LoadingSpinner' -import { ErrorModal } from 'components/Table/ErrorBox' import { ScrollButton, ScrollButtonProps } from 'components/Table/ScrollButton' import { CellContainer, - DataRow, HeaderRow, LOAD_MORE_BOTTOM_OFFSET, LoadingIndicator, LoadingIndicatorContainer, - NoDataFoundTableRow, SHOW_RETURN_TO_TOP_OFFSET, TableBodyContainer, TableContainer, TableHead, - TableRowLink, TableScrollMask, } from 'components/Table/styled' -import { TableSizeProvider, useTableSize } from 'components/Table/TableSizeProvider' +import { TableBody } from 'components/Table/TableBody' +import { TableSizeProvider } from 'components/Table/TableSizeProvider' import { getCommonPinningStyles } from 'components/Table/utils' import useDebounce from 'hooks/useDebounce' -import { forwardRef, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Trans } from 'react-i18next' -import { LinkProps } from 'react-router' import { ScrollSync, ScrollSyncPane } from 'react-scroll-sync' -import { ThemedText } from 'theme/components' import { Flex } from 'ui/src' -import { UseSporeColorsReturn, useSporeColors } from 'ui/src/hooks/useSporeColors' -import { breakpoints, INTERFACE_NAV_HEIGHT, zIndexes } from 'ui/src/theme' -import { ElementName } from 'uniswap/src/features/telemetry/constants' -import Trace from 'uniswap/src/features/telemetry/Trace' -import { useTrace } from 'utilities/src/telemetry/trace/TraceContext' - -const ROW_HEIGHT_DESKTOP = 56 -const ROW_HEIGHT_MOBILE_WEB = 48 - -interface TableCellProps { - cell: Cell - colors: UseSporeColorsReturn -} - -function TableCellComponent({ cell, colors }: TableCellProps): JSX.Element { - return ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ) -} - -const TableCell = memo(TableCellComponent) as typeof TableCellComponent - -interface TableRowProps { - row: Row - v2: boolean - rowWrapper?: (row: Row, content: JSX.Element) => JSX.Element -} +import { useSporeColors } from 'ui/src/hooks/useSporeColors' +import { INTERFACE_NAV_HEIGHT, zIndexes } from 'ui/src/theme' -function TableRowComponent({ row, v2 = true, rowWrapper }: TableRowProps): JSX.Element { - const analyticsContext = useTrace() - const rowOriginal = row.original as { - linkState: LinkProps['state'] - testId: string - analytics?: { - elementName: ElementName - properties: Record - } +function calculateScrollButtonTop(params: { + maxHeight?: number + isSticky: boolean + centerArrows: boolean + height: number + headerHeight: number +}): number { + const { maxHeight, isSticky, centerArrows, height, headerHeight } = params + + // When centerArrows is true, center based on table height + if (centerArrows && height > 0) { + return height / 2 } - const linkState = rowOriginal.linkState - const rowTestId = rowOriginal.testId - const colors = useSporeColors() - const { width: tableWidth } = useTableSize() - const rowHeight = useMemo( - () => (tableWidth <= breakpoints.lg ? ROW_HEIGHT_MOBILE_WEB : ROW_HEIGHT_DESKTOP), - [tableWidth], - ) - const cells = row - .getVisibleCells() - .map((cell: Cell) => key={cell.id} cell={cell} colors={colors} />) - - const rowContent = ( - - - {'link' in rowOriginal && typeof rowOriginal.link === 'string' ? ( - - - {cells} - - - ) : ( - - {cells} - - )} - - - ) - return rowWrapper ? rowWrapper(row, rowContent) : rowContent -} - -const TableRow = memo(TableRowComponent) as typeof TableRowComponent - -type TableBodyProps = { - table: TanstackTable - loading?: boolean - error?: ApolloError | boolean - v2: boolean - rowWrapper?: (row: Row, content: JSX.Element) => JSX.Element -} - -function TableBodyInner( - { table, loading, error, v2 = true, rowWrapper }: TableBodyProps, - ref: React.Ref, -) { - const rows = table.getRowModel().rows - const { width: tableWidth } = useTableSize() - const skeletonRowHeight = useMemo( - () => (tableWidth <= breakpoints.lg ? ROW_HEIGHT_MOBILE_WEB : ROW_HEIGHT_DESKTOP), - [tableWidth], - ) - if (loading || error) { - return ( - <> - {Array.from({ length: 20 }, (_, rowIndex) => ( - - {table.getAllColumns().map((column, columnIndex) => ( - - {flexRender(column.columnDef.cell, {} as CellContext)} - - ))} - - ))} - {error && ( - } - subtitle={} - /> - )} - - ) + // When maxHeight is set but centerArrows is false, still use table height + // (container-based positioning) + if (maxHeight) { + return height / 2 } - if (!rows.length) { - return ( - - - - - - ) + // When sticky and centerArrows is false, use window-based calculation + if (isSticky) { + return (window.innerHeight - (headerHeight + 12)) / 2 } - return ( - - {rows.map((row) => ( - key={row.id} row={row} v2={v2} rowWrapper={rowWrapper} /> - ))} - - ) + return 0 } -const TableBody = forwardRef(TableBodyInner) as unknown as ( - p: TableBodyProps & { ref?: React.Ref }, -) => JSX.Element - export function Table({ columns, data, @@ -199,6 +70,10 @@ export function Table({ scrollGroup = 'table-sync', getRowId, rowWrapper, + loadingRowsCount = 20, + rowHeight, + compactRowHeight, + centerArrows = false, }: { columns: ColumnDef[] data: T[] @@ -215,10 +90,15 @@ export function Table({ scrollGroup?: string getRowId?: (originalRow: T, index: number, parent?: Row) => string rowWrapper?: (row: Row, content: JSX.Element) => JSX.Element + loadingRowsCount?: number + rowHeight?: number + compactRowHeight?: number + centerArrows?: boolean }) { const [loadingMore, setLoadingMore] = useState(false) const [showScrollRightButton, setShowScrollRightButton] = useState(false) const [showScrollLeftButton, setShowScrollLeftButton] = useState(false) + const [showRightFadeOverlay, setShowRightFadeOverlay] = useState(false) const colors = useSporeColors() const [pinnedColumns, setPinnedColumns] = useState([]) @@ -239,8 +119,10 @@ export function Table({ // biome-ignore lint/correctness/useExhaustiveDependencies: we want to run it also when loadMore, loadingMore are changed useEffect(() => { - const scrollableElement = maxHeight ? tableBodyRef.current : window - if (scrollableElement === null) { + // Use parentElement because the actual scrolling container is the parent wrapper, + // not the table body div itself (which is a child of the scrollable container) + const scrollableElement = maxHeight ? tableBodyRef.current?.parentElement : window + if (!scrollableElement) { return undefined } const updateScrollPosition = () => { @@ -250,7 +132,7 @@ export function Table({ distanceFromTop: scrollTop, distanceToBottom: scrollHeight - scrollTop - clientHeight, }) - } else { + } else if (scrollableElement === window) { setScrollPosition({ distanceFromTop: scrollableElement.scrollY, distanceToBottom: document.body.scrollHeight - scrollableElement.scrollY - scrollableElement.innerHeight, @@ -263,7 +145,29 @@ export function Table({ // biome-ignore lint/correctness/useExhaustiveDependencies: we want to run it also when distanceFromTop, loading are changed useEffect(() => { - if (distanceToBottom < LOAD_MORE_BOTTOM_OFFSET && !loadingMore && loadMore && canLoadMore.current && !error) { + const scrollableElement = maxHeight ? tableBodyRef.current?.parentElement : window + const shouldLoadMoreFromScroll = distanceToBottom < LOAD_MORE_BOTTOM_OFFSET + let shouldLoadMoreFromViewportHeight = false + + if (!shouldLoadMoreFromScroll) { + if (!maxHeight && scrollableElement === window) { + const contentHeight = document.body.scrollHeight + const viewportHeight = window.innerHeight + shouldLoadMoreFromViewportHeight = contentHeight <= viewportHeight + } else if (scrollableElement instanceof HTMLDivElement) { + const { scrollHeight, clientHeight } = scrollableElement + shouldLoadMoreFromViewportHeight = scrollHeight <= clientHeight + } + } + + if ( + (shouldLoadMoreFromScroll || shouldLoadMoreFromViewportHeight) && + !loadingMore && + loadMore && + canLoadMore.current && + !error && + !loading + ) { setLoadingMore(true) // Manually update scroll position to prevent re-triggering setScrollPosition({ @@ -281,7 +185,7 @@ export function Table({ }, }) } - }, [data.length, distanceFromTop, distanceToBottom, error, loadMore, loading, loadingMore]) + }, [data.length, distanceFromTop, distanceToBottom, error, loadMore, loading, loadingMore, maxHeight, tableBodyRef]) const table = useReactTable({ columns, @@ -326,6 +230,13 @@ export function Table({ if (showScrollLeftButton !== nextShowScrollLeftButton) { setShowScrollLeftButton(nextShowScrollLeftButton) } + // Hide overlay when table is full width or scrolled all the way to the right + const isFullWidth = maxScrollLeft <= 0 + const isScrolledToRight = container.scrollLeft >= maxScrollLeft + const nextShowRightFadeOverlay = pinnedColumns.length > 0 && !isFullWidth && !isScrolledToRight + if (showRightFadeOverlay !== nextShowRightFadeOverlay) { + setShowRightFadeOverlay(nextShowRightFadeOverlay) + } } horizontalScrollHandler() @@ -333,7 +244,7 @@ export function Table({ return () => { container.removeEventListener('scroll', horizontalScrollHandler) } - }, [loading, showScrollLeftButton, showScrollRightButton]) + }, [loading, showScrollLeftButton, showScrollRightButton, showRightFadeOverlay, pinnedColumns.length]) const headerHeight = useMemo(() => { const header = document.getElementById('AppHeader') @@ -341,14 +252,14 @@ export function Table({ }, []) const scrollButtonTop = useMemo(() => { - if (maxHeight) { - return height / 2 - } else if (isSticky) { - return (window.innerHeight - (headerHeight + 12)) / 2 - } - - return 0 - }, [headerHeight, height, isSticky, maxHeight]) + return calculateScrollButtonTop({ + maxHeight, + isSticky, + centerArrows, + height, + headerHeight, + }) + }, [headerHeight, height, isSticky, maxHeight, centerArrows]) const onScrollButtonPress = useCallback( (direction: ScrollButtonProps['direction']) => () => { @@ -393,51 +304,64 @@ export function Table({ const content = ( - {!hideHeader && ( - <> - - {hasPinnedColumns && ( - <> - - - - - - + <> + + {hasPinnedColumns && ( + <> + + + + + + + {(!v2 || showRightFadeOverlay) && ( - - )} + )} + + )} + + {!hideHeader && ( {table.getFlatHeaders().map((header) => ( - + {flexRender(header.column.columnDef.header, header.getContext())} ))} - - {hasPinnedColumns && } - - )} + )} + + {hasPinnedColumns && (!v2 || showRightFadeOverlay) && ( + + )} + ({ error={error} v2={v2} rowWrapper={rowWrapper} + loadingRowsCount={loadingRowsCount} + rowHeight={rowHeight} + compactRowHeight={compactRowHeight} // @ts-ignore table={table} ref={tableBodyRef} diff --git a/apps/web/src/components/Tokens/TokenDetails/Delta.tsx b/apps/web/src/components/Tokens/TokenDetails/Delta.tsx index fe2fb6bec84..450206721e5 100644 --- a/apps/web/src/components/Tokens/TokenDetails/Delta.tsx +++ b/apps/web/src/components/Tokens/TokenDetails/Delta.tsx @@ -1,13 +1,13 @@ import { ArrowChangeDown } from 'components/Icons/ArrowChangeDown' import { ArrowChangeUp } from 'components/Icons/ArrowChangeUp' -import styled from 'lib/styled-components' +import { deprecatedStyled } from 'lib/styled-components' import { colorsDark, colorsLight } from 'ui/src/theme' -const StyledUpArrow = styled(ArrowChangeUp)<{ $noColor?: boolean }>` +const StyledUpArrow = deprecatedStyled(ArrowChangeUp)<{ $noColor?: boolean }>` color: ${({ theme, $noColor }) => $noColor ? theme.neutral3 : theme.darkMode ? colorsDark.statusSuccess : colorsLight.statusSuccess}; ` -const StyledDownArrow = styled(ArrowChangeDown)<{ $noColor?: boolean }>` +const StyledDownArrow = deprecatedStyled(ArrowChangeDown)<{ $noColor?: boolean }>` color: ${({ theme, $noColor }) => $noColor ? theme.neutral3 : theme.darkMode ? colorsDark.statusCritical : colorsLight.statusCritical}; ` @@ -47,7 +47,7 @@ export function DeltaArrow({ delta, formattedDelta, noColor = false, size = 16 } ) } -export const DeltaText = styled.span<{ delta?: number }>` +export const DeltaText = deprecatedStyled.span<{ delta?: number }>` color: ${({ theme, delta }) => { if (delta === undefined || delta === 0) { return theme.neutral3 diff --git a/apps/web/src/components/Tokens/TokenDetails/__snapshots__/Delta.test.tsx.snap b/apps/web/src/components/Tokens/TokenDetails/__snapshots__/Delta.test.tsx.snap index c6f79fed363..32cf872d116 100644 --- a/apps/web/src/components/Tokens/TokenDetails/__snapshots__/Delta.test.tsx.snap +++ b/apps/web/src/components/Tokens/TokenDetails/__snapshots__/Delta.test.tsx.snap @@ -3,7 +3,7 @@ exports[`Delta > should render correctly 1`] = ` .c0 { - color: rgba(19,19,19,0.35); + color: rgba(19, 19, 19, 0.35); } should render correctly when delta is positive 1`] = ` exports[`Delta > should render negative zero when delta is close to zero but negative 1`] = ` .c0 { - color: rgba(19,19,19,0.35); + color: rgba(19, 19, 19, 0.35); } should render negative zero when delta is close to zero but neg exports[`Delta > should render positive zero when delta is 0 1`] = ` .c0 { - color: rgba(19,19,19,0.35); + color: rgba(19, 19, 19, 0.35); } should render positive zero when delta is 0 1`] = ` exports[`Delta > should render positive zero when delta is close to zero but positive 1`] = ` .c0 { - color: rgba(19,19,19,0.35); + color: rgba(19, 19, 19, 0.35); } should render positive zero when delta is close to zero but pos exports[`Delta > should render positive zero when delta is parsed as positive zero 1`] = ` .c0 { - color: rgba(19,19,19,0.35); + color: rgba(19, 19, 19, 0.35); } import('state/fiatOnRampTransact const loadWebAccountsStoreUpdater = () => import('features/accounts/store/updater').then((m) => ({ default: m.WebAccountsStoreUpdater })) +const provideSessionInitService = () => + createSessionInitializationService({ + getSessionService: () => + provideSessionService({ + getBaseUrl: getEntryGatewayUrl, + getIsSessionServiceEnabled, + getLogger, + }), + challengeSolverService: createChallengeSolverService(), + getIsSessionUpgradeAutoEnabled, + getLogger, + }) + function Updaters() { const location = useLocation() + const isSessionServiceEnabled = useIsSessionServiceEnabled() const ListsUpdater = useDeferredComponent(loadListsUpdater) const SystemThemeUpdater = useDeferredComponent(loadSystemThemeUpdater) @@ -97,6 +121,7 @@ function Updaters() { {FiatOnRampTransactionsUpdater && } {WebAccountsStoreUpdater && } + ) } @@ -115,6 +140,7 @@ function GraphqlProviders({ children }: { children: React.ReactNode }) { } function StatsigProvider({ children }: PropsWithChildren) { const account = useAccount() + const statsigUser: StatsigUser = useMemo( () => ({ userID: getDeviceId(), @@ -151,53 +177,59 @@ const container = document.getElementById('root') as HTMLElement const Router = isBrowserRouterEnabled() ? BrowserRouter : HashRouter -createRoot(container).render( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - , -) +const RootApp = (): JSX.Element => { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} + +createRoot(container).render() // We once had a ServiceWorker, and users who have not visited since then may still have it registered. // This ensures it is truly gone. diff --git a/apps/web/src/pages/App/WalletConnection.e2e.test.ts b/apps/web/src/pages/App/WalletConnection.e2e.test.ts index fd607149b46..52982d63a0f 100644 --- a/apps/web/src/pages/App/WalletConnection.e2e.test.ts +++ b/apps/web/src/pages/App/WalletConnection.e2e.test.ts @@ -1,37 +1,59 @@ import { FeatureFlags, getFeatureFlagName } from '@universe/gating' +import ms from 'ms' import { expect, getTest } from 'playwright/fixtures' import { TestID } from 'uniswap/src/test/fixtures/testIDs' const test = getTest() -test.describe('Wallet Connection', () => { - test('disconnect wallet', async ({ page }) => { - await page.goto(`/swap?featureFlagOverrideOff=${getFeatureFlagName(FeatureFlags.EmbeddedWallet)}`) - await page.getByTestId(TestID.AmountInputIn).fill('1') - - // Verify wallet is connected - await expect(await page.getByTestId(TestID.Web3StatusConnected).getByText('test0')).toBeVisible() - - // Disconnect the wallet - await page.getByTestId(TestID.Web3StatusConnected).click() - await page.getByTestId(TestID.WalletDisconnect).click() - - // Verify wallet has disconnected - await expect(await page.getByText('Connect wallet')).toBeVisible() - - // Verify swap input is not cleared - await expect(await page.getByTestId(TestID.AmountInputIn)).toHaveValue('1') - }) - - test('should connect wallet', async ({ page }) => { - await page.goto( - `/swap?eagerlyConnect=false&featureFlagOverrideOff=${getFeatureFlagName(FeatureFlags.EmbeddedWallet)}`, - ) - - await page.getByText('Connect Wallet').click() - await page.getByText('Mock Connector').click() - - await expect(await page.getByText('Connect wallet')).not.toBeVisible() - await expect(await page.getByTestId(TestID.Web3StatusConnected)).toHaveText('test0') - }) -}) +test.describe( + 'Wallet Connection', + { + tag: '@team:apps-growth', + annotation: [ + { type: 'DD_TAGS[team]', description: 'apps-growth' }, + { type: 'DD_TAGS[test.type]', description: 'web-e2e' }, + ], + }, + () => { + test('disconnect wallet', async ({ page }) => { + await page.goto(`/swap?featureFlagOverrideOff=${getFeatureFlagName(FeatureFlags.EmbeddedWallet)}`) + await page.getByTestId(TestID.AmountInputIn).fill('1') + + // Verify wallet is connected + await expect(await page.getByTestId(TestID.Web3StatusConnected).getByText('test0')).toBeVisible() + + // Disconnect the wallet + await page.getByTestId(TestID.Web3StatusConnected).click() + + // Wait for the disconnect button to be visible + await page.getByTestId(TestID.WalletDisconnect).waitFor({ state: 'visible' }) + await page.getByTestId(TestID.WalletDisconnect).hover() + await page.getByTestId(TestID.WalletDisconnectInModal).click() + + // Check if tooltip content appears (Solana enabled case) + const hasTooltip = await page.getByTestId(TestID.WalletDisconnectInModal).isVisible({ timeout: ms('3s') }) + + if (hasTooltip) { + await page.getByTestId(TestID.WalletDisconnectInModal).click() + } + + // Verify wallet has disconnected + await expect(await page.getByText('Connect wallet')).toBeVisible() + + // Verify swap input is not cleared + await expect(await page.getByTestId(TestID.AmountInputIn)).toHaveValue('1') + }) + + test('should connect wallet', async ({ page }) => { + await page.goto( + `/swap?eagerlyConnect=false&featureFlagOverrideOff=${getFeatureFlagName(FeatureFlags.EmbeddedWallet)}`, + ) + + await page.getByText('Connect Wallet').click() + await page.getByText('Mock Connector').click() + + await expect(await page.getByText('Connect wallet')).not.toBeVisible() + await expect(await page.getByTestId(TestID.Web3StatusConnected)).toHaveText('test0') + }) + }, +) diff --git a/apps/web/src/pages/CreatePosition/CreatePosition.anvil.e2e.test.ts b/apps/web/src/pages/CreatePosition/CreatePosition.anvil.e2e.test.ts index 14d6390c300..136015c82d6 100644 --- a/apps/web/src/pages/CreatePosition/CreatePosition.anvil.e2e.test.ts +++ b/apps/web/src/pages/CreatePosition/CreatePosition.anvil.e2e.test.ts @@ -15,7 +15,6 @@ import { parseEther } from 'viem' const test = getTest({ withAnvil: true }) const WETH_ADDRESS = WETH.address -const DEFAULT_INITIAL_POOL_PRICE = '3000' function modifyGasLimit(data: { create: { gasLimit: string } }) { try { @@ -26,131 +25,140 @@ function modifyGasLimit(data: { create: { gasLimit: string } }) { } } -test.describe('Create position', () => { - test('Create position with full range', async ({ page, anvil, graphql }) => { - await stubTradingApiEndpoint({ - page, - endpoint: uniswapUrls.tradingApiPaths.createLp, - modifyResponseData: modifyGasLimit, - }) - await graphql.intercept('SearchTokens', Mocks.Token.search_token_tether) - await anvil.setErc20Balance({ address: assume0xAddress(USDT.address), balance: ONE_MILLION_USDT }) - await page.goto('/positions/create') - await page.getByRole('button', { name: 'Choose token' }).click() - await page.getByTestId(TestID.ExploreSearchInput).fill(USDT.address) - // eslint-disable-next-line - await page.getByTestId('token-option-1-USDT').first().click() - await page.getByRole('button', { name: 'Continue' }).click() - await reviewAndCreatePosition({ page }) - }) - - test('Create position with custom range', async ({ page, anvil, graphql }) => { - await stubTradingApiEndpoint({ - page, - endpoint: uniswapUrls.tradingApiPaths.createLp, - modifyResponseData: modifyGasLimit, - }) - await graphql.intercept('SearchTokens', Mocks.Token.search_token_tether) - await anvil.setErc20Balance({ address: assume0xAddress(USDT.address), balance: ONE_MILLION_USDT }) - await page.goto('/positions/create') - await page.getByRole('button', { name: 'Choose token' }).click() - await page.getByTestId(TestID.ExploreSearchInput).fill(USDT.address) - // eslint-disable-next-line - await page.getByTestId('token-option-1-USDT').first().click() - await page.getByRole('button', { name: 'Continue' }).click() - await graphql.waitForResponse('PoolPriceHistory') - await graphql.waitForResponse('AllV4Ticks') - await page.getByText('Custom range').click() - await page.getByTestId(TestID.RangeInputIncrement + '-0').click() - await page.getByTestId(TestID.RangeInputDecrement + '-1').click() - await reviewAndCreatePosition({ page }) - }) - - test.describe('error handling', () => { - test('should gracefully handle errors during review', async ({ page, anvil }) => { - await anvil.setErc20Balance({ address: assume0xAddress(USDT.address), balance: ONE_MILLION_USDT }) - await page.goto(`/positions/create?currencyA=NATIVE¤cyB=${USDT.address}`) - await page.getByRole('button', { name: 'Continue' }).click() - await page.getByTestId(TestID.AmountInputIn).first().click() - await page.getByTestId(TestID.AmountInputIn).first().fill('1') - await page.getByRole('button', { name: 'Review' }).click() - await page.getByRole('button', { name: 'Create' }).click() - await expect(page.getByText('Something went wrong').first()).toBeVisible() - await page.getByRole('button', { name: 'Create' }).click() - await expect(page.getByText('Something went wrong').first()).not.toBeVisible() - }) - }) - - test.describe('v2 zero liquidity', () => { - test('should create a position', async ({ page, anvil }) => { - await anvil.setErc20Balance({ address: assume0xAddress(WETH_ADDRESS), balance: parseEther('100') }) - await anvil.setErc20Balance({ address: assume0xAddress(USDT.address), balance: ONE_MILLION_USDT }) - await anvil.setV2PoolReserves({ - pairAddress: assume0xAddress( - computePairAddress({ - factoryAddress: V2_FACTORY_ADDRESSES[UniverseChainId.Mainnet], - tokenA: WETH, - tokenB: USDT, - }), - ), - reserve0: 0n, - reserve1: 0n, - }) - await page.goto(`/positions/create/v2?currencyA=${WETH_ADDRESS}¤cyB=${USDT.address}`) - await page.getByRole('button', { name: 'Continue' }).click() - await page.getByTestId(TestID.AmountInputIn).last().click() - await page.getByTestId(TestID.AmountInputIn).last().fill('10000') - await page.getByTestId(TestID.AmountInputIn).first().click() - await page.getByTestId(TestID.AmountInputIn).first().fill('1') - await page.getByRole('button', { name: 'Review' }).click() - await page.getByRole('button', { name: 'Create' }).click() - await expect(page.getByText('Creating position')).toBeVisible() - }) - }) - - test.describe('Custom fee tier', () => { - test('should create a position with a custom fee tier', async ({ page, anvil }) => { +test.describe( + 'Create position', + { + tag: '@team:apps-lp', + annotation: [ + { type: 'DD_TAGS[team]', description: 'apps-lp' }, + { type: 'DD_TAGS[test.type]', description: 'web-e2e' }, + ], + }, + () => { + test('Create position with full range', async ({ page, anvil, graphql }) => { await stubTradingApiEndpoint({ page, endpoint: uniswapUrls.tradingApiPaths.createLp, modifyResponseData: modifyGasLimit, }) + await graphql.intercept('SearchTokens', Mocks.Token.search_token_tether) await anvil.setErc20Balance({ address: assume0xAddress(USDT.address), balance: ONE_MILLION_USDT }) - await page.goto(`/positions/create?currencyA=NATIVE¤cyB=${USDT.address}`) - await page.getByRole('button', { name: 'More', exact: true }).click() - await page.getByText('Search or create other fee').click() - await page.getByRole('button', { name: 'Create new fee tier' }).click() - await page.getByPlaceholder('0').fill('3.1415') - await page.getByRole('button', { name: 'Create new fee tier' }).click() - await expect(page.getByText('New tier').first()).toBeVisible() - await expect(page.getByText('Creating new pool')).toBeVisible() + await page.goto('/positions/create') + await page.getByRole('button', { name: 'Choose token' }).click() + await page.getByTestId(TestID.ExploreSearchInput).fill(USDT.address) + // eslint-disable-next-line + await page.getByTestId('token-option-1-USDT').first().click() await page.getByRole('button', { name: 'Continue' }).click() - // Set initial price for new pool - await page.getByPlaceholder('0').first().fill(DEFAULT_INITIAL_POOL_PRICE) + await graphql.waitForResponse('PoolPriceHistory') + await graphql.waitForResponse('AllV4Ticks') + await page.getByText('Full range').click() await reviewAndCreatePosition({ page }) }) - test('should create a position with a custom fee tier and a dynamic fee tier', async ({ page, anvil }) => { - const HOOK_ADDRESS = '0x09DEA99D714A3a19378e3D80D1ad22Ca46085080' + test('Create position with custom range', async ({ page, anvil, graphql }) => { await stubTradingApiEndpoint({ page, endpoint: uniswapUrls.tradingApiPaths.createLp, modifyResponseData: modifyGasLimit, }) + await graphql.intercept('SearchTokens', Mocks.Token.search_token_tether) await anvil.setErc20Balance({ address: assume0xAddress(USDT.address), balance: ONE_MILLION_USDT }) - await page.goto(`/positions/create?currencyA=NATIVE¤cyB=${USDT.address}&hook=${HOOK_ADDRESS}`) - await page.getByRole('button', { name: 'More', exact: true }).click() - await page.getByText('Search or create other fee').click() - await page.getByText('Dynamic fee').click() - await page.getByRole('button', { name: 'Continue' }).click() + await page.goto('/positions/create') + await page.getByRole('button', { name: 'Choose token' }).click() + await page.getByTestId(TestID.ExploreSearchInput).fill(USDT.address) + // eslint-disable-next-line + await page.getByTestId('token-option-1-USDT').first().click() await page.getByRole('button', { name: 'Continue' }).click() - await page.getByRole('button', { name: 'Continue' }).click() - // Set initial price for new pool - await page.getByPlaceholder('0').first().fill(DEFAULT_INITIAL_POOL_PRICE) + await graphql.waitForResponse('PoolPriceHistory') + await graphql.waitForResponse('AllV4Ticks') + await page.getByTestId(TestID.RangeInputIncrement + '-0').click() + await page.getByTestId(TestID.RangeInputDecrement + '-1').click() await reviewAndCreatePosition({ page }) }) - }) -}) + + test.describe('v2 zero liquidity', () => { + test('should create a position', async ({ page, anvil }) => { + await anvil.setErc20Balance({ address: assume0xAddress(WETH_ADDRESS), balance: parseEther('100') }) + await anvil.setErc20Balance({ address: assume0xAddress(USDT.address), balance: ONE_MILLION_USDT }) + await anvil.setV2PoolReserves({ + pairAddress: assume0xAddress( + computePairAddress({ + factoryAddress: V2_FACTORY_ADDRESSES[UniverseChainId.Mainnet], + tokenA: WETH, + tokenB: USDT, + }), + ), + reserve0: 0n, + reserve1: 0n, + }) + await page.goto(`/positions/create/v2?currencyA=${WETH_ADDRESS}¤cyB=${USDT.address}`) + await page.getByRole('button', { name: 'Continue' }).click() + await page.getByTestId(TestID.AmountInputIn).last().click() + await page.getByTestId(TestID.AmountInputIn).last().fill('10000') + await page.getByTestId(TestID.AmountInputIn).first().click() + await page.getByTestId(TestID.AmountInputIn).first().fill('1') + await page.getByRole('button', { name: 'Review' }).click() + await page.getByRole('button', { name: 'Create' }).click() + await expect(page.getByText('Creating position')).toBeVisible() + }) + }) + + test.describe('v2 no pair', () => { + test('should create a pair', async ({ page, anvil }) => { + // random coins that are unlikely to have a v2 pair + const randomCoin1 = '0x1aBaEA1f7C830bD89Acc67eC4af516284b1bC33c' + const randomCoin2 = '0x3081f70000e8CF8Be2aFCaE3Db6B9D9c796CaEc5' + + await anvil.setErc20Balance({ address: assume0xAddress(WETH_ADDRESS), balance: parseEther('100') }) + await page.goto( + `/positions/create/v2?currencyA=${randomCoin1}¤cyB=${randomCoin2}&chain=ethereum&fee=undefined&hook=undefined&priceRangeState={"priceInverted":false,"fullRange":false,"minPrice":"","maxPrice":"","initialPrice":"","inputMode":"price"}&depositState={"exactField":"TOKEN0","exactAmounts":{}}`, + ) + await expect(page.getByText('Creating new pool').first()).toBeVisible() + await page.getByRole('button', { name: 'Continue' }).click() + await expect(page.url()).toContain('step=1') + }) + }) + + test.describe('Custom fee tier', () => { + test('should create a position with a custom fee tier', async ({ page, anvil }) => { + await stubTradingApiEndpoint({ + page, + endpoint: uniswapUrls.tradingApiPaths.createLp, + modifyResponseData: modifyGasLimit, + }) + await anvil.setErc20Balance({ address: assume0xAddress(USDT.address), balance: ONE_MILLION_USDT }) + await page.goto(`/positions/create?currencyA=NATIVE¤cyB=${USDT.address}`) + await page.getByRole('button', { name: 'More', exact: true }).click() + await page.getByText('Search or create other fee').click() + await page.getByRole('button', { name: 'Create new fee tier' }).click() + await page.getByPlaceholder('0').fill('3.1415') + await page.getByRole('button', { name: 'Create new fee tier' }).click() + await expect(page.getByText('New tier').first()).toBeVisible() + await expect(page.getByText('Creating new pool')).toBeVisible() + await page.getByRole('button', { name: 'Continue' }).click() + await reviewAndCreatePosition({ page }) + }) + + test('should create a position with a dynamic fee tier', async ({ page, anvil }) => { + const HOOK_ADDRESS = '0x09DEA99D714A3a19378e3D80D1ad22Ca46085080' + await stubTradingApiEndpoint({ + page, + endpoint: uniswapUrls.tradingApiPaths.createLp, + modifyResponseData: modifyGasLimit, + }) + await anvil.setErc20Balance({ address: assume0xAddress(USDT.address), balance: ONE_MILLION_USDT }) + await page.goto(`/positions/create?currencyA=NATIVE¤cyB=${USDT.address}&hook=${HOOK_ADDRESS}`) + await page.getByRole('button', { name: 'More', exact: true }).click() + await page.getByText('Search or create other fee').click() + await page.getByText('Dynamic fee').click() + await page.getByTestId(TestID.DynamicFeeTierSpeedbumpContinue).click() + await page.getByRole('button', { name: 'Continue' }).click() + await page.getByTestId(TestID.HookModalContinueButton).click() + await reviewAndCreatePosition({ page }) + }) + }) + }, +) async function reviewAndCreatePosition({ page }: { page: Page }) { await page.getByTestId(TestID.AmountInputIn).first().click() diff --git a/apps/web/src/pages/CreatePosition/CreatePosition.e2e.test.ts b/apps/web/src/pages/CreatePosition/CreatePosition.e2e.test.ts index 5553e26635b..1934a733677 100644 --- a/apps/web/src/pages/CreatePosition/CreatePosition.e2e.test.ts +++ b/apps/web/src/pages/CreatePosition/CreatePosition.e2e.test.ts @@ -1,7 +1,8 @@ -import { DYNAMIC_FEE_DATA } from 'components/Liquidity/Create/types' -import ms from 'ms' +import { FeatureFlags } from '@universe/gating' +import { DEFAULT_FEE_DATA, DYNAMIC_FEE_DATA } from 'components/Liquidity/Create/types' import { expect, getTest, type Page } from 'playwright/fixtures' import { stubTradingApiEndpoint } from 'playwright/fixtures/tradingApi' +import { createTestUrlBuilder } from 'playwright/fixtures/urls' import { DAI, USDC_UNICHAIN, USDT } from 'uniswap/src/constants/tokens' import { uniswapUrls } from 'uniswap/src/constants/urls' import { WETH } from 'uniswap/src/test/fixtures/lib/sdk' @@ -9,543 +10,402 @@ import { TestID } from 'uniswap/src/test/fixtures/testIDs' const test = getTest() -const WETH_ADDRESS = WETH.address - -test.describe('Create position', () => { - test.describe('URL state persistence', () => { - test.describe('Backwards compatibility', () => { - test('feeTier and isDynamic', async ({ page }) => { - const UNICHAIN_WBTC_ADDRESS = '0x0555E30da8f98308EdB960aa94C0Db47230d2B9c' - - await page.goto( - `/positions/create?currencyA=NATIVE¤cyB=${UNICHAIN_WBTC_ADDRESS}&feeTier=10000&chain=unichain`, - ) - await expect(page.getByRole('button', { name: 'ETH' })).toBeVisible() - await expect(page.getByRole('button', { name: 'WBTC' })).toBeVisible() - await expect(page.getByText('1% fee tier')).toBeVisible() - - await page.goto( - `/positions/create?currencyA=NATIVE¤cyB=${UNICHAIN_WBTC_ADDRESS}&feeTier=${DYNAMIC_FEE_DATA.feeAmount}&chain=unichain&hook=0xA0b0D2d00fD544D8E0887F1a3cEDd6e24Baf10cc`, - ) - await expect(page.getByText('Dynamic fee tier')).toBeVisible() - await expect(page.getByRole('button', { name: '0xA0b0...10cc' })).toBeVisible() - - // Unichain WBTC should not load on mainnet, but ETH should - await page.goto(`/positions/create?currencyA=NATIVE¤cyB=${UNICHAIN_WBTC_ADDRESS}&chain=mainnet`) - await expect(page.getByRole('button', { name: 'ETH' })).toBeVisible() - await expect(page.getByRole('button', { name: 'WBTC' })).not.toBeVisible() - }) - - test('currencya and currencyb', async ({ page }) => { - await page.goto(`/positions/create?currencya=NATIVE¤cyb=${USDT.address}`) - await expect(page.getByRole('button', { name: 'ETH' })).toBeVisible() - await expect(page.getByRole('button', { name: 'USDT' })).toBeVisible() - - await page.reload() - const url = new URL(page.url()) - expect(url.searchParams.get('currencyA')).toBe('NATIVE') - expect(url.searchParams.get('currencyB')).toBe(USDT.address) - expect(url.searchParams.get('currencya')).toBe(null) - expect(url.searchParams.get('currencyb')).toBe(null) - }) - }) - - test.describe('Individual field parsing', () => { - test.describe('protocolVersion parsing', () => { - test('parses protocolVersion and resets', async ({ page }) => { - await page.goto(`/positions/create/v2?currencyA=NATIVE¤cyB=${USDT.address}`) - await page.getByRole('button', { name: 'Continue' }).click() - await page.getByRole('button', { name: 'Reset' }).click() - // Confirm reset - await page.getByRole('button', { name: 'Reset' }).click() - const url = new URL(page.url()) - await expect(url.pathname).toBe(`/positions/create/v2`) - await expect(page.getByRole('button', { name: 'New v2 position' })).not.toBeVisible() - }) - }) - - test.describe('tokenA and tokenB parsing', () => { - test('parses native token as tokenA', async ({ page }) => { - await page.goto(`/positions/create/v4?currencyA=NATIVE¤cyB=${USDT.address}`) - - // Verify native ETH is loaded as tokenA - await expect(page.getByRole('button', { name: 'ETH' })).toBeVisible() - await expect(page.getByRole('button', { name: 'USDT' })).toBeVisible() - }) - - test('handles missing currencyA with default token', async ({ page }) => { - await page.goto(`/positions/create/v4?currencyB=${USDT.address}`) - - // Should default to native token when currencyA is missing - await expect(page.getByRole('button', { name: 'ETH' })).toBeVisible() - await expect(page.getByRole('button', { name: 'USDT' })).toBeVisible() - }) - - test('handles missing currencyB', async ({ page }) => { - await page.goto(`/positions/create/v4?currencyA=NATIVE`) - - // Should show tokenA and "Choose token" for tokenB - await expect(page.getByRole('button', { name: 'ETH' })).toBeVisible() - await expect(page.getByRole('button', { name: 'Choose token' })).toBeVisible() - }) - - test('prevents duplicate tokens (same address)', async ({ page }) => { - await page.goto(`/positions/create/v4?currencyA=${USDT.address}¤cyB=${USDT.address}`) - - // Should show USDT for tokenA and "Choose token" for tokenB (duplicate prevented) - await expect(page.getByRole('button', { name: 'USDT' })).toBeVisible() - await expect(page.getByRole('button', { name: 'Choose token' })).toBeVisible() - }) +const buildUrl = createTestUrlBuilder({ + basePath: '/positions/create', + defaultFeatureFlags: { + [FeatureFlags.D3LiquidityRangeChart]: false, + [FeatureFlags.PriceRangeInputV2]: true, + }, +}) - test('prevents ETH/WETH conflicts', async ({ page }) => { - await page.goto(`/positions/create/v4?currencyA=NATIVE¤cyB=${WETH_ADDRESS}`) +const WETH_ADDRESS = WETH.address - // Should show ETH for tokenA and "Choose token" for tokenB (ETH/WETH conflict prevented) - await expect(page.getByRole('button', { name: 'ETH' })).toBeVisible() - await expect(page.getByRole('button', { name: 'Choose token' })).toBeVisible() - }) - }) +test.describe( + 'Create position', + { + tag: '@team:apps-lp', + annotation: [ + { type: 'DD_TAGS[team]', description: 'apps-lp' }, + { type: 'DD_TAGS[test.type]', description: 'web-e2e' }, + ], + }, + () => { + test.describe('URL state parsing and persistence', () => { + test.describe('Backwards compatibility', () => { + test('feeTier and isDynamic', async ({ page }) => { + const UNICHAIN_WBTC_ADDRESS = '0x0555E30da8f98308EdB960aa94C0Db47230d2B9c' - test.describe('fee parsing', () => { - test('parses standard fee tiers', async ({ page }) => { await page.goto( - `/positions/create/v4?currencyA=NATIVE¤cyB=${USDT.address}&fee={"feeAmount":500,"tickSpacing":10,"isDynamic":false}`, + buildUrl({ + queryParams: { + currencyA: 'NATIVE', + currencyB: UNICHAIN_WBTC_ADDRESS, + feeTier: '10000', + chain: 'unichain', + }, + }), ) + await expect(page.getByRole('button', { name: 'ETH' })).toBeVisible() + await expect(page.getByRole('button', { name: 'WBTC' })).toBeVisible() + await expect(page.getByText('1% fee tier')).toBeVisible() - await expect(page.getByText('0.05% fee tier').first()).toBeVisible() - }) - - test('parses dynamic fee tier', async ({ page }) => { await page.goto( - `/positions/create/v4?currencyA=NATIVE¤cyB=0x2416092f143378750bb29b79ed961ab195cceea5&chain=unichain&hook=0x09DEA99D714A3a19378e3D80D1ad22Ca46085080&isDynamic=true&priceRangeState={"priceInverted":false,"fullRange":true,"minPrice":"","maxPrice":"","initialPrice":""}&depositState={"exactField":"TOKEN0","exactAmounts":{}}&fee={"isDynamic":true,"feeAmount":100,"tickSpacing":1}`, + buildUrl({ + queryParams: { + currencyA: 'NATIVE', + currencyB: UNICHAIN_WBTC_ADDRESS, + feeTier: DYNAMIC_FEE_DATA.feeAmount.toString(), + chain: 'unichain', + hook: '0xA0b0D2d00fD544D8E0887F1a3cEDd6e24Baf10cc', + }, + }), ) - await expect(page.getByText('Dynamic fee tier')).toBeVisible() - }) - }) + await expect(page.getByRole('button', { name: '0xA0b0...10cc' })).toBeVisible() - test.describe('hook parsing', () => { - test('parses valid hook address', async ({ page }) => { + // Unichain WBTC should not load on mainnet, but ETH should await page.goto( - `/positions/create/v4?currencyA=NATIVE¤cyB=${USDT.address}&hook=0xA0b0D2d00fD544D8E0887F1a3cEDd6e24Baf10cc`, + buildUrl({ + queryParams: { + currencyA: 'NATIVE', + currencyB: UNICHAIN_WBTC_ADDRESS, + chain: 'mainnet', + }, + }), ) - - await expect(page.getByRole('button', { name: '0xA0b0...10cc' })).toBeVisible() - await expect(page.getByText('Add a hook')).not.toBeVisible() - }) - - test('handles invalid hook address', async ({ page }) => { - await page.goto(`/positions/create/v4?currencyA=NATIVE¤cyB=${USDT.address}&hook=invalid-address`) - - // Should not show any hook button when invalid - await expect(page.getByText('Add a hook')).toBeVisible() - }) - }) - - test.describe('chainId parsing', () => { - test('parses explicit chain parameter', async ({ page }) => { - await page.goto(`/positions/create/v4?currencyA=NATIVE¤cyB=${USDC_UNICHAIN.address}&chain=unichain`) - - // Verify we're on unichain by checking the url - const url = new URL(page.url()) - expect(url.searchParams.get('chain')).toBe('unichain') - }) - - test('uses default chain when missing', async ({ page }) => { - await page.goto(`/positions/create/v4?currencyA=NATIVE¤cyB=${USDT.address}`) - - // Should default to mainnet await expect(page.getByRole('button', { name: 'ETH' })).toBeVisible() - await expect(page.getByRole('button', { name: 'USDT' })).toBeVisible() - }) - - test('handles invalid chain with fallback', async ({ page }) => { - await page.goto(`/positions/create/v4?currencyA=NATIVE¤cyB=${USDT.address}&chain=invalid-chain`) - - // Should fall back to default chain - await expect(page.getByRole('button', { name: 'ETH' })).toBeVisible() - }) - }) - - test.describe('flowStep parsing', () => { - test('parses step 0 (token selection)', async ({ page }) => { - await page.goto(`/positions/create/v4?currencyA=NATIVE¤cyB=${USDT.address}&step=0`) - - await expect(page.getByText('Select pair')).toBeVisible() - await expect(page.getByRole('button', { name: 'Continue' })).toBeVisible() - }) - - test('parses step 1 (price range and deposit)', async ({ page }) => { - await page.goto(`/positions/create/v4?currencyA=NATIVE¤cyB=${USDT.address}&step=1`) - - await expect(page.getByText('Deposit tokens')).toBeVisible() - await expect(page.getByText('Full range').first()).toBeVisible() - }) - - test('handles missing step with default', async ({ page }) => { - await page.goto(`/positions/create/v4?currencyA=NATIVE¤cyB=${USDT.address}`) - - // Should default to step 0 - await expect(page.getByText('Select pair')).toBeVisible() - }) - - test('handles invalid step with default', async ({ page }) => { - await page.goto(`/positions/create/v4?currencyA=NATIVE¤cyB=${USDT.address}&step=99`) - - // Should fall back to default step - await expect(page.getByText('Select pair')).toBeVisible() - }) - - test('historyState is set from URL', async ({ page }) => { - await page.goto(`/positions/create/v4?currencyA=NATIVE¤cyB=${USDT.address}&step=0`) - - await expect(page.getByText('Select pair')).toBeVisible() - - await page.getByRole('button', { name: 'Continue' }).click() - - await expect(page.getByText('Deposit tokens')).toBeVisible() - const url = new URL(page.url()) - expect(url.searchParams.get('step')).toBe('1') - - await page.goBack() - - await expect(page.getByText('Select pair')).toBeVisible() - const url2 = new URL(page.url()) - expect(url2.searchParams.get('step')).toBe('0') - - await page.goForward() - - await expect(page.getByText('Deposit tokens')).toBeVisible() - const url3 = new URL(page.url()) - expect(url3.searchParams.get('step')).toBe('1') + await expect(page.getByRole('button', { name: 'WBTC' })).not.toBeVisible() }) }) - test.describe('loading state', () => { - test('shows loading indicators during token resolution', async ({ page }) => { - // Navigate to a URL with tokens that might take time to load - await page.goto(`/positions/create/v4?currencyA=NATIVE¤cyB=${USDT.address}`) + test('parses token and normalizes currency param capitalization', async ({ page }) => { + await page.goto( + buildUrl({ + subPath: '/v4', + queryParams: { + currencya: 'NATIVE', + currencyb: USDT.address, + }, + }), + ) + // Verify native ETH is loaded as tokenA + await expect(page.getByRole('button', { name: 'ETH' })).toBeVisible() + await expect(page.getByRole('button', { name: 'USDT' })).toBeVisible() - // This is harder to test reliably in e2e since loading is usually fast - // We can at least verify the page loads successfully - await expect(page.getByRole('button', { name: 'ETH' })).toBeVisible() - await expect(page.getByRole('button', { name: 'USDT' })).toBeVisible() - }) + // Reload to verify persistence + await page.reload() + const url = new URL(page.url()) + expect(url.searchParams.get('currencyA')).toBe('NATIVE') + expect(url.searchParams.get('currencyB')).toBe(USDT.address) + expect(url.searchParams.get('currencya')).toBe(null) + expect(url.searchParams.get('currencyb')).toBe(null) }) - }) - - test.describe('Price range state', () => { - test('parses and restores complete priceRange state from URL', async ({ page }) => { - // Test URL with all PriceRangeState fields populated - const testUrl = `/positions/create/v4?currencyA=NATIVE¤cyB=${USDT.address}&step=1&priceRangeState={"priceInverted":true,"fullRange":false,"minPrice":"0.00019382924070396673","maxPrice":"0.000350504530738769","initialPrice":""}&chain=ethereum&hook=undefined&depositState={"exactField":"TOKEN1","exactAmounts":{}}` - await page.goto(testUrl) - - // Verify all price range fields are correctly parsed and applied + test('parses simple query params and resets', async ({ page }) => { + await page.goto( + buildUrl({ + subPath: '/v2', + queryParams: { + currencyB: USDT.address, + }, + }), + ) + // Should default to native token when currencyA is missing + await expect(page.getByRole('button', { name: 'ETH' })).toBeVisible() + await expect(page.getByRole('button', { name: 'USDT' })).toBeVisible() + // Should allow for reset + await page.getByRole('button', { name: 'Continue' }).click() + await page.getByRole('button', { name: 'Reset' }).click() + // Confirm reset + await page.getByRole('button', { name: 'Reset' }).click() const url = new URL(page.url()) - const priceRange = JSON.parse(url.searchParams.get('priceRangeState')!) - - expect(priceRange.priceInverted).toBe(true) - expect(priceRange.fullRange).toBe(false) - expect(priceRange.minPrice).toBe('0.00019382924070396673') - expect(priceRange.maxPrice).toBe('0.000350504530738769') - expect(priceRange.initialPrice).toBe('') - - // Verify UI reflects the parsed state - await expect(page.getByText('ETH = 1 USDT').first()).toBeVisible() // priceInverted: true - await expect(page.getByRole('button', { name: 'Custom range' })).toHaveAttribute('data-state', 'active') // fullRange: false - - const minPriceInput = page.getByTestId(TestID.RangeInput + '-0') - const maxPriceInput = page.getByTestId(TestID.RangeInput + '-1') - await expect(minPriceInput).toHaveValue('0.00019432562') - await expect(maxPriceInput).toHaveValue('0.00034985046') - - // Reload and verify persistence - await page.reload() - - const reloadedUrl = new URL(page.url()) - const reloadedPriceRange = JSON.parse(reloadedUrl.searchParams.get('priceRangeState')!) - - expect(reloadedPriceRange.priceInverted).toBe(true) - expect(reloadedPriceRange.fullRange).toBe(false) - expect(reloadedPriceRange.minPrice).toBe('0.00019382924070396673') - expect(reloadedPriceRange.maxPrice).toBe('0.000350504530738769') - expect(reloadedPriceRange.initialPrice).toBe('') + await expect(url.pathname).toContain(`/positions/create/v2`) + await expect(page.getByRole('button', { name: 'New v2 position' })).not.toBeVisible() }) - test('restores initialPrice from URL with random token', async ({ page }) => { - // Random sh*t coin 0x2621Cb9FE8921351E9558D4CD8666688e1DcD689 - const randomCoin = '0x2621Cb9FE8921351E9558D4CD8666688e1DcD689' - + test('parses complex query params', async ({ page }) => { await page.goto( - `/positions/create/v4?currencyA=NATIVE¤cyB=${randomCoin}&step=1&priceRangeState={"priceInverted":false,"fullRange":false,"minPrice":"2991.7083","maxPrice":"3990.1553","initialPrice":"3500.75","isInitialPriceDirty":true}`, + buildUrl({ + subPath: '/v4', + queryParams: { + currencyA: 'NATIVE', + currencyB: '0x2416092f143378750bb29b79ed961ab195cceea5', + chain: 'unichain', + hook: '0x09DEA99D714A3a19378e3D80D1ad22Ca46085080', + priceRangeState: + '{"priceInverted":true,"fullRange":false,"minPrice":"0.00019382924070396673","maxPrice":"0.000350504530738769","initialPrice":"0.000025"}', + fee: JSON.stringify({ ...DEFAULT_FEE_DATA, isDynamic: true }), + }, + }), ) - - // Verify price inputs are populated - const minPriceInput = page.getByTestId(TestID.RangeInput + '-0') - const maxPriceInput = page.getByTestId(TestID.RangeInput + '-1') - await expect(minPriceInput).toHaveValue('2991.7083') - await expect(maxPriceInput).toHaveValue('3990.1553') - - // Reload and verify all price range state is preserved - await page.reload() - const url = new URL(page.url()) + + // Verify chain + expect(url.searchParams.get('chain')).toBe('unichain') + // Verify fee tier + await expect(page.getByText('Dynamic fee tier')).toBeVisible() + // Verify hook + await expect(page.getByRole('button', { name: '0x09DE' })).toBeVisible() + await expect(page.getByText('Add a hook')).not.toBeVisible() + // Continue to second step + await page.getByRole('button', { name: 'Continue' }).click() + // Hook confirmation modal must be dismissed + await page.getByTestId(TestID.HookModalContinueButton).click() + + // Verify price range state const priceRange = JSON.parse(url.searchParams.get('priceRangeState')!) - expect(priceRange.minPrice).toBe('2991.7083') - expect(priceRange.maxPrice).toBe('3990.1553') - expect(priceRange.initialPrice).toBe('3500.75') - expect(priceRange.fullRange).toBe(false) - expect(priceRange.priceInverted).toBe(false) - expect(priceRange.isInitialPriceDirty).toBe(true) - - await expect(minPriceInput).toHaveValue('2991.7083') - await expect(maxPriceInput).toHaveValue('3990.1553') + expect(priceRange.priceInverted, 'priceInverted').toBe(true) + expect(priceRange.fullRange, 'fullRange').toBe(false) + expect(priceRange.minPrice, 'minPrice').toBe('0.00019382924070396673') + expect(priceRange.maxPrice, 'maxPrice').toBe('0.000350504530738769') + expect(priceRange.initialPrice, 'initialPrice').toBe('0.000025') + const minPriceInput = page.getByTestId(TestID.RangeInput + '-0').first() + const maxPriceInput = page.getByTestId(TestID.RangeInput + '-1').first() + await expect(minPriceInput).toHaveValue(/0\.000193/) + await expect(maxPriceInput).toHaveValue(/0\.000350/) }) - }) - test.describe('Deposit state', () => { - test('parses and restores complete depositState from URL with TOKEN0 exact field', async ({ page }) => { - // Test URL with TOKEN0 as exact field (TOKEN1 will be calculated) - const testUrl = `/positions/create/v4?currencyA=NATIVE¤cyB=${USDT.address}&step=1&depositState={"exactField":"TOKEN0","exactAmounts":{"TOKEN0":"1.25","TOKEN1":""}}` - - await page.goto(testUrl) - - // Verify deposit state fields are correctly parsed + test('handles step 1 data correctly', async ({ page }) => { + await page.goto( + buildUrl({ + subPath: '/v4', + queryParams: { + step: '1', + currencyA: 'NATIVE', + currencyB: USDT.address, + depositState: '{"exactField":"TOKEN0","exactAmounts":{"TOKEN0":"1.25"}}', + }, + }), + ) const url = new URL(page.url()) + // Verify deposit state const depositState = JSON.parse(url.searchParams.get('depositState')!) - - expect(depositState.exactField).toBe('TOKEN0') - expect(depositState.exactAmounts.TOKEN0).toBe('1.25') - expect(depositState.exactAmounts.TOKEN1).toBe('') - - // Verify UI reflects the parsed state + expect(depositState.exactField, 'exactField').toBe('TOKEN0') + expect(depositState.exactAmounts.TOKEN0, 'exactAmounts.TOKEN0').toBe('1.25') const ethInput = page.getByTestId(TestID.AmountInputIn).first() - - await expect(ethInput).toHaveValue('1.25') - - // Reload and verify persistence - await page.reload() - - const reloadedUrl = new URL(page.url()) - const reloadedDepositState = JSON.parse(reloadedUrl.searchParams.get('depositState')!) - - expect(reloadedDepositState.exactField).toBe('TOKEN0') - expect(reloadedDepositState.exactAmounts.TOKEN0).toBe('1.25') - expect(reloadedDepositState.exactAmounts.TOKEN1).toBe('') - await expect(ethInput).toHaveValue('1.25') }) - test('parses and restores complete depositState from URL with TOKEN1 exact field', async ({ page }) => { - // Test URL with TOKEN1 as exact field (TOKEN0 will be calculated) - const testUrl = `/positions/create/v4?currencyA=NATIVE¤cyB=${USDT.address}&step=1&depositState={"exactField":"TOKEN1","exactAmounts":{"TOKEN0":"","TOKEN1":"3500.50"}}` - - await page.goto(testUrl) - - // Verify deposit state fields are correctly parsed - const url = new URL(page.url()) - const depositState = JSON.parse(url.searchParams.get('depositState')!) - - expect(depositState.exactField).toBe('TOKEN1') - expect(depositState.exactAmounts.TOKEN0).toBe('') - expect(depositState.exactAmounts.TOKEN1).toBe('3500.50') + test('historyState is set from URL', async ({ page }) => { + await page.goto( + buildUrl({ + subPath: '/v4', + queryParams: { + currencyA: 'NATIVE', + currencyB: USDT.address, + step: '0', + }, + }), + ) - // Verify UI reflects the parsed state - const usdtInput = page.getByTestId(TestID.AmountInputIn).last() + await expect(page.getByText('Select pair')).toBeVisible() - await expect(usdtInput).toHaveValue('3500.50') + await page.getByRole('button', { name: 'Continue' }).click() - // Reload and verify persistence - await page.reload() + await expect(page.getByText('Deposit tokens')).toBeVisible() + const url = new URL(page.url()) + expect(url.searchParams.get('step')).toBe('1') - const reloadedUrl = new URL(page.url()) - const reloadedDepositState = JSON.parse(reloadedUrl.searchParams.get('depositState')!) + await page.goBack() - expect(reloadedDepositState.exactField).toBe('TOKEN1') - expect(reloadedDepositState.exactAmounts.TOKEN0).toBe('') - expect(reloadedDepositState.exactAmounts.TOKEN1).toBe('3500.50') + await expect(page.getByText('Select pair')).toBeVisible() + const url2 = new URL(page.url()) + expect(url2.searchParams.get('step')).toBe('0') - await expect(usdtInput).toHaveValue('3500.50') - }) - }) - }) + await page.goForward() - test.describe('Token sorting', () => { - test.describe('V4', () => { - test.describe('Native', () => { - test('token0 and token1 are sorted', async ({ page }) => { - await page.goto(`/positions/create/v4?currencyA=NATIVE¤cyB=${USDT.address}`) - await page.getByRole('button', { name: 'Continue' }).click() - await expect(page.getByText('ETH/USDT')).toBeVisible() - await expect(page.getByText('USDT/ETH')).not.toBeVisible() - }) - test('token0 and token1 are not sorted', async ({ page }) => { - await page.goto(`/positions/create/v4?currencyA=${USDT.address}¤cyB=NATIVE`) - await page.getByRole('button', { name: 'Continue' }).click() - await expect(page.getByText('ETH/USDT')).toBeVisible() - await expect(page.getByText('USDT/ETH')).not.toBeVisible() - }) + await expect(page.getByText('Deposit tokens')).toBeVisible() + const url3 = new URL(page.url()) + expect(url3.searchParams.get('step')).toBe('1') }) - // DAI: 0x6 - // USDT: 0xd - test.describe('Non-native', () => { - test('token0 and token1 are sorted', async ({ page }) => { - await page.goto(`/positions/create/v4?currencyA=${USDT.address}¤cyB=${DAI.address}`) - await page.getByRole('button', { name: 'Continue' }).click() - await expect(page.getByText('DAI/USDT')).toBeVisible() - await expect(page.getByText('USDT/DAI')).not.toBeVisible() - }) + test('prevents invalid params', async ({ page }) => { + // Duplicated token addresses and invalid param values + await page.goto( + buildUrl({ + subPath: '/v4', + queryParams: { + currencyA: USDT.address, + currencyB: USDT.address, + hook: 'invalid-address', + chain: 'invalid-chain', + step: '99', + }, + }), + ) + // Should show USDT for tokenA and "Choose token" for tokenB (duplicate prevented) + await expect(page.getByRole('button', { name: 'USDT' })).toBeVisible() + await expect(page.getByRole('button', { name: 'Choose token' })).toBeVisible() + // Should not show any hook button when invalid + await expect(page.getByText('Add a hook')).toBeVisible() + // Should fall back to default step + await expect(page.getByText('Select pair')).toBeVisible() - test('token0 and token1 are not sorted', async ({ page }) => { - await page.goto(`/positions/create/v4?currencyA=${DAI.address}¤cyB=${USDT.address}`) - await page.getByRole('button', { name: 'Continue' }).click() - await expect(page.getByText('DAI/USDT')).toBeVisible() - await expect(page.getByText('USDT/DAI')).not.toBeVisible() - }) + // ETH/WETH conflicts + await page.goto( + buildUrl({ + subPath: '/v4', + queryParams: { + currencyA: 'NATIVE', + currencyB: WETH_ADDRESS, + }, + }), + ) + // Should show ETH for tokenA and "Choose token" for tokenB (ETH/WETH conflict prevented) + await expect(page.getByRole('button', { name: 'ETH' })).toBeVisible() + await expect(page.getByRole('button', { name: 'Choose token' })).toBeVisible() }) }) - test.describe('V3', () => { - test.describe('Native', () => { - test('token0 and token1 are sorted', async ({ page }) => { - await page.goto(`/positions/create/v3?currencyA=NATIVE¤cyB=${USDT.address}`) - await page.getByRole('button', { name: 'Continue' }).click() - await expect(page.getByText('ETH/USDT')).toBeVisible() - await expect(page.getByText('USDT/ETH')).not.toBeVisible() - }) - - test('token0 and token1 are not sorted', async ({ page }) => { - await page.goto(`/positions/create/v3?currencyA=${USDT.address}¤cyB=NATIVE`) - await page.getByRole('button', { name: 'Continue' }).click() - await expect(page.getByText('ETH/USDT')).toBeVisible() - await expect(page.getByText('USDT/ETH')).not.toBeVisible() - }) - }) - - test.describe('Wrapped Native', () => { - test('token0 and token1 are sorted', async ({ page }) => { - await page.goto(`/positions/create/v3?currencyA=${WETH_ADDRESS}¤cyB=${USDT.address}`) - await page.getByRole('button', { name: 'Continue' }).click() - await expect(page.getByText('WETH/USDT')).toBeVisible() - await expect(page.getByText('USDT/WETH')).not.toBeVisible() - }) - - test('token0 and token1 are not sorted', async ({ page }) => { - await page.goto(`/positions/create/v3?currencyA=${USDT.address}¤cyB=${WETH_ADDRESS}`) + test.describe('Token sorting', () => { + test.describe('V4', () => { + test('native token0 and token1 are sorted correctly', async ({ page }) => { + await page.goto( + buildUrl({ + subPath: '/v4', + queryParams: { + currencyA: 'NATIVE', + currencyB: USDT.address, + }, + }), + ) await page.getByRole('button', { name: 'Continue' }).click() - await expect(page.getByText('ETH/USDT')).toBeVisible() - await expect(page.getByText('USDT/ETH')).not.toBeVisible() - }) - }) + await expect(page.getByTestId(TestID.PoolPairLabel)).toHaveText(/ETH.*USDT/) + await expect(page.getByTestId(TestID.PoolPairLabel)).not.toHaveText(/USDT.*ETH/) - // DAI: 0x6 - // USDT: 0xd - test.describe('Non-native', () => { - test('token0 and token1 are sorted', async ({ page }) => { - await page.goto(`/positions/create/v3?currencyA=${USDT.address}¤cyB=${DAI.address}`) - await page.getByRole('button', { name: 'Continue' }).click() - await expect(page.getByText('DAI/USDT')).toBeVisible() - await expect(page.getByText('USDT/DAI')).not.toBeVisible() - }) - - test('token0 and token1 are not sorted', async ({ page }) => { - await page.goto(`/positions/create/v3?currencyA=${DAI.address}¤cyB=${USDT.address}`) + await page.goto( + buildUrl({ + subPath: '/v4', + queryParams: { + currencyA: USDT.address, + currencyB: 'NATIVE', + }, + }), + ) await page.getByRole('button', { name: 'Continue' }).click() - await expect(page.getByText('DAI/USDT')).toBeVisible() - await expect(page.getByText('USDT/DAI')).not.toBeVisible() + await expect(page.getByTestId(TestID.PoolPairLabel)).toHaveText(/ETH.*USDT/) + await expect(page.getByTestId(TestID.PoolPairLabel)).not.toHaveText(/USDT.*ETH/) }) - }) - }) - test.describe('V2', () => { - test.describe('Native', () => { - test('token0 and token1 are sorted', async ({ page }) => { - await page.goto(`/positions/create/v2?currencyA=NATIVE¤cyB=${USDT.address}`) + test('Non-native token0 and token1 are sorted', async ({ page }) => { + await page.goto( + buildUrl({ + subPath: '/v4', + queryParams: { + currencyA: USDT.address, + currencyB: DAI.address, + }, + }), + ) await page.getByRole('button', { name: 'Continue' }).click() - await expect(page.getByText('ETH/USDT')).toBeVisible() - await expect(page.getByText('USDT/ETH')).not.toBeVisible() - }) + await expect(page.getByTestId(TestID.PoolPairLabel)).toHaveText(/DAI.*USDT/) + await expect(page.getByTestId(TestID.PoolPairLabel)).not.toHaveText(/USDT.*DAI/) - test('token0 and token1 are not sorted', async ({ page }) => { - await page.goto(`/positions/create/v2?currencyA=${USDT.address}¤cyB=NATIVE`) + await page.goto( + buildUrl({ + subPath: '/v4', + queryParams: { + currencyA: DAI.address, + currencyB: USDT.address, + }, + }), + ) await page.getByRole('button', { name: 'Continue' }).click() - await expect(page.getByText('ETH/USDT')).toBeVisible() - await expect(page.getByText('USDT/ETH')).not.toBeVisible() + await expect(page.getByTestId(TestID.PoolPairLabel)).toHaveText(/DAI.*USDT/) + await expect(page.getByTestId(TestID.PoolPairLabel)).not.toHaveText(/USDT.*DAI/) }) }) - test.describe('Wrapped Native', () => { - test('token0 and token1 are sorted', async ({ page }) => { - await page.goto(`/positions/create/v2?currencyA=${WETH_ADDRESS}¤cyB=${USDT.address}`) - await page.getByRole('button', { name: 'Continue' }).click() - await expect(page.getByText('WETH/USDT')).toBeVisible() - await expect(page.getByText('USDT/WETH')).not.toBeVisible() - }) - - test('token0 and token1 are not sorted', async ({ page }) => { - await page.goto(`/positions/create/v2?currencyA=${USDT.address}¤cyB=${WETH_ADDRESS}`) + test.describe('V3', () => { + test('native token0 and token1 are sorted correctly', async ({ page }) => { + await page.goto( + buildUrl({ + subPath: '/v3', + queryParams: { + currencyA: USDT.address, + currencyB: 'NATIVE', + }, + }), + ) await page.getByRole('button', { name: 'Continue' }).click() - await expect(page.getByText('ETH/USDT')).toBeVisible() - await expect(page.getByText('USDT/WETH')).not.toBeVisible() + await expect(page.getByTestId(TestID.PoolPairLabel)).toHaveText(/ETH.*USDT/) + await expect(page.getByTestId(TestID.PoolPairLabel)).not.toHaveText(/USDT.*ETH/) }) - }) - // DAI: 0x6 - // USDT: 0xd - test.describe('Non-native', () => { - test('token0 and token1 are sorted', async ({ page }) => { - await page.goto(`/positions/create/v2?currencyA=${USDT.address}¤cyB=${DAI.address}`) + test('wrapped native token0 and token1 are sorted correctly', async ({ page }) => { + await page.goto( + buildUrl({ + subPath: '/v3', + queryParams: { + currencyA: USDT.address, + currencyB: WETH_ADDRESS, + }, + }), + ) await page.getByRole('button', { name: 'Continue' }).click() - await expect(page.getByText('DAI/USDT')).toBeVisible() - await expect(page.getByText('USDT/DAI')).not.toBeVisible() + await expect(page.getByTestId(TestID.PoolPairLabel)).toHaveText(/WETH.*USDT/) + await expect(page.getByTestId(TestID.PoolPairLabel)).not.toHaveText(/USDT.*WETH/) }) - test('token0 and token1 are not sorted', async ({ page }) => { - await page.goto(`/positions/create/v2?currencyA=${DAI.address}¤cyB=${USDT.address}`) + test('non-native token0 and token1 are sorted correctly', async ({ page }) => { + await page.goto( + buildUrl({ + subPath: '/v3', + queryParams: { + currencyA: USDT.address, + currencyB: DAI.address, + }, + }), + ) await page.getByRole('button', { name: 'Continue' }).click() - await expect(page.getByText('DAI/USDT')).toBeVisible() - await expect(page.getByText('USDT/DAI')).not.toBeVisible() + await expect(page.getByTestId(TestID.PoolPairLabel)).toHaveText(/DAI.*USDT/) + await expect(page.getByTestId(TestID.PoolPairLabel)).not.toHaveText(/USDT.*DAI/) }) }) }) - }) - test.describe('Price range', () => { - test.describe('V4', () => { - test('token0 and token1 are sorted - increment/decrement', async ({ page }) => { - await page.goto(`/positions/create/v4?currencyA=NATIVE¤cyB=${USDT.address}`) + test.describe('Price range', () => { + const priceRangeQueryParams = { + step: '1', + fee: '{"feeAmount":3000,"tickSpacing":60,"isDynamic":false}', + priceRangeState: + '{"priceInverted":false,"fullRange":false,"minPrice":"2500","maxPrice":"5000","initialPrice":""}', + } - await waitUntilInputFilled({ page }) - await incrementDecrementPrice({ page }) - }) - - test('token0 and token1 are not sorted - increment/decrement', async ({ page }) => { - await page.goto(`/positions/create/v4?currencyA=${USDT.address}¤cyB=NATIVE`) - - await waitUntilInputFilled({ page }) - await incrementDecrementPrice({ page }) - }) - }) + test('V4 can increment/decrement price range correctly', async ({ page }) => { + await page.goto( + buildUrl({ + subPath: '/v4', + queryParams: { + currencyA: 'NATIVE', + currencyB: USDT.address, + ...priceRangeQueryParams, + }, + }), + ) - test.describe('V3', () => { - test('token0 and token1 are sorted - increment/decrement', async ({ page }) => { - await stubTradingApiEndpoint({ page, endpoint: uniswapUrls.tradingApiPaths.quote }) - await page.goto(`/positions/create/v3?currencyA=NATIVE¤cyB=${USDT.address}`) - await waitUntilInputFilled({ page }) + await expectInputToBeFilled({ page }) await incrementDecrementPrice({ page }) }) - test('token0 and token1 are not sorted - increment/decrement', async ({ page }) => { + test('V3 can increment/decrement price range correctly', async ({ page }) => { await stubTradingApiEndpoint({ page, endpoint: uniswapUrls.tradingApiPaths.quote }) - await page.goto(`/positions/create/v3?currencyA=${USDT.address}¤cyB=NATIVE`) - await waitUntilInputFilled({ page }) + await page.goto( + buildUrl({ + subPath: '/v3', + queryParams: { + currencyA: USDT.address, + currencyB: 'NATIVE', + ...priceRangeQueryParams, + }, + }), + ) + await expectInputToBeFilled({ page }) await incrementDecrementPrice({ page }) }) }) - }) -}) + }, +) async function incrementDecrementPrice({ page }: { page: Page }) { // Decrement and increment the min price @@ -571,10 +431,7 @@ async function incrementDecrementPrice({ page }: { page: Page }) { expect(Number(higherMaxPrice)).toBeGreaterThan(Number(lowerMaxPrice)) } -async function waitUntilInputFilled({ page }: { page: Page }) { - await page.getByRole('button', { name: 'Continue' }).click() - await page.waitForTimeout(ms('2s')) - await page.getByText('Custom range').click() +async function expectInputToBeFilled({ page }: { page: Page }) { await expect(async () => { const minValue = await page.getByTestId(TestID.RangeInput + '-0').inputValue() const maxValue = await page.getByTestId(TestID.RangeInput + '-1').inputValue() diff --git a/apps/web/src/pages/CreatePosition/CreatePositionModal.tsx b/apps/web/src/pages/CreatePosition/CreatePositionModal.tsx index 6ff6df08151..7fbbc4e6f56 100644 --- a/apps/web/src/pages/CreatePosition/CreatePositionModal.tsx +++ b/apps/web/src/pages/CreatePosition/CreatePositionModal.tsx @@ -1,47 +1,25 @@ -import { ProtocolVersion } from '@uniswap/client-data-api/dist/data/v1/poolTypes_pb' -import { Currency, CurrencyAmount } from '@uniswap/sdk-core' -import { - getLiquidityRangeChartProps, - WrappedLiquidityPositionRangeChart, -} from 'components/Charts/LiquidityPositionRangeChart/LiquidityPositionRangeChart' -import { ErrorCallout } from 'components/ErrorCallout' import { getLPBaseAnalyticsProperties } from 'components/Liquidity/analytics' -import { BaseQuoteFiatAmount } from 'components/Liquidity/BaseQuoteFiatAmount' -import { PoolOutOfSyncError } from 'components/Liquidity/Create/PoolOutOfSyncError' -import { LiquidityPositionInfoBadges } from 'components/Liquidity/LiquidityPositionInfoBadges' +import { ReviewModal, ReviewModalProps } from 'components/Liquidity/ReviewModal' import { getPoolIdOrAddressFromCreatePositionInfo } from 'components/Liquidity/utils/getPoolIdOrAddressFromCreatePositionInfo' -import { DoubleCurrencyLogo } from 'components/Logo/DoubleLogo' -import { DetailLineItem } from 'components/swap/DetailLineItem' -import { useCurrencyInfo } from 'hooks/Tokens' import { useAccount } from 'hooks/useAccount' import useSelectChain from 'hooks/useSelectChain' import { useCreateLiquidityContext } from 'pages/CreatePosition/CreateLiquidityContextProvider' -import { useCallback, useMemo, useState } from 'react' -import { Trans, useTranslation } from 'react-i18next' +import { useSetOverrideOneClickSwapFlag } from 'pages/Swap/settings/OneClickSwap' +import { useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' import { useDispatch } from 'react-redux' import { useNavigate } from 'react-router' import { liquiditySaga } from 'state/sagas/liquidity/liquiditySaga' -import { PositionField } from 'types/position' -import { Button, Flex, Separator, Text } from 'ui/src' -import { Passkey } from 'ui/src/components/icons/Passkey' -import { iconSizes } from 'ui/src/theme' -import { ProgressIndicator } from 'uniswap/src/components/ConfirmSwapModal/ProgressIndicator' -import { NetworkLogo } from 'uniswap/src/components/CurrencyLogo/NetworkLogo' -import { TokenLogo } from 'uniswap/src/components/CurrencyLogo/TokenLogo' -import { GetHelpHeader } from 'uniswap/src/components/dialog/GetHelpHeader' -import { Modal } from 'uniswap/src/components/modals/Modal' -import { useLocalizationContext } from 'uniswap/src/features/language/LocalizationContext' -import { useGetPasskeyAuthStatus } from 'uniswap/src/features/passkey/hooks/useGetPasskeyAuthStatus' import { ModalName } from 'uniswap/src/features/telemetry/constants' import { CreatePositionTxAndGasInfo, isValidLiquidityTxContext, + MigratePositionTxAndGasInfo, } from 'uniswap/src/features/transactions/liquidity/types' import { getErrorMessageToDisplay } from 'uniswap/src/features/transactions/liquidity/utils' import { TransactionStep } from 'uniswap/src/features/transactions/steps/types' import { useWallet } from 'uniswap/src/features/wallet/hooks/useWallet' import { isSignerMnemonicAccountDetails } from 'uniswap/src/features/wallet/types/AccountDetails' -import { NumberType } from 'utilities/src/format/types' import { useTrace } from 'utilities/src/telemetry/trace/TraceContext' export function CreatePositionModal({ @@ -54,60 +32,30 @@ export function CreatePositionModal({ setTransactionError, isOpen, onClose, -}: { - formattedAmounts?: { [field in PositionField]?: string } - currencyAmounts?: { [field in PositionField]?: Maybe> } - currencyAmountsUSDValue?: { [field in PositionField]?: Maybe> } - txInfo?: CreatePositionTxAndGasInfo - gasFeeEstimateUSD?: Maybe> - transactionError: string | boolean +}: Pick< + ReviewModalProps, + | 'formattedAmounts' + | 'currencyAmounts' + | 'currencyAmountsUSDValue' + | 'gasFeeEstimateUSD' + | 'transactionError' + | 'isOpen' + | 'onClose' +> & { + txInfo?: CreatePositionTxAndGasInfo | MigratePositionTxAndGasInfo setTransactionError: (error: string | boolean) => void - isOpen: boolean - onClose: () => void }) { const { protocolVersion, creatingPoolOrPair, positionState: { fee, hook }, - currentTransactionStep, setCurrentTransactionStep, - price, poolOrPair, ticks, - ticksAtLimit, - pricesAtTicks, - priceRangeState: { priceInverted }, - refetch, } = useCreateLiquidityContext() const { t } = useTranslation() - const token0 = currencyAmounts?.TOKEN0?.currency - const token1 = currencyAmounts?.TOKEN1?.currency - const token0CurrencyInfo = useCurrencyInfo(token0) - const token1CurrencyInfo = useCurrencyInfo(token1) - const chainId = token0?.chainId - - const { formatNumberOrString, formatCurrencyAmount } = useLocalizationContext() - - const baseCurrency = price?.baseCurrency - const quoteCurrency = price?.quoteCurrency - - const formattedPrices = useMemo(() => { - if (protocolVersion === ProtocolVersion.V2) { - return ['', ''] - } - - const lowerPriceFormatted = ticksAtLimit[0] - ? '0' - : formatNumberOrString({ value: pricesAtTicks[0]?.toSignificant(), type: NumberType.TokenTx }) - - const upperPriceFormatted = ticksAtLimit[1] - ? '∞' - : formatNumberOrString({ value: pricesAtTicks[1]?.toSignificant(), type: NumberType.TokenTx }) - - const postfix = `${quoteCurrency?.symbol + '/' + baseCurrency?.symbol}` - return [`${lowerPriceFormatted} ${postfix}`, `${upperPriceFormatted} ${postfix}`] - }, [formatNumberOrString, pricesAtTicks, ticksAtLimit, protocolVersion, baseCurrency, quoteCurrency]) + const disableOneClickSwap = useSetOverrideOneClickSwapFlag() const [steps, setSteps] = useState([]) const dispatch = useDispatch() @@ -117,9 +65,6 @@ export function CreatePositionModal({ const startChainId = connectedAccount.chainId const navigate = useNavigate() const trace = useTrace() - const { isSignedInWithPasskey, isSessionAuthenticated, needsPasskeySignin } = useGetPasskeyAuthStatus( - connectedAccount.connector?.id, - ) const onSuccess = useCallback(() => { setSteps([]) @@ -128,22 +73,6 @@ export function CreatePositionModal({ navigate('/positions') }, [setCurrentTransactionStep, onClose, navigate]) - const liquidityRangeChartProps = useMemo( - () => - getLiquidityRangeChartProps({ - protocolVersion, - sdkCurrencies: { - TOKEN0: currencyAmounts?.TOKEN0?.currency, - TOKEN1: currencyAmounts?.TOKEN1?.currency, - }, - ticks, - poolOrPair, - pricesAtTicks, - priceInverted, - }), - [protocolVersion, currencyAmounts, priceInverted, ticks, pricesAtTicks, poolOrPair], - ) - const handleCreate = useCallback(() => { setTransactionError(false) @@ -174,6 +103,7 @@ export function CreatePositionModal({ } setCurrentTransactionStep(undefined) }, + disableOneClickSwap, analytics: { ...getLPBaseAnalyticsProperties({ trace, @@ -181,8 +111,8 @@ export function CreatePositionModal({ version: protocolVersion, tickLower: ticks[0] ?? undefined, tickUpper: ticks[1] ?? undefined, - fee: fee.feeAmount, - tickSpacing: fee.tickSpacing, + fee: fee?.feeAmount, + tickSpacing: fee?.tickSpacing, currency0: currencyAmounts.TOKEN0.currency, currency1: currencyAmounts.TOKEN1.currency, currency0AmountUsd: currencyAmountsUSDValue?.TOKEN0, @@ -214,8 +144,8 @@ export function CreatePositionModal({ setCurrentTransactionStep, onSuccess, trace, - fee.feeAmount, - fee.tickSpacing, + fee?.feeAmount, + fee?.tickSpacing, ticks, hook, currencyAmountsUSDValue?.TOKEN0, @@ -223,162 +153,24 @@ export function CreatePositionModal({ protocolVersion, creatingPoolOrPair, poolOrPair, + disableOneClickSwap, ]) return ( - - - - - - - } - closeModal={() => onClose()} - /> - - - - - {currencyAmounts?.TOKEN0?.currency.symbol} - / - {currencyAmounts?.TOKEN1?.currency.symbol} - - - - - - - - {(protocolVersion === ProtocolVersion.V3 || protocolVersion === ProtocolVersion.V4) && ( - <> - {!creatingPoolOrPair && !!liquidityRangeChartProps && ( - - )} - - - - - - {formattedPrices[0]} - - - - - - {formattedPrices[1]} - - - - )} - - {creatingPoolOrPair && ( - - - - - - - )} - - - - - {currencyAmounts?.TOKEN0?.greaterThan(0) && ( - - - - {formattedAmounts?.TOKEN0} - {currencyAmounts.TOKEN0.currency.symbol} - - - {formatCurrencyAmount({ value: currencyAmountsUSDValue?.TOKEN0, type: NumberType.FiatTokenPrice })} - - - - - )} - {currencyAmounts?.TOKEN1?.greaterThan(0) && ( - - - - {formattedAmounts?.TOKEN1} - {currencyAmounts.TOKEN1.currency.symbol} - - - {formatCurrencyAmount({ value: currencyAmountsUSDValue?.TOKEN1, type: NumberType.FiatTokenPrice })} - - - - - )} - - - - - - - {currentTransactionStep && steps.length > 1 ? ( - - ) : ( - <> - - - ( - - - - ), - Value: () => ( - - - - {formatCurrencyAmount({ value: gasFeeEstimateUSD, type: NumberType.FiatGasPrice })} - - - ), - }} - /> - - {currentTransactionStep ? ( - - ) : ( - - )} - - )} - - + ) } diff --git a/apps/web/src/pages/CreatePosition/CreatePositionTxContext.tsx b/apps/web/src/pages/CreatePosition/CreatePositionTxContext.tsx index 381072e7788..ca0e94c378f 100644 --- a/apps/web/src/pages/CreatePosition/CreatePositionTxContext.tsx +++ b/apps/web/src/pages/CreatePosition/CreatePositionTxContext.tsx @@ -1,3 +1,4 @@ +/* eslint-disable max-lines */ import { ProtocolVersion } from '@uniswap/client-data-api/dist/data/v1/poolTypes_pb' import { Currency, CurrencyAmount } from '@uniswap/sdk-core' import { Pair } from '@uniswap/v2-sdk' @@ -23,9 +24,10 @@ import { useState, } from 'react' import { PositionField } from 'types/position' -import { useUniswapContext } from 'uniswap/src/contexts/UniswapContext' +import { useUniswapContextSelector } from 'uniswap/src/contexts/UniswapContext' import { useCheckLpApprovalQuery } from 'uniswap/src/data/apiClients/tradingApi/useCheckLpApprovalQuery' import { useCreateLpPositionCalldataQuery } from 'uniswap/src/data/apiClients/tradingApi/useCreateLpPositionCalldataQuery' +import { UniverseChainId } from 'uniswap/src/features/chains/types' import { toSupportedChainId } from 'uniswap/src/features/chains/utils' import { useTransactionGasFee, useUSDCurrencyAmountOfGasFee } from 'uniswap/src/features/gas/hooks' import { InterfaceEventName } from 'uniswap/src/features/telemetry/constants' @@ -49,13 +51,13 @@ export function generateAddLiquidityApprovalParams({ protocolVersion, displayCurrencies, currencyAmounts, - generatePermitAsTransaction, + canBatchTransactions, }: { address?: string protocolVersion: ProtocolVersion displayCurrencies: { [field in PositionField]: Maybe } currencyAmounts?: { [field in PositionField]?: Maybe> } - generatePermitAsTransaction?: boolean + canBatchTransactions?: boolean }): TradingApi.CheckApprovalLPRequest | undefined { const apiProtocolItems = getProtocolItems(protocolVersion) @@ -78,7 +80,7 @@ export function generateAddLiquidityApprovalParams({ token1: getTokenOrZeroAddress(displayCurrencies.TOKEN1), amount0: currencyAmounts.TOKEN0.quotient.toString(), amount1: currencyAmounts.TOKEN1.quotient.toString(), - generatePermitAsTransaction: protocolVersion === ProtocolVersion.V4 ? generatePermitAsTransaction : undefined, + generatePermitAsTransaction: protocolVersion === ProtocolVersion.V4 ? canBatchTransactions : undefined, } satisfies TradingApi.CheckApprovalLPRequest } @@ -226,7 +228,7 @@ export function generateCreateCalldataQueryParams({ tickSpacing, token0: getTokenOrZeroAddress(displayCurrencies.TOKEN0), token1: getTokenOrZeroAddress(displayCurrencies.TOKEN1), - fee: positionState.fee.isDynamic ? DYNAMIC_FEE_DATA.feeAmount : positionState.fee.feeAmount, + fee: positionState.fee?.isDynamic ? DYNAMIC_FEE_DATA.feeAmount : positionState.fee?.feeAmount, hooks: positionState.hook, }, }, @@ -243,6 +245,7 @@ export function generateCreatePositionTxRequest({ createCalldataQueryParams, currencyAmounts, poolOrPair, + canBatchTransactions, }: { protocolVersion: ProtocolVersion approvalCalldata?: TradingApi.CheckApprovalLPResponse @@ -250,6 +253,7 @@ export function generateCreatePositionTxRequest({ createCalldataQueryParams?: TradingApi.CreateLPPositionRequest currencyAmounts?: { [field in PositionField]?: Maybe> } poolOrPair: Pair | undefined + canBatchTransactions: boolean }): CreatePositionTxAndGasInfo | undefined { if (!createCalldata || !currencyAmounts?.TOKEN0 || !currencyAmounts.TOKEN1) { return undefined @@ -296,8 +300,8 @@ export function generateCreatePositionTxRequest({ return { type: LiquidityTransactionType.Create, + canBatchTransactions, unsigned: Boolean(validatedPermitRequest), - protocolVersion, createPositionRequestArgs: queryParams, action: { type: LiquidityTransactionType.Create, @@ -385,7 +389,9 @@ export function CreatePositionTxContextProvider({ children }: PropsWithChildren) customDeadline: s.customDeadline, customSlippageTolerance: s.customSlippageTolerance, })) - const generatePermitAsTransaction = useUniswapContext().getCanSignPermits?.(poolOrPair?.chainId) + const canBatchTransactions = + (useUniswapContextSelector((ctx) => ctx.getCanBatchTransactions?.(poolOrPair?.chainId)) ?? false) && + poolOrPair?.chainId !== UniverseChainId.Monad const [transactionError, setTransactionError] = useState(false) @@ -395,9 +401,9 @@ export function CreatePositionTxContextProvider({ children }: PropsWithChildren) protocolVersion, displayCurrencies: currencies.display, currencyAmounts, - generatePermitAsTransaction, + canBatchTransactions, }) - }, [account?.address, protocolVersion, currencies.display, currencyAmounts, generatePermitAsTransaction]) + }, [account?.address, protocolVersion, currencies.display, currencyAmounts, canBatchTransactions]) const { data: approvalCalldata, @@ -550,8 +556,17 @@ export function CreatePositionTxContextProvider({ children }: PropsWithChildren) createCalldataQueryParams, currencyAmounts, poolOrPair: protocolVersion === ProtocolVersion.V2 ? poolOrPair : undefined, + canBatchTransactions, }) - }, [approvalCalldata, createCalldata, createCalldataQueryParams, currencyAmounts, poolOrPair, protocolVersion]) + }, [ + approvalCalldata, + createCalldata, + createCalldataQueryParams, + currencyAmounts, + poolOrPair, + protocolVersion, + canBatchTransactions, + ]) const value = useMemo( (): CreatePositionTxContextType => ({ diff --git a/apps/web/src/pages/Errors.anvil.e2e.test.ts b/apps/web/src/pages/Errors.anvil.e2e.test.ts index 918c0edf368..d5ee90dea4b 100644 --- a/apps/web/src/pages/Errors.anvil.e2e.test.ts +++ b/apps/web/src/pages/Errors.anvil.e2e.test.ts @@ -1,137 +1,57 @@ import { rejectNextTransaction } from 'components/Web3Provider/rejectableConnector' import { expect, getTest } from 'playwright/fixtures' -import { stubTradingApiEndpoint } from 'playwright/fixtures/tradingApi' -import { TEST_WALLET_ADDRESS } from 'playwright/fixtures/wallets' -import { USDC_MAINNET } from 'uniswap/src/constants/tokens' -import { uniswapUrls } from 'uniswap/src/constants/urls' +import { HAYDEN_ADDRESS, TEST_WALLET_ADDRESS } from 'playwright/fixtures/wallets' import { TestID } from 'uniswap/src/test/fixtures/testIDs' -import { AddressStringFormat, normalizeAddress } from 'uniswap/src/utils/addresses' -import { type HexString } from 'utilities/src/addresses/hex' const test = getTest({ withAnvil: true }) -test.describe('Errors', () => { - test('wallet rejection', async ({ page, anvil }) => { - await stubTradingApiEndpoint({ page, endpoint: uniswapUrls.tradingApiPaths.swap }) - await stubTradingApiEndpoint({ page, endpoint: uniswapUrls.tradingApiPaths.quote }) - - await page.goto(`/swap?inputCurrency=ETH&outputCurrency=${USDC_MAINNET.address}`) - - const nonceBefore = await anvil.getTransactionCount({ address: TEST_WALLET_ADDRESS }) - - // Enter amount to swap - await page.getByTestId(TestID.AmountInputOut).fill('1') - - // Wait for input value to be populated - await expect(page.getByTestId(TestID.AmountInputIn)).toHaveValue(/.+/) - - // Submit transaction - await page.getByTestId(TestID.ReviewSwap).click() - - // Set rejection flag before clicking Swap - await rejectNextTransaction(page) - - await page.getByTestId(TestID.Swap).click() - - await anvil.mine({ blocks: 1 }) - const nonceAfter = await anvil.getTransactionCount({ address: TEST_WALLET_ADDRESS }) - - // Verify transaction was rejected - nonce should not have changed - expect(nonceAfter).toBe(nonceBefore) - }) - - test.skip('transaction past deadline', async ({ page, anvil }) => { - await stubTradingApiEndpoint({ page, endpoint: uniswapUrls.tradingApiPaths.swap }) - await stubTradingApiEndpoint({ - page, - endpoint: uniswapUrls.tradingApiPaths.quote, - modifyRequestData: (data) => ({ - ...data, - protocols: ['V2', 'V3'], - }), +test.describe( + 'Errors', + { + tag: '@team:apps-infra', + annotation: [ + { type: 'DD_TAGS[team]', description: 'apps-infra' }, + { type: 'DD_TAGS[test.type]', description: 'web-e2e' }, + ], + }, + () => { + test('wallet rejection', async ({ page, anvil }) => { + await page.goto('/send') + + const nonceBefore = await anvil.getTransactionCount({ address: TEST_WALLET_ADDRESS }) + + // Fill in amount to send + await page.getByTestId(TestID.SendFormAmountInput).click() + await page.getByTestId(TestID.SendFormAmountInput).fill('10') + + // Fill in recipient address + const recipientInput = page.getByPlaceholder(/address or ens/i) + await recipientInput.click() + await recipientInput.fill(HAYDEN_ADDRESS) + await page.getByText('hayden.eth').click() + + // Wait for send button to be enabled + const sendButton = page.getByRole('button', { name: /^send$/i }) + await expect(sendButton).toBeEnabled() + await sendButton.click() + + // Click Continue on the new address confirmation modal + await page.getByRole('button', { name: /continue/i }).click() + + // Wait for review modal to appear + await expect(page.getByTestId(TestID.SendReviewModal)).toBeVisible() + + // Set rejection flag before confirming send + await rejectNextTransaction(page) + + // Confirm send (this should be rejected) + await page.getByRole('button', { name: /confirm send/i }).click() + + await anvil.mine({ blocks: 1 }) + const nonceAfter = await anvil.getTransactionCount({ address: TEST_WALLET_ADDRESS }) + + // Verify transaction was rejected - nonce should not have changed + expect(nonceAfter).toBe(nonceBefore) }) - - await page.goto(`/swap?inputCurrency=ETH&outputCurrency=${USDC_MAINNET.address}`) - - // Enter amount to swap - await page.getByTestId(TestID.AmountInputOut).fill('1') - - // Wait for input value to be populated - await expect(page.getByTestId(TestID.AmountInputIn)).toHaveValue(/.+/) - - // Submit transaction - await page.getByTestId(TestID.ReviewSwap).click() - await page.getByTestId(TestID.Swap).click() - - // Get the hash of the transaction in the mempool - let hash: HexString | undefined - const startTime = performance.now() - const timeoutMs = 5000 - while (!hash) { - if (performance.now() - startTime > timeoutMs) { - throw new Error('Timeout: Transaction hash not found within 5 seconds') - } - - const poolContent = await anvil.getTxpoolContent() - const currentTransaction = Object.entries( - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - poolContent.pending[normalizeAddress(TEST_WALLET_ADDRESS, AddressStringFormat.Lowercase) as HexString] ?? {}, - )[0] - - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - hash = currentTransaction[1]?.hash - } - - await anvil.dropTransaction({ - hash: hash as HexString, - }) - await anvil.mine({ - blocks: 1, - }) - - // Verify failure state by checking the button text - await expect(page.getByText('Swap failed')).toBeVisible() - }) - - test('slippage failure', async ({ page, anvil }) => { - await stubTradingApiEndpoint({ page, endpoint: uniswapUrls.tradingApiPaths.swap }) - await stubTradingApiEndpoint({ page, endpoint: uniswapUrls.tradingApiPaths.quote }) - - const originalEthBalance = await anvil.getBalance({ address: TEST_WALLET_ADDRESS }) - - await page.goto(`/swap?inputCurrency=ETH&outputCurrency=${USDC_MAINNET.address}`) - - await page.getByTestId(TestID.SwapSettings).click() - await page - .locator('div') - .filter({ hasText: /^5.50$/ }) - .getByRole('textbox') - .fill('.01') - - await page.getByTestId(TestID.SwapSettings).click() - - await page.waitForTimeout(1000) - - await page.getByTestId(TestID.AmountInputIn).fill('1') - await expect(page.getByTestId(TestID.AmountInputOut)).toHaveValue(/.+/) - await page.getByTestId(TestID.ReviewSwap).click() - await page.getByTestId(TestID.Swap).click() - - await page.waitForTimeout(1000) - - await page.getByTestId(TestID.AmountInputIn).fill('1') - await expect(page.getByTestId(TestID.AmountInputOut)).toHaveValue(/.+/) - await page.getByTestId(TestID.ReviewSwap).click() - await page.getByTestId(TestID.Swap).click() - - // mine both transaction - await anvil.mine({ - blocks: 1, - }) - - // Only one swap should succeed - await expect(page.getByText('Swapped')).toBeVisible() - const balance = await anvil.getBalance({ address: TEST_WALLET_ADDRESS }) - expect(balance > originalEthBalance - 200000000000000000000n) - }) -}) + }, +) diff --git a/apps/web/src/pages/Landing/Landing.e2e.test.ts b/apps/web/src/pages/Landing/Landing.e2e.test.ts index e213ceaa6c1..dd3d96bbe0e 100644 --- a/apps/web/src/pages/Landing/Landing.e2e.test.ts +++ b/apps/web/src/pages/Landing/Landing.e2e.test.ts @@ -6,78 +6,88 @@ const MOBILE_VIEWPORT = { width: 375, height: 667 } const UNCONNECTED_USER_PARAM = '?eagerlyConnect=false' // Query param to prevent automatic wallet connection const FORCE_INTRO_PARAM = '?intro=true' // Query param to force the intro screen to be displayed -test.describe('Landing Page', () => { - test('shows landing page when no user state exists', async ({ page }) => { - await page.goto(`/${UNCONNECTED_USER_PARAM}`) - await expect(page.getByTestId(TestID.LandingPage)).toBeVisible() - }) - - test('shows landing page when intro is forced', async ({ page }) => { - await page.goto(`/${FORCE_INTRO_PARAM}`) - await expect(page.getByTestId(TestID.LandingPage)).toBeVisible() - }) +test.describe( + 'Landing Page', + { + tag: '@team:apps-growth', + annotation: [ + { type: 'DD_TAGS[team]', description: 'apps-growth' }, + { type: 'DD_TAGS[test.type]', description: 'web-e2e' }, + ], + }, + () => { + test('shows landing page when no user state exists', async ({ page }) => { + await page.goto(`/${UNCONNECTED_USER_PARAM}`) + await expect(page.getByTestId(TestID.LandingPage)).toBeVisible() + }) - test('allows navigation to pool', async ({ page }) => { - await page.goto(`/swap${UNCONNECTED_USER_PARAM}`) - await page.getByRole('link', { name: 'Pool' }).click() - await expect(page).toHaveURL('/positions') - }) + test('shows landing page when intro is forced', async ({ page }) => { + await page.goto(`/${FORCE_INTRO_PARAM}`) + await expect(page.getByTestId(TestID.LandingPage)).toBeVisible() + }) - test('allows navigation to pool on mobile', async ({ page }) => { - await page.setViewportSize(MOBILE_VIEWPORT) - await page.goto(`/swap${UNCONNECTED_USER_PARAM}`) - await page.getByTestId(TestID.NavCompanyMenu).click() - await expect(page.getByTestId(TestID.CompanyMenuMobileDrawer)).toBeVisible() - await page.getByRole('link', { name: 'Pool' }).click() - await expect(page).toHaveURL('/positions') - }) + test('allows navigation to pool', async ({ page }) => { + await page.goto(`/swap${UNCONNECTED_USER_PARAM}`) + await page.getByRole('link', { name: 'Pool' }).click() + await expect(page).toHaveURL('/positions') + }) - test('does not render landing page when / path is blocked', async ({ page }) => { - await page.route('/', async (route) => { - const response = await route.fetch() - const body = (await response.text()).replace( - '', - ``, - ) - await route.fulfill({ status: response.status(), headers: response.headers(), body }) + test('allows navigation to pool on mobile', async ({ page }) => { + await page.setViewportSize(MOBILE_VIEWPORT) + await page.goto(`/swap${UNCONNECTED_USER_PARAM}`) + await page.getByTestId(TestID.NavCompanyMenu).click() + await expect(page.getByTestId(TestID.CompanyMenuMobileDrawer)).toBeVisible() + await page.getByRole('link', { name: 'Pool' }).click() + await expect(page).toHaveURL('/positions') }) - await page.goto('/') - await expect(page.getByTestId(TestID.LandingPage)).not.toBeVisible() - await expect(page.getByTestId(TestID.BuyFiatButton)).not.toBeVisible() - await expect(page).toHaveURL('/swap') - }) + test('does not render landing page when / path is blocked', async ({ page }) => { + await page.route('/', async (route) => { + const response = await route.fetch() + const body = (await response.text()).replace( + '', + ``, + ) + await route.fulfill({ status: response.status(), headers: response.headers(), body }) + }) - test.describe('UK compliance banner', () => { - test.afterEach(async ({ page }) => { - await page.unrouteAll({ behavior: 'ignoreErrors' }) + await page.goto('/') + await expect(page.getByTestId(TestID.LandingPage)).not.toBeVisible() + await expect(page.getByTestId(TestID.BuyFiatButton)).not.toBeVisible() + await expect(page).toHaveURL('/swap') }) - test('renders UK compliance banner in UK', async ({ page }) => { - await page.route(/(?:interface|beta)\.gateway\.uniswap\.org\/v1\/amplitude-proxy/, async (route) => { - const requestBody = JSON.stringify(await route.request().postDataJSON()) - const originalResponse = await route.fetch() - const byteSize = new Blob([requestBody]).size - await route.fulfill({ - status: 200, - contentType: 'application/json', - headers: { ...originalResponse.headers(), 'origin-country': 'GB' }, - body: JSON.stringify({ - code: 200, - server_upload_time: Date.now(), - payload_size_bytes: byteSize, - events_ingested: (await route.request().postDataJSON()).events.length, - }), - }) + + test.describe('UK compliance banner', () => { + test.afterEach(async ({ page }) => { + await page.unrouteAll({ behavior: 'ignoreErrors' }) }) + test('renders UK compliance banner in UK', async ({ page }) => { + await page.route(/(?:interface|beta)\.gateway\.uniswap\.org\/v1\/amplitude-proxy/, async (route) => { + const requestBody = JSON.stringify(await route.request().postDataJSON()) + const originalResponse = await route.fetch() + const byteSize = new Blob([requestBody]).size + await route.fulfill({ + status: 200, + contentType: 'application/json', + headers: { ...originalResponse.headers(), 'origin-country': 'GB' }, + body: JSON.stringify({ + code: 200, + server_upload_time: Date.now(), + payload_size_bytes: byteSize, + events_ingested: (await route.request().postDataJSON()).events.length, + }), + }) + }) - await page.goto(`/swap${UNCONNECTED_USER_PARAM}`) - await page.getByText('Read more').click() - await expect(page.getByText('Disclaimer for UK residents')).toBeVisible() - }) + await page.goto(`/swap${UNCONNECTED_USER_PARAM}`) + await page.getByText('Read more').click() + await expect(page.getByText('Disclaimer for UK residents')).toBeVisible() + }) - test('does not render UK compliance banner in US', async ({ page }) => { - await page.goto(`/swap${UNCONNECTED_USER_PARAM}`) - await expect(page.getByTestId(TestID.UKDisclaimer)).not.toBeVisible() + test('does not render UK compliance banner in US', async ({ page }) => { + await page.goto(`/swap${UNCONNECTED_USER_PARAM}`) + await expect(page.getByTestId(TestID.UKDisclaimer)).not.toBeVisible() + }) }) - }) -}) + }, +) diff --git a/apps/web/src/pages/Migrate/hooks/useInitialPosition.ts b/apps/web/src/pages/Migrate/hooks/useInitialPosition.ts index 3eec90a5aef..4de6cf03b83 100644 --- a/apps/web/src/pages/Migrate/hooks/useInitialPosition.ts +++ b/apps/web/src/pages/Migrate/hooks/useInitialPosition.ts @@ -1,9 +1,10 @@ import { PositionStatus, ProtocolVersion } from '@uniswap/client-data-api/dist/data/v1/poolTypes_pb' +import { InitialPosition } from 'components/Liquidity/Create/types' import { PositionInfo } from 'components/Liquidity/types' import { useMemo } from 'react' import { unwrappedToken } from 'utils/unwrappedToken' -export default function useInitialPosition(positionInfo?: PositionInfo) { +export default function useInitialPosition(positionInfo?: PositionInfo): InitialPosition | undefined { return useMemo(() => { if (!positionInfo) { return undefined diff --git a/apps/web/src/pages/Portfolio/Activity/Activity.tsx b/apps/web/src/pages/Portfolio/Activity/Activity.tsx index 595e1e66339..c99ee32cd1c 100644 --- a/apps/web/src/pages/Portfolio/Activity/Activity.tsx +++ b/apps/web/src/pages/Portfolio/Activity/Activity.tsx @@ -1,33 +1,35 @@ import { Row } from '@tanstack/react-table' -import { ActivityTable } from 'components/ActivityTable/ActivityTable' -import { DropdownSelector } from 'components/Dropdowns/DropdownSelector' +import { SharedEventName } from '@uniswap/analytics-events' +import { POPUP_MEDIUM_DISMISS_MS } from 'components/Popups/constants' +import { popupRegistry } from 'components/Popups/registry' +import { PopupType } from 'components/Popups/types' +import { ActivityFilters } from 'pages/Portfolio/Activity/ActivityFilters' +import { ActivityTable } from 'pages/Portfolio/Activity/ActivityTable/ActivityTable' import { - getTimePeriodFilterOptions, - getTransactionTypeFilterOptions, + filterTransactionDetailsFromActivityItems, getTransactionTypesForFilter, } from 'pages/Portfolio/Activity/Filters/utils' -import { SearchInput } from 'pages/Portfolio/components/SearchInput' -import { usePortfolioAddress } from 'pages/Portfolio/hooks/usePortfolioAddress' -import { useCallback, useMemo, useState } from 'react' +import { PaginationSkeletonRow } from 'pages/Portfolio/Activity/PaginationSkeletonRow' +import { usePortfolioRoutes } from 'pages/Portfolio/Header/hooks/usePortfolioRoutes' +import { usePortfolioAddresses } from 'pages/Portfolio/hooks/usePortfolioAddresses' +import { useCallback, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' +import { useNavigate } from 'react-router' import { Flex, TouchableArea } from 'ui/src' -import { Calendar } from 'ui/src/components/icons/Calendar' -import { Filter } from 'ui/src/components/icons/Filter' +import { ActivityListEmptyState } from 'uniswap/src/components/activity/ActivityListEmptyState' import { TransactionDetailsModal } from 'uniswap/src/components/activity/details/TransactionDetailsModal' import { ActivityItem } from 'uniswap/src/components/activity/generateActivityItemRenderer' -import { isLoadingItem, isSectionHeader } from 'uniswap/src/components/activity/utils' import { useActivityData } from 'uniswap/src/features/activity/hooks/useActivityData' -import { InterfacePageName } from 'uniswap/src/features/telemetry/constants' +import { getChainLabel } from 'uniswap/src/features/chains/utils' +import { ElementName, InterfacePageName, SectionName } from 'uniswap/src/features/telemetry/constants' +import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' import Trace from 'uniswap/src/features/telemetry/Trace' import { TransactionDetails } from 'uniswap/src/features/transactions/types/transactionDetails' +import { useEvent } from 'utilities/src/react/hooks' +import { useInfiniteScroll } from 'utilities/src/react/useInfiniteScroll' +import { useTrace } from 'utilities/src/telemetry/trace/TraceContext' import { ONE_DAY_MS } from 'utilities/src/time/time' - -const DROPDOWN_MIN_WIDTH = { - transactionType: 220, - timePeriod: 200, -} - -const PAGE_SIZE = 50 +import { filterDefinedWalletAddresses } from 'utils/filterDefinedWalletAddresses' function isWithinTimePeriod(txTime: number, period: string): boolean { if (period === 'all') { @@ -57,126 +59,165 @@ function filterTransactions({ }): TransactionDetails[] { const allowedTypes = getTransactionTypesForFilter(typeFilter) - // Filter out loading items and section headers, leaving only TransactionDetails - const transactionItems = transactions.filter( - (item): item is TransactionDetails => !isLoadingItem(item) && !isSectionHeader(item), - ) - - return transactionItems + return filterTransactionDetailsFromActivityItems(transactions) .filter((tx) => allowedTypes === 'all' || allowedTypes.includes(tx.typeInfo.type)) .filter((tx) => isWithinTimePeriod(tx.addedTime, timeFilter)) } export default function PortfolioActivity() { const { t } = useTranslation() - const transactionTypeOptions = getTransactionTypeFilterOptions(t) - const timePeriodOptions = getTimePeriodFilterOptions(t) + const navigate = useNavigate() + const trace = useTrace() const [selectedTransactionType, setSelectedTransactionType] = useState('all') const [selectedTimePeriod, setSelectedTimePeriod] = useState('all') - const [searchValue, setSearchValue] = useState('') - const [filterTypeExpanded, setFilterTypeExpanded] = useState(false) - const [timePeriodExpanded, setTimePeriodExpanded] = useState(false) const [selectedTransaction, setSelectedTransaction] = useState(null) - const portfolioAddress = usePortfolioAddress() + const { evmAddress, svmAddress } = usePortfolioAddresses() + const { chainId } = usePortfolioRoutes() - const activityData = useActivityData({ - evmOwner: portfolioAddress, - ownerAddresses: [portfolioAddress], - swapCallbacks: { - useLatestSwapTransaction: () => undefined, - useSwapFormTransactionState: () => undefined, - onRetryGenerator: () => () => {}, - }, + const { sectionData, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading, isFetching } = useActivityData({ + evmOwner: evmAddress, + svmOwner: svmAddress, + ownerAddresses: filterDefinedWalletAddresses([evmAddress, svmAddress]), fiatOnRampParams: undefined, + chainIds: chainId ? [chainId] : undefined, }) - // Show loading skeleton while data is being fetched (sectionData contains loading items when loading) - const loading = Boolean(activityData.sectionData?.some(isLoadingItem)) + // Track chainId changes to show loading skeleton when switching networks + // We need this because placeholderData keeps old data visible during refetch, + // but we want to show a skeleton when the chain filter changes + const prevChainIdRef = useRef(chainId) + const chainIdChanged = prevChainIdRef.current !== chainId + if (chainIdChanged && !isFetching) { + // Update ref once we're done fetching for the new chainId + prevChainIdRef.current = chainId + } + + // Show loading skeleton when: + // 1. Initial load (isLoading is true, no cached data) + // 2. Chain filter changed and we're fetching new data + const showLoading = isLoading || (chainIdChanged && isFetching) + + const { sentinelRef } = useInfiniteScroll({ + onLoadMore: fetchNextPage, + hasNextPage, + isFetching: isFetchingNextPage, + }) // Filter out section headers and loading items to get just transaction data const transactionData: TransactionDetails[] = useMemo( () => filterTransactions({ - transactions: activityData.sectionData || [], + transactions: sectionData || [], typeFilter: selectedTransactionType, timeFilter: selectedTimePeriod, - }).slice(0, PAGE_SIZE), // TODO: add infinite scroll once that stack gets merged - [activityData.sectionData, selectedTransactionType, selectedTimePeriod], + }), + [sectionData, selectedTransactionType, selectedTimePeriod], ) const error = false - const handleTransactionClick = useCallback((transaction: TransactionDetails) => { + const handleTransactionClick = useEvent((transaction: TransactionDetails) => { + sendAnalyticsEvent(SharedEventName.ELEMENT_CLICKED, { + element: ElementName.ActivityRow, + section: SectionName.PortfolioActivityTab, + ...trace, + }) setSelectedTransaction(transaction) - }, []) - - const rowWrapper = useCallback( - (row: Row, content: JSX.Element) => { - const transaction = row.original - return ( - handleTransactionClick(transaction)} cursor="pointer"> - {content} - - ) - }, - [handleTransactionClick], - ) + }) + + const rowWrapper = useEvent((row: Row, content: JSX.Element) => { + const transaction = row.original + return ( + handleTransactionClick(transaction)} cursor="pointer"> + {content} + + ) + }) const handleCloseTransactionDetails = () => { setSelectedTransaction(null) } + const onReportSuccess = useEvent(() => { + popupRegistry.addPopup( + { type: PopupType.Success, message: t('common.reported') }, + 'report-transaction-success', + POPUP_MEDIUM_DISMISS_MS, + ) + }) + + const onUnhideTransaction = useEvent(() => { + popupRegistry.addPopup( + { type: PopupType.Unhide, assetName: t('common.activity') }, + 'unhide-transaction-success', + POPUP_MEDIUM_DISMISS_MS, + ) + }) + + // Handler to clear chain filter and show all networks + const handleShowAllNetworks = useCallback(() => { + navigate('/portfolio/activity') + }, [navigate]) + + // Custom empty state for chain filtering + const chainFilterEmptyState = useMemo(() => { + if (!chainId) { + return undefined + } + const chainName = getChainLabel(chainId) + return ( + + ) + }, [handleShowAllNetworks, chainId, t]) + return ( - + {/* Filtering Controls */} - - - {/* Transaction Type Filter */} - - - {/* Time Period Filter */} - - - - + + + + + + {!showLoading && transactionData.length === 0 ? ( + chainId ? ( + chainFilterEmptyState + ) : ( + + ) + ) : ( + + <> + + + {/* Show skeleton loading indicator while fetching next page */} + {isFetchingNextPage && } + + {/* Intersection observer sentinel for infinite scroll */} + + + + )} - - {selectedTransaction && ( )} diff --git a/apps/web/src/pages/Portfolio/Activity/Filters/utils.ts b/apps/web/src/pages/Portfolio/Activity/Filters/utils.ts index 67df4ab0c17..53976427db1 100644 --- a/apps/web/src/pages/Portfolio/Activity/Filters/utils.ts +++ b/apps/web/src/pages/Portfolio/Activity/Filters/utils.ts @@ -1,24 +1,26 @@ import { SelectOption } from 'components/Dropdowns/DropdownSelector' -import { Approve } from 'ui/src/components/icons/Approve' -import { ArrowChange } from 'ui/src/components/icons/ArrowChange' -import { ArrowDownCircle } from 'ui/src/components/icons/ArrowDownCircle' -import { ArrowUpCircle } from 'ui/src/components/icons/ArrowUpCircle' -import { Dollar } from 'ui/src/components/icons/Dollar' +import { Box } from 'ui/src/components/icons/Box' +import { Coin } from 'ui/src/components/icons/Coin' +import { CoinConvert } from 'ui/src/components/icons/CoinConvert' +import { Lock } from 'ui/src/components/icons/Lock' +import { Minus } from 'ui/src/components/icons/Minus' +import { MoneyHand } from 'ui/src/components/icons/MoneyHand' import { Plus } from 'ui/src/components/icons/Plus' +import { Pools } from 'ui/src/components/icons/Pools' import { ReceiveAlt } from 'ui/src/components/icons/ReceiveAlt' import { SendAction } from 'ui/src/components/icons/SendAction' -import { Sparkle } from 'ui/src/components/icons/Sparkle' -import { Swap } from 'ui/src/components/icons/Swap' import { AppTFunction } from 'ui/src/i18n/types' -import { TransactionType } from 'uniswap/src/features/transactions/types/transactionDetails' +import { ActivityItem } from 'uniswap/src/components/activity/generateActivityItemRenderer' +import { isLoadingItem, isSectionHeader } from 'uniswap/src/components/activity/utils' +import { TransactionDetails, TransactionType } from 'uniswap/src/features/transactions/types/transactionDetails' -enum ActivityFilterType { +export enum ActivityFilterType { All = 'all', Sends = 'sends', Receives = 'receives', Swaps = 'swaps', Wraps = 'wraps', - Approves = 'approves', + Approvals = 'approvals', CreatePool = 'create-pool', AddLiquidity = 'add-liquidity', RemoveLiquidity = 'remove-liquidity', @@ -26,6 +28,29 @@ enum ActivityFilterType { ClaimFees = 'claim-fees', } +/** + * Type guard to check if an ActivityItem is a TransactionDetails + * @param item ActivityItem to check + * @returns true if the item is a TransactionDetails + */ +function isTransactionDetails(item: ActivityItem): item is TransactionDetails { + // Validate that the item has required TransactionDetails properties + return ( + 'typeInfo' in item && 'addedTime' in item && typeof item.typeInfo === 'object' && typeof item.addedTime === 'number' + ) +} + +/** + * Filters out loading items and section headers, leaving only TransactionDetails + * @param transactions ActivityItems to filter + * @returns only TransactionDetails items + */ +export function filterTransactionDetailsFromActivityItems(transactions: ActivityItem[]): TransactionDetails[] { + return transactions.filter( + (item): item is TransactionDetails => !isLoadingItem(item) && !isSectionHeader(item) && isTransactionDetails(item), + ) +} + export function getTransactionTypeFilterOptions(t: AppTFunction): Record { return { [ActivityFilterType.All]: { @@ -34,43 +59,43 @@ export function getTransactionTypeFilterOptions(t: AppTFunction): Record - {showEmblems && ( - <> - - - - - - - - - - - - - - - - - - - - - - - - - - )} + {t('common.connectAWallet.button')}{' '} @@ -115,10 +18,16 @@ export default function PortfolioConnectWalletBanner() { - + + + - + ) } diff --git a/apps/web/src/pages/Portfolio/ConnectWalletBottomOverlay.tsx b/apps/web/src/pages/Portfolio/ConnectWalletBottomOverlay.tsx deleted file mode 100644 index 1d3023714b3..00000000000 --- a/apps/web/src/pages/Portfolio/ConnectWalletBottomOverlay.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { useAccountDrawer } from 'components/AccountDrawer/MiniPortfolio/hooks' -import { useTranslation } from 'react-i18next' -import { Button, Flex, Text } from 'ui/src' - -export function ConnectWalletBottomOverlay(): JSX.Element { - const accountDrawer = useAccountDrawer() - const { t } = useTranslation() - - return ( - - - - {t('portfolio.disconnected.connectWallet.cta')} - - - - - ) -} diff --git a/apps/web/src/pages/Portfolio/Header/Header.tsx b/apps/web/src/pages/Portfolio/Header/Header.tsx index c08767e2216..5581f0cd62c 100644 --- a/apps/web/src/pages/Portfolio/Header/Header.tsx +++ b/apps/web/src/pages/Portfolio/Header/Header.tsx @@ -1,51 +1,84 @@ import NetworkFilter from 'components/NetworkFilter/NetworkFilter' -import { usePortfolioParams } from 'pages/Portfolio/Header/hooks/usePortfolioParams' -import PortfolioAddressDisplay from 'pages/Portfolio/Header/PortfolioAddressDisplay/PortfolioAddressDisplay' +import { TOTAL_INTERFACE_NAV_HEIGHT } from 'pages/Portfolio/constants' +import { usePortfolioRoutes } from 'pages/Portfolio/Header/hooks/usePortfolioRoutes' +import { PortfolioAddressDisplay } from 'pages/Portfolio/Header/PortfolioAddressDisplay/PortfolioAddressDisplay' import { PortfolioTabs } from 'pages/Portfolio/Header/Tabs' +import { useShouldHeaderBeCompact } from 'pages/Portfolio/Header/useShouldHeaderBeCompact' import { PortfolioTab } from 'pages/Portfolio/types' +import { buildPortfolioUrl } from 'pages/Portfolio/utils/portfolioUrls' import { useNavigate } from 'react-router' -import { Flex } from 'ui/src' -import { INTERFACE_NAV_HEIGHT } from 'ui/src/theme/heights' +import { Flex, useMedia } from 'ui/src' import { UniverseChainId } from 'uniswap/src/features/chains/types' +import { ElementName, InterfacePageName, UniswapEventName } from 'uniswap/src/features/telemetry/constants' +import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' import { useEvent } from 'utilities/src/react/hooks' -import { getChainUrlParam } from 'utils/chainParams' -function buildPortfolioUrl(tab: PortfolioTab | undefined, chainId: UniverseChainId | undefined): string { - const chainUrlParam = chainId ? getChainUrlParam(chainId) : '' - const currentPath = tab === PortfolioTab.Overview ? '/portfolio' : `/portfolio/${tab}` - return `${currentPath}${chainId ? `?chain=${chainUrlParam}` : ''}` +const HEADER_TRANSITION = 'all 0.2s ease' + +function getPageNameFromTab(tab: PortfolioTab | undefined): InterfacePageName { + switch (tab) { + case PortfolioTab.Overview: + return InterfacePageName.PortfolioPage + case PortfolioTab.Tokens: + return InterfacePageName.PortfolioTokensPage + case PortfolioTab.Defi: + return InterfacePageName.PortfolioDefiPage + case PortfolioTab.Nfts: + return InterfacePageName.PortfolioNftsPage + case PortfolioTab.Activity: + return InterfacePageName.PortfolioActivityPage + default: + return InterfacePageName.PortfolioPage + } } -export default function PortfolioHeader() { - const navigate = useNavigate() - const { tab, chainId: currentChainId } = usePortfolioParams() +interface PortfolioHeaderProps { + scrollY?: number +} +export function PortfolioHeader({ scrollY }: PortfolioHeaderProps) { + const navigate = useNavigate() + const media = useMedia() + const { tab, chainId: currentChainId } = usePortfolioRoutes() + const isCompact = useShouldHeaderBeCompact(scrollY) const onNetworkPress = useEvent((chainId: UniverseChainId | undefined) => { + const currentPageName = getPageNameFromTab(tab) + const selectedChain = chainId ?? ('All' as const) + + sendAnalyticsEvent(UniswapEventName.NetworkFilterSelected, { + element: ElementName.PortfolioNetworkFilter, + page: currentPageName, + chain: selectedChain, + }) + navigate(buildPortfolioUrl(tab, chainId)) }) return ( - - + + diff --git a/apps/web/src/pages/Portfolio/Header/PortfolioAddressDisplay/ConnectedAddressDisplay.tsx b/apps/web/src/pages/Portfolio/Header/PortfolioAddressDisplay/ConnectedAddressDisplay.tsx index 7d3326517dc..15dd986b9ba 100644 --- a/apps/web/src/pages/Portfolio/Header/PortfolioAddressDisplay/ConnectedAddressDisplay.tsx +++ b/apps/web/src/pages/Portfolio/Header/PortfolioAddressDisplay/ConnectedAddressDisplay.tsx @@ -1,39 +1,26 @@ /* eslint-disable-next-line no-restricted-imports, no-restricted-syntax */ -import { useAccount } from 'hooks/useAccount' -import { useScroll } from 'hooks/useScroll' -import { useEffect, useState } from 'react' -import { AddressDisplay } from 'uniswap/src/components/accounts/AddressDisplay' +import { MultiBlockchainAddressDisplay } from 'components/AccountDetails/MultiBlockchainAddressDisplay' +import StatusIcon from 'components/StatusIcon' +import { useActiveAddresses } from 'features/accounts/store/hooks' +import { Flex } from 'ui/src' +import { iconSizes } from 'ui/src/theme/iconSizes' -export default function ConnectedAddressDisplay() { - const { height: scrollHeight } = useScroll() - const [isCompact, setIsCompact] = useState(false) - // Use connected address rather than usePortfolioAddress because this is only for the connected view - const account = useAccount() +export function ConnectedAddressDisplay({ isCompact }: { isCompact: boolean }) { + const activeAddresses = useActiveAddresses() - useEffect(() => { - setIsCompact((prevIsCompact) => { - if (!prevIsCompact && scrollHeight > 120) { - return true - } - if (prevIsCompact && scrollHeight < 80) { - return false - } - return prevIsCompact - }) - }, [scrollHeight]) + // Use primary address for icon (EVM first, then SVM) + const addressToDisplay = activeAddresses.evmAddress ?? activeAddresses.svmAddress - if (!account.address) { + if (!addressToDisplay) { return null } + const iconSize = isCompact ? iconSizes.icon24 : iconSizes.icon48 + return ( - + + + + ) } diff --git a/apps/web/src/pages/Portfolio/Header/PortfolioAddressDisplay/DemoAddressDisplay.tsx b/apps/web/src/pages/Portfolio/Header/PortfolioAddressDisplay/DemoAddressDisplay.tsx index 4d6166b96cf..fe0809bbd47 100644 --- a/apps/web/src/pages/Portfolio/Header/PortfolioAddressDisplay/DemoAddressDisplay.tsx +++ b/apps/web/src/pages/Portfolio/Header/PortfolioAddressDisplay/DemoAddressDisplay.tsx @@ -1,10 +1,10 @@ -import { ReactComponent as Unicon } from 'assets/svg/Emblem/default.svg' +import { ReactComponent as Unicon } from 'assets/svg/demo-wallet-emblem.svg' import { useTranslation } from 'react-i18next' import { Flex, Text, Tooltip, useSporeColors } from 'ui/src' import { Eye } from 'ui/src/components/icons/Eye' -import { iconSizes } from 'ui/src/theme' +import { iconSizes, zIndexes } from 'ui/src/theme' -export default function DemoAddressDisplay() { +export function DemoAddressDisplay() { const colors = useSporeColors() const { t } = useTranslation() @@ -28,7 +28,7 @@ export default function DemoAddressDisplay() { - + {t('portfolio.disconnected.demoWallet.description')} diff --git a/apps/web/src/pages/Portfolio/Header/PortfolioAddressDisplay/PortfolioAddressDisplay.tsx b/apps/web/src/pages/Portfolio/Header/PortfolioAddressDisplay/PortfolioAddressDisplay.tsx index c2f6a8b9fbf..b50f045bc08 100644 --- a/apps/web/src/pages/Portfolio/Header/PortfolioAddressDisplay/PortfolioAddressDisplay.tsx +++ b/apps/web/src/pages/Portfolio/Header/PortfolioAddressDisplay/PortfolioAddressDisplay.tsx @@ -1,9 +1,9 @@ import useIsConnected from 'pages/Portfolio/Header/hooks/useIsConnected' -import ConnectedAddressDisplay from 'pages/Portfolio/Header/PortfolioAddressDisplay/ConnectedAddressDisplay' -import DemoAddressDisplay from 'pages/Portfolio/Header/PortfolioAddressDisplay/DemoAddressDisplay' +import { ConnectedAddressDisplay } from 'pages/Portfolio/Header/PortfolioAddressDisplay/ConnectedAddressDisplay' +import { DemoAddressDisplay } from 'pages/Portfolio/Header/PortfolioAddressDisplay/DemoAddressDisplay' -export default function PortfolioAddressDisplay(): JSX.Element { +export function PortfolioAddressDisplay({ isCompact }: { isCompact: boolean }): JSX.Element { const isConnected = useIsConnected() - return isConnected ? : + return isConnected ? : } diff --git a/apps/web/src/pages/Portfolio/Header/hooks/useIsConnected.ts b/apps/web/src/pages/Portfolio/Header/hooks/useIsConnected.ts index 6c381c705ec..7847d07d423 100644 --- a/apps/web/src/pages/Portfolio/Header/hooks/useIsConnected.ts +++ b/apps/web/src/pages/Portfolio/Header/hooks/useIsConnected.ts @@ -1,7 +1,7 @@ /* eslint-disable-next-line no-restricted-imports, no-restricted-syntax */ -import { useAccount } from 'hooks/useAccount' +import { useActiveAddresses } from 'uniswap/src/features/accounts/store/hooks' -export default function useIsConnected() { - const account = useAccount() - return !!account.address +export default function useIsConnected(): boolean { + const { evmAddress, svmAddress } = useActiveAddresses() + return Boolean(evmAddress || svmAddress) } diff --git a/apps/web/src/pages/Portfolio/NFTs/NFTCard.tsx b/apps/web/src/pages/Portfolio/NFTs/NFTCard.tsx index 878c0a3cab8..682e0a725e7 100644 --- a/apps/web/src/pages/Portfolio/NFTs/NFTCard.tsx +++ b/apps/web/src/pages/Portfolio/NFTs/NFTCard.tsx @@ -1,35 +1,37 @@ import { SharedEventName } from '@uniswap/analytics-events' -import { useCallback, useMemo, useState } from 'react' +import { usePortfolioAddresses } from 'pages/Portfolio/hooks/usePortfolioAddresses' +import { generateRotationStyle } from 'pages/Portfolio/NFTs/generateRotationStyle' +import { memo, useCallback, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -import { AnimateTransition, Flex, Text, TouchableArea, useSporeColors } from 'ui/src' +import { AnimateTransition, animationPresets, Flex, Popover, Text, TouchableArea, useSporeColors } from 'ui/src' import { ArrowUpRight } from 'ui/src/components/icons/ArrowUpRight' import { MoreHorizontal } from 'ui/src/components/icons/MoreHorizontal' import { zIndexes } from 'ui/src/theme' import { iconSizes } from 'ui/src/theme/iconSizes' import { NetworkLogo } from 'uniswap/src/components/CurrencyLogo/NetworkLogo' +import { MenuContent } from 'uniswap/src/components/menus/ContextMenuContent' import { NftView, NftViewProps } from 'uniswap/src/components/nfts/NftView' +import { useEnabledChains } from 'uniswap/src/features/chains/hooks/useEnabledChains' import { fromGraphQLChain } from 'uniswap/src/features/chains/utils' -import { ElementName, SectionName } from 'uniswap/src/features/telemetry/constants' +import { useNFTContextMenuItems } from 'uniswap/src/features/nfts/hooks/useNftContextMenuItems' +import { getNFTAssetKey } from 'uniswap/src/features/nfts/utils' +import { ElementName, SectionName, UniswapEventName } from 'uniswap/src/features/telemetry/constants' import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' -import { getOpenseaLink, openUri } from 'uniswap/src/utils/linking' +import { getNftExplorerLink, getOpenseaLink, openUri } from 'uniswap/src/utils/linking' +import { useBooleanState } from 'utilities/src/react/useBooleanState' +import { useTrace } from 'utilities/src/telemetry/trace/TraceContext' +import { filterDefinedWalletAddresses } from 'utils/filterDefinedWalletAddresses' const FLOAT_UP_ON_HOVER_OFFSET = -4 -/** - * Generates a unique rotation angle for an element based on its ID - * @param id - Unique identifier for the element - * @returns CSS custom property object with rotation value - */ -function generateRotationStyle(id: string) { - // Generate hash from ID - const hashCode = id.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0) - - // Determine rotation direction (positive or negative) - const direction = hashCode % 2 === 0 ? 1 : -1 - - // Generate rotation amount between 0.5 and 2.5 degrees - const rotationAmount = 0.5 + (hashCode % 201) / 100 // Range: 0.5 to 2.5 - return direction * rotationAmount +let openNftPopoverId: string | null = null + +function getOpenNftPopoverId(): string | null { + return openNftPopoverId +} + +export function setOpenNftPopoverId(id: string | null): void { + openNftPopoverId = id } type NftCardProps = Omit & { @@ -38,78 +40,195 @@ type NftCardProps = Omit & { onPress?: () => void } -export function NFTCard(props: NftCardProps): JSX.Element { - const [isHovered, setIsHovered] = useState(false) +function _NFTCard(props: NftCardProps): JSX.Element { + const { value: isHovered, setTrue: setIsHovered, setFalse: setIsHoveredFalse } = useBooleanState(false) const colors = useSporeColors() const { t } = useTranslation() + const { evmAddress, svmAddress } = usePortfolioAddresses() + const { defaultChainId } = useEnabledChains() - // Generate OpenSea URL for the NFT - const openseaUrl = useMemo(() => { - if (props.item.chain && props.item.contractAddress && props.item.tokenId) { - const chainId = fromGraphQLChain(props.item.chain) - if (chainId) { - return getOpenseaLink({ - chainId, - contractAddress: props.item.contractAddress, - tokenId: props.item.tokenId, + const nftUniqueId = useMemo( + () => getNFTAssetKey(props.item.contractAddress ?? '', props.item.tokenId ?? ''), + [props.item.contractAddress, props.item.tokenId], + ) + + const [openPopoverId, setOpenPopoverId] = useState(() => getOpenNftPopoverId()) + const isPopoverOpen = openPopoverId === nftUniqueId + const trace = useTrace() + + useEffect(() => { + setOpenPopoverId(getOpenNftPopoverId()) + }, []) + + // Track menu open + useEffect(() => { + if (isPopoverOpen) { + sendAnalyticsEvent(UniswapEventName.ContextMenuOpened, { + element: ElementName.PortfolioNftContextMenu, + section: SectionName.PortfolioNftsTab, + ...trace, + }) + } + }, [isPopoverOpen, trace]) + + const handlePopoverOpenChange = useCallback( + (open: boolean) => { + const newId = open ? nftUniqueId : null + setOpenNftPopoverId(newId) + setOpenPopoverId(newId) + + // Track menu close + if (!open && isPopoverOpen) { + sendAnalyticsEvent(UniswapEventName.ContextMenuClosed, { + element: ElementName.PortfolioNftContextMenu, + section: SectionName.PortfolioNftsTab, + ...trace, }) } + }, + [nftUniqueId, isPopoverOpen, trace], + ) + + const closePopover = useCallback(() => { + setOpenNftPopoverId(null) + setOpenPopoverId(null) + }, []) + + // Combine hover state and popover open state to keep hovered styles when popover is open + const isActive = isHovered || isPopoverOpen + + // Active card styles for when hovering or popover is open + const activeCardStyles = useMemo( + () => ({ + y: FLOAT_UP_ON_HOVER_OFFSET, + rotate: `${generateRotationStyle(props.id)}deg`, + shadowColor: '$shadowColor', + boxShadow: `0px 4px 12px -3px ${colors.shadowColor.val}, 0px 2px 5px -2px ${colors.shadowColor.val}`, + }), + [props.id, colors.shadowColor.val], + ) + + // Generate chainId for the NFT + const chainId = useMemo(() => { + if (props.item.chain) { + return fromGraphQLChain(props.item.chain) ?? undefined + } + return undefined + }, [props.item.chain]) + + // Generate OpenSea URL for the NFT + const openseaUrl = useMemo(() => { + if (chainId && props.item.contractAddress && props.item.tokenId) { + return getOpenseaLink({ + chainId, + contractAddress: props.item.contractAddress, + tokenId: props.item.tokenId, + }) } return null - }, [props.item.chain, props.item.contractAddress, props.item.tokenId]) + }, [chainId, props.item.contractAddress, props.item.tokenId]) - const handlePress = useCallback(async () => { - if (openseaUrl) { - await openUri({ uri: openseaUrl }) + // Generate explorer URL for the NFT (fallback when no OpenSea) + const explorerUrl = useMemo(() => { + if (!openseaUrl && chainId && props.item.contractAddress && props.item.tokenId) { + return getNftExplorerLink({ + chainId, + fallbackChainId: defaultChainId, + contractAddress: props.item.contractAddress, + tokenId: props.item.tokenId, + }) } - sendAnalyticsEvent(SharedEventName.ELEMENT_CLICKED, { - element: ElementName.PortfolioNftItem, - section: SectionName.PortfolioNftsTab, - collection_name: props.item.collectionName, - collection_address: props.item.contractAddress, - token_id: props.item.tokenId, - }) - props.onPress?.() - }, [openseaUrl, props.item.collectionName, props.item.contractAddress, props.item.tokenId, props.onPress]) + return null + }, [openseaUrl, chainId, props.item.contractAddress, props.item.tokenId, defaultChainId]) + + // Generate context menu items + const menuItems = useNFTContextMenuItems({ + contractAddress: props.item.contractAddress, + tokenId: props.item.tokenId, + owner: props.owner, + walletAddresses: filterDefinedWalletAddresses([evmAddress, svmAddress]), + isSpam: props.item.isSpam, + showNotification: false, + chainId, + }) + + const handlePress = useCallback( + async (event?: any) => { + // Prefer OpenSea URL, fall back to block explorer if no OpenSea URL available + const url = openseaUrl || explorerUrl + const linkType = openseaUrl ? 'opensea' : 'block_explorer' + + if (url) { + await openUri({ uri: url }) + } + + sendAnalyticsEvent(SharedEventName.ELEMENT_CLICKED, { + element: ElementName.PortfolioNftItem, + section: SectionName.PortfolioNftsTab, + collection_name: props.item.collectionName, + collection_address: props.item.contractAddress, + token_id: props.item.tokenId, + link_type: linkType, + }) + props.onPress?.() + }, + [openseaUrl, explorerUrl, props.item.collectionName, props.item.contractAddress, props.item.tokenId, props.onPress], + ) return ( setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)} + {...(isActive ? activeCardStyles : {})} + onMouseEnter={setIsHovered} + onMouseLeave={setIsHoveredFalse} onPress={handlePress} > {/* Context menu trigger icon */} - {/* TODO: open NFT context menu on click */} - event.stopPropagation()} - > - - + e.stopPropagation()}> + + + + + + + + + + + {/* Let the parent card handle the onPress */} {}} /> @@ -120,19 +239,19 @@ export function NFTCard(props: NftCardProps): JSX.Element { {props.item.collectionName} - {props.item.chain && } + {props.item.chain && chainId && } - {t('common.opensea.link')} + {openseaUrl ? t('common.opensea.link') : t('common.viewOnExplorer')} @@ -142,3 +261,7 @@ export function NFTCard(props: NftCardProps): JSX.Element { ) } + +export const NFTCard = memo(_NFTCard) + +NFTCard.displayName = 'NFTCard' diff --git a/apps/web/src/pages/Portfolio/NFTs/Nfts.tsx b/apps/web/src/pages/Portfolio/NFTs/Nfts.tsx index d876b5a78f2..6c05592e4cc 100644 --- a/apps/web/src/pages/Portfolio/NFTs/Nfts.tsx +++ b/apps/web/src/pages/Portfolio/NFTs/Nfts.tsx @@ -1,74 +1,136 @@ +import { PortfolioExpandoRow } from 'pages/Portfolio/components/PortfolioExpandoRow' import { SearchInput } from 'pages/Portfolio/components/SearchInput' -import { usePortfolioAddress } from 'pages/Portfolio/hooks/usePortfolioAddress' -import { NFTCard } from 'pages/Portfolio/NFTs/NFTCard' -import { filterNft } from 'pages/Portfolio/NFTs/utils/filterNfts' -import { useCallback, useMemo, useRef, useState } from 'react' +import { usePortfolioRoutes } from 'pages/Portfolio/Header/hooks/usePortfolioRoutes' +import { usePortfolioAddresses } from 'pages/Portfolio/hooks/usePortfolioAddresses' +import { NFTCard, setOpenNftPopoverId } from 'pages/Portfolio/NFTs/NFTCard' +import { NFTCardSkeleton } from 'pages/Portfolio/NFTs/NFTCardSkeleton' +import { memo, useCallback, useEffect, useMemo, useRef } from 'react' import { useTranslation } from 'react-i18next' -import { Flex, Text } from 'ui/src' -import { useNftListRenderData } from 'uniswap/src/components/nfts/hooks/useNftListRenderData' +import { useNavigate } from 'react-router' +import { Flex, useMedia } from 'ui/src' import { NftsList } from 'uniswap/src/components/nfts/NftsList' +import { NftsListEmptyState } from 'uniswap/src/components/nfts/NftsListEmptyState' +import { PollingInterval } from 'uniswap/src/constants/misc' +import { getChainInfo } from 'uniswap/src/features/chains/chainInfo' +import { UniverseChainId } from 'uniswap/src/features/chains/types' +import { getChainLabel } from 'uniswap/src/features/chains/utils' import { NFTItem } from 'uniswap/src/features/nfts/types' -import { InterfacePageName } from 'uniswap/src/features/telemetry/constants' +import { ElementName, InterfacePageName, SectionName } from 'uniswap/src/features/telemetry/constants' import Trace from 'uniswap/src/features/telemetry/Trace' import { assume0xAddress } from 'utils/wagmi' const LOADING_SKELETON_COUNT = 10 -export default function PortfolioNfts(): JSX.Element { +// Memoized wrapper component to avoid recreating Flex structure on every render +const NFTItemWrapper = memo(function NFTItemWrapper({ item, owner }: { item: NFTItem; owner: Address }): JSX.Element { + return ( + + + + + + ) +}) + +export function PortfolioNfts(): JSX.Element { const { t } = useTranslation() - const owner = usePortfolioAddress() + const media = useMedia() + const navigate = useNavigate() + // TODO(PORT-485): Solana NFTs are not supported yet, add empty state for NFTs when connected to a Solana wallet only + const { evmAddress, svmAddress } = usePortfolioAddresses() + const { chainId: selectedChainId } = usePortfolioRoutes() const nftsContainerRef = useRef(null) + const owner = assume0xAddress(evmAddress) ?? '' + const isSolanaOnlyWallet = Boolean(svmAddress && !evmAddress) - const [search, setSearch] = useState('') - const lowercaseSearch = useMemo(() => search.trim().toLowerCase(), [search]) - - const { numShown } = useNftListRenderData({ owner: assume0xAddress(owner), skip: !owner }) + useEffect(() => { + // Reset popover state when component unmounts + return () => { + setOpenNftPopoverId(null) + } + }, []) + // renderNFTItem uses memoized wrapper component to avoid recreating Flex structure const renderNFTItem = useCallback( (item: NFTItem) => { - if (!filterNft(item, lowercaseSearch)) { - return - } - - return ( - - - - - - ) + return }, - [lowercaseSearch, owner], + [owner], ) + // Custom loading state with Portfolio-specific skeleton + const customLoadingState = useMemo( + () => ( + <> + {Array.from({ length: LOADING_SKELETON_COUNT }, (_, i) => ( + + ))} + + ), + [], + ) + + // Memoize renderExpandoRow to avoid recreating the function on every render + const renderExpandoRow = useCallback( + ({ isExpanded, label, onPress }: { isExpanded: boolean; label: string; onPress: () => void }) => ( + + ), + [], + ) + + // Handler to clear chain filter and show all networks + const handleShowAllNetworks = useCallback(() => { + navigate('/portfolio/nfts') + }, [navigate]) + + // Custom empty state for chain filtering + const chainFilterEmptyState = useMemo(() => { + if (!selectedChainId) { + if (isSolanaOnlyWallet) { + const solanaChainName = getChainLabel(UniverseChainId.Solana) + const title = t('tokens.nfts.list.notSupported.title', { chainName: solanaChainName }) + return + } + return undefined + } + const chainName = getChainLabel(selectedChainId) + const chainInfo = getChainInfo(selectedChainId) + const hasNFTSupport = chainInfo.supportsNFTs === true + const title = hasNFTSupport + ? t('tokens.nfts.list.noneOnChain.title', { chainName }) + : t('tokens.nfts.list.notSupported.title', { chainName }) + return ( + + ) + }, [handleShowAllNetworks, selectedChainId, t, isSolanaOnlyWallet]) + return ( - - - - {numShown ? `${numShown}` : ''} {t('portfolio.nfts.title')} - - - - - - - + + + + + + ) diff --git a/apps/web/src/pages/Portfolio/NFTs/utils/filterNfts.test.ts b/apps/web/src/pages/Portfolio/NFTs/utils/filterNfts.test.ts deleted file mode 100644 index b208030e60d..00000000000 --- a/apps/web/src/pages/Portfolio/NFTs/utils/filterNfts.test.ts +++ /dev/null @@ -1,257 +0,0 @@ -import { filterNft } from 'pages/Portfolio/NFTs/utils/filterNfts' -import { NFTItem } from 'uniswap/src/features/nfts/types' - -describe('filterNft', () => { - const createMockNft = (overrides: Partial = {}): NFTItem => ({ - name: 'Bored Ape #1234', - collectionName: 'Bored Ape Yacht Club', - tokenId: '1234', - contractAddress: '0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D', - ...overrides, - }) - - describe('when search query is empty', () => { - it('should return true for empty string', () => { - const nft = createMockNft() - expect(filterNft(nft, '')).toBe(true) - }) - - it('should return true for whitespace-only string', () => { - const nft = createMockNft() - expect(filterNft(nft, ' ')).toBe(true) - }) - - it('should return true for null/undefined search', () => { - const nft = createMockNft() - expect(filterNft(nft, '')).toBe(true) - }) - }) - - describe('when searching by NFT name', () => { - it('should match exact name', () => { - const nft = createMockNft({ name: 'Bored Ape #1234' }) - expect(filterNft(nft, 'Bored Ape #1234')).toBe(true) - }) - - it('should match partial name', () => { - const nft = createMockNft({ name: 'Bored Ape #1234' }) - expect(filterNft(nft, 'Bored')).toBe(true) - }) - - it('should be case-insensitive', () => { - const nft = createMockNft({ name: 'Bored Ape #1234' }) - expect(filterNft(nft, 'bored')).toBe(true) - expect(filterNft(nft, 'BORED')).toBe(true) - expect(filterNft(nft, 'BoReD')).toBe(true) - }) - - it('should not match when name does not contain search term', () => { - const nft = createMockNft({ name: 'Bored Ape #1234' }) - expect(filterNft(nft, 'CryptoPunk')).toBe(false) - }) - - it('should handle undefined name', () => { - const nft = createMockNft({ - name: undefined, - collectionName: undefined, - tokenId: undefined, - contractAddress: undefined, - }) - expect(filterNft(nft, 'Bored')).toBe(false) - }) - - it('should handle null name', () => { - const nft = createMockNft({ - name: null as any, - collectionName: undefined, - tokenId: undefined, - contractAddress: undefined, - }) - expect(filterNft(nft, 'Bored')).toBe(false) - }) - }) - - describe('when searching by collection name', () => { - it('should match exact collection name', () => { - const nft = createMockNft({ collectionName: 'Bored Ape Yacht Club' }) - expect(filterNft(nft, 'Bored Ape Yacht Club')).toBe(true) - }) - - it('should match partial collection name', () => { - const nft = createMockNft({ collectionName: 'Bored Ape Yacht Club' }) - expect(filterNft(nft, 'Yacht')).toBe(true) - }) - - it('should be case-insensitive', () => { - const nft = createMockNft({ collectionName: 'Bored Ape Yacht Club' }) - expect(filterNft(nft, 'yacht')).toBe(true) - expect(filterNft(nft, 'YACHT')).toBe(true) - expect(filterNft(nft, 'YaChT')).toBe(true) - }) - - it('should not match when collection name does not contain search term', () => { - const nft = createMockNft({ collectionName: 'Bored Ape Yacht Club' }) - expect(filterNft(nft, 'CryptoPunks')).toBe(false) - }) - - it('should handle undefined collection name', () => { - const nft = createMockNft({ collectionName: undefined }) - expect(filterNft(nft, 'Yacht')).toBe(false) - }) - }) - - describe('when searching by token ID', () => { - it('should match exact token ID', () => { - const nft = createMockNft({ tokenId: '1234' }) - expect(filterNft(nft, '1234')).toBe(true) - }) - - it('should match partial token ID', () => { - const nft = createMockNft({ tokenId: '1234' }) - expect(filterNft(nft, '123')).toBe(true) - }) - - it('should be case-insensitive', () => { - const nft = createMockNft({ tokenId: 'ABC123' }) - expect(filterNft(nft, 'abc')).toBe(true) - expect(filterNft(nft, 'ABC')).toBe(true) - expect(filterNft(nft, 'AbC')).toBe(true) - }) - - it('should not match when token ID does not contain search term', () => { - const nft = createMockNft({ tokenId: '1234' }) - expect(filterNft(nft, '5678')).toBe(false) - }) - - it('should handle undefined token ID', () => { - const nft = createMockNft({ - tokenId: undefined, - name: undefined, - collectionName: undefined, - contractAddress: undefined, - }) - expect(filterNft(nft, '1234')).toBe(false) - }) - }) - - describe('when searching by contract address', () => { - it('should match exact contract address', () => { - const nft = createMockNft({ contractAddress: '0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D' }) - expect(filterNft(nft, '0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D')).toBe(true) - }) - - it('should match partial contract address', () => { - const nft = createMockNft({ contractAddress: '0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D' }) - expect(filterNft(nft, 'BC4CA0')).toBe(true) - }) - - it('should be case-insensitive', () => { - const nft = createMockNft({ contractAddress: '0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D' }) - expect(filterNft(nft, 'bc4ca0')).toBe(true) - expect(filterNft(nft, 'BC4CA0')).toBe(true) - expect(filterNft(nft, 'Bc4Ca0')).toBe(true) - }) - - it('should not match when contract address does not contain search term', () => { - const nft = createMockNft({ contractAddress: '0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D' }) - expect(filterNft(nft, '0x123456789')).toBe(false) - }) - - it('should handle undefined contract address', () => { - const nft = createMockNft({ contractAddress: undefined }) - expect(filterNft(nft, 'BC4CA0')).toBe(false) - }) - }) - - describe('when searching with whitespace', () => { - it('should trim leading and trailing whitespace', () => { - const nft = createMockNft({ name: 'Bored Ape #1234' }) - expect(filterNft(nft, ' Bored ')).toBe(true) - expect(filterNft(nft, '\tBored\n')).toBe(true) - }) - - it('should handle whitespace-only search as empty search', () => { - const nft = createMockNft() - expect(filterNft(nft, ' ')).toBe(true) - expect(filterNft(nft, '\t\n')).toBe(true) - }) - }) - - describe('edge cases', () => { - it('should handle NFT with all undefined fields', () => { - const nft = createMockNft({ - name: undefined, - collectionName: undefined, - tokenId: undefined, - contractAddress: undefined, - }) - expect(filterNft(nft, 'anything')).toBe(false) - }) - - it('should handle NFT with empty string fields', () => { - const nft = createMockNft({ - name: '', - collectionName: '', - tokenId: '', - contractAddress: '', - }) - expect(filterNft(nft, 'anything')).toBe(false) - }) - - it('should handle special characters in search', () => { - const nft = createMockNft({ name: 'NFT #1234' }) - expect(filterNft(nft, '#')).toBe(true) - expect(filterNft(nft, '1234')).toBe(true) - }) - - it('should handle unicode characters', () => { - const nft = createMockNft({ name: '🚀 Rocket NFT' }) - expect(filterNft(nft, '🚀')).toBe(true) - expect(filterNft(nft, 'Rocket')).toBe(true) - }) - }) - - describe('real-world examples', () => { - it('should match Bored Ape Yacht Club NFT', () => { - const nft = createMockNft({ - name: 'Bored Ape #1234', - collectionName: 'Bored Ape Yacht Club', - tokenId: '1234', - contractAddress: '0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D', - }) - - expect(filterNft(nft, 'bored')).toBe(true) - expect(filterNft(nft, 'ape')).toBe(true) - expect(filterNft(nft, 'yacht')).toBe(true) - expect(filterNft(nft, '1234')).toBe(true) - expect(filterNft(nft, 'BC4CA0')).toBe(true) - }) - - it('should match CryptoPunks NFT', () => { - const nft = createMockNft({ - name: 'CryptoPunk #1234', - collectionName: 'CryptoPunks', - tokenId: '1234', - contractAddress: '0xb47e3cd837dDF8e4c57F05d70Ab865de6e193BBB', - }) - - expect(filterNft(nft, 'crypto')).toBe(true) - expect(filterNft(nft, 'punk')).toBe(true) - expect(filterNft(nft, 'punks')).toBe(true) - expect(filterNft(nft, '1234')).toBe(true) - }) - - it('should not match unrelated NFTs', () => { - const nft = createMockNft({ - name: 'Bored Ape #1234', - collectionName: 'Bored Ape Yacht Club', - tokenId: '1234', - contractAddress: '0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D', - }) - - expect(filterNft(nft, 'cryptopunk')).toBe(false) - expect(filterNft(nft, 'azuki')).toBe(false) - expect(filterNft(nft, '5678')).toBe(false) - }) - }) -}) diff --git a/apps/web/src/pages/Portfolio/NFTs/utils/filterNfts.ts b/apps/web/src/pages/Portfolio/NFTs/utils/filterNfts.ts deleted file mode 100644 index 643712033f0..00000000000 --- a/apps/web/src/pages/Portfolio/NFTs/utils/filterNfts.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { NFTItem } from 'uniswap/src/features/nfts/types' - -/** - * Filters an NFT item based on a search query. - * The search is case-insensitive and matches against: - * - NFT name - * - Collection name - * - Token ID - * - Contract address - * - * @param item - The NFT item to filter - * @param searchQuery - The search query (will be converted to lowercase) - * @returns true if the item matches the search query, false otherwise - */ -export function filterNft(item: NFTItem, searchQuery: string): boolean { - if (!searchQuery.trim()) { - return true - } - - const lowercaseSearch = searchQuery.trim().toLowerCase() - const name = item.name?.toLowerCase() ?? '' - const collectionName = item.collectionName?.toLowerCase() ?? '' - const tokenId = item.tokenId?.toLowerCase() ?? '' - const contract = item.contractAddress?.toLowerCase() ?? '' - - return ( - name.includes(lowercaseSearch) || - collectionName.includes(lowercaseSearch) || - tokenId.includes(lowercaseSearch) || - contract.includes(lowercaseSearch) - ) -} diff --git a/apps/web/src/pages/Portfolio/Portfolio.tsx b/apps/web/src/pages/Portfolio/Portfolio.tsx index 8959a955b19..e94bcd20f19 100644 --- a/apps/web/src/pages/Portfolio/Portfolio.tsx +++ b/apps/web/src/pages/Portfolio/Portfolio.tsx @@ -1,58 +1,41 @@ import { Layers, PortfolioDisconnectedDemoViewProperties, useExperimentValueFromLayer } from '@universe/gating' -import PortfolioConnectWalletBanner from 'pages/Portfolio/ConnectWalletBanner' -import { ConnectWalletBottomOverlay } from 'pages/Portfolio/ConnectWalletBottomOverlay' -import PortfolioHeader from 'pages/Portfolio/Header/Header' +import { useScroll } from 'hooks/useScroll' +import { CONNECT_WALLET_BANNER_HEIGHT, CONNECT_WALLET_FIXED_BOTTOM_SECTION_HEIGHT } from 'pages/Portfolio/constants' import useIsConnected from 'pages/Portfolio/Header/hooks/useIsConnected' -import { PortfolioContent } from 'pages/Portfolio/PortfolioContent' import PortfolioDisconnectedView from 'pages/Portfolio/PortfolioDisconnectedView' -import { Flex } from 'ui/src' -import { InterfacePageName } from 'uniswap/src/features/telemetry/constants' +import { PortfolioPageInner } from 'pages/Portfolio/PortfolioPageInner' +import { useMemo } from 'react' +import { InterfacePageName, SectionName } from 'uniswap/src/features/telemetry/constants' import Trace from 'uniswap/src/features/telemetry/Trace' +// Trigger slightly before banner fully scrolls out for more responsive animation +const SCROLL_BUFFER = 40 +const BANNER_SCROLL_THRESHOLD = CONNECT_WALLET_BANNER_HEIGHT - SCROLL_BUFFER +const DEMO_BOTTOM_MARGIN = CONNECT_WALLET_FIXED_BOTTOM_SECTION_HEIGHT - 40 + // eslint-disable-next-line import/no-unused-modules -- used in RouteDefinitions.tsx via lazy import export default function Portfolio() { const isConnected = useIsConnected() - const showDemoView = useExperimentValueFromLayer({ + const demoDisconnectedViewEnabled = useExperimentValueFromLayer({ layerName: Layers.PortfolioPage, param: PortfolioDisconnectedDemoViewProperties.DemoViewEnabled, defaultValue: false, }) + const showDemoDisconnectedView = demoDisconnectedViewEnabled && !isConnected + const { height: scrollY } = useScroll() + const isBannerVisible = useMemo(() => scrollY < BANNER_SCROLL_THRESHOLD, [scrollY]) + return ( - {!showDemoView && !isConnected ? ( + {!demoDisconnectedViewEnabled && !isConnected ? ( + ) : showDemoDisconnectedView ? ( + + + ) : ( - - {!isConnected && } - {!isConnected && } - - {isConnected ? ( - <> - - - {/* Animated Content Area - All routes show same content, filtered by chain */} - - - ) : ( - <> - - - {/* Animated Content Area - All routes show same content, filtered by chain */} - - - - - )} - + )} ) diff --git a/apps/web/src/pages/Portfolio/PortfolioContent.tsx b/apps/web/src/pages/Portfolio/PortfolioContent.tsx index 9f859aaa643..efbc638a1fe 100644 --- a/apps/web/src/pages/Portfolio/PortfolioContent.tsx +++ b/apps/web/src/pages/Portfolio/PortfolioContent.tsx @@ -1,23 +1,25 @@ +import { FeatureFlags, useFeatureFlag } from '@universe/gating' import PortfolioActivity from 'pages/Portfolio/Activity/Activity' -import PortfolioDefi from 'pages/Portfolio/Defi' -import { usePortfolioParams } from 'pages/Portfolio/Header/hooks/usePortfolioParams' +import { PortfolioDefi } from 'pages/Portfolio/Defi' +import { usePortfolioRoutes } from 'pages/Portfolio/Header/hooks/usePortfolioRoutes' import { usePortfolioTabsAnimation } from 'pages/Portfolio/Header/hooks/usePortfolioTabsAnimation' -import PortfolioNfts from 'pages/Portfolio/NFTs/Nfts' -import PortfolioOverview from 'pages/Portfolio/Overview' -import PortfolioTokens from 'pages/Portfolio/Tokens/Tokens' +import { PortfolioNfts } from 'pages/Portfolio/NFTs/Nfts' +import { PortfolioOverview } from 'pages/Portfolio/Overview/Overview' +import { PortfolioTokens } from 'pages/Portfolio/Tokens/Tokens' import { PortfolioTab } from 'pages/Portfolio/types' import { useLocation } from 'react-router' import { Flex } from 'ui/src' import { TransitionItem } from 'ui/src/animations/components/AnimatePresencePager' -const renderPortfolioContent = (tab: PortfolioTab | undefined) => { +const renderPortfolioContent = (tab: PortfolioTab | undefined, isPortfolioDefiTabEnabled: boolean) => { switch (tab) { case PortfolioTab.Overview: return case PortfolioTab.Tokens: return case PortfolioTab.Defi: - return + // If defi tab is disabled, usePortfolioRoutes will redirect to overview tab + return isPortfolioDefiTabEnabled ? : <> case PortfolioTab.Nfts: return case PortfolioTab.Activity: @@ -30,12 +32,13 @@ const renderPortfolioContent = (tab: PortfolioTab | undefined) => { export function PortfolioContent({ disabled }: { disabled?: boolean }): JSX.Element { const { pathname } = useLocation() const animationType = usePortfolioTabsAnimation(pathname) - const { tab } = usePortfolioParams() + const { tab } = usePortfolioRoutes() + const isPortfolioDefiTabEnabled = useFeatureFlag(FeatureFlags.PortfolioDefiTab) return ( - {renderPortfolioContent(tab)} + {renderPortfolioContent(tab, isPortfolioDefiTabEnabled)} ) diff --git a/apps/web/src/pages/Portfolio/PortfolioDisconnectedView.tsx b/apps/web/src/pages/Portfolio/PortfolioDisconnectedView.tsx index fe9e4c354d6..dcca5954c7d 100644 --- a/apps/web/src/pages/Portfolio/PortfolioDisconnectedView.tsx +++ b/apps/web/src/pages/Portfolio/PortfolioDisconnectedView.tsx @@ -1,105 +1,162 @@ -import DISCONNECTED_B_DARK from 'assets/images/portfolio-page-promo/dark.svg' -import DISCONNECTED_B_LIGHT from 'assets/images/portfolio-page-promo/light.svg' +import PREVIEW_IMG_DARK from 'assets/images/portfolio-page-disconnected-preview/dark.svg' +import PREVIEW_IMG_LIGHT from 'assets/images/portfolio-page-disconnected-preview/light.svg' +import PREVIEW_IMG_MOBILE_DARK from 'assets/images/portfolio-page-disconnected-preview/mobile-dark.svg' +import PREVIEW_IMG_MOBILE_LIGHT from 'assets/images/portfolio-page-disconnected-preview/mobile-light.svg' import { useAccountDrawer } from 'components/AccountDrawer/MiniPortfolio/hooks' +import { TOTAL_INTERFACE_NAV_HEIGHT } from 'pages/Portfolio/constants' import { useTranslation } from 'react-i18next' -import { Button, Flex, Image, Text, useIsDarkMode, useSporeColors } from 'ui/src' -import { INTERFACE_NAV_HEIGHT } from 'ui/src/theme' +import { Button, Flex, styled, Text, useIsDarkMode, useMedia, useSporeColors } from 'ui/src' +import { opacify } from 'ui/src/theme' import { useEnabledChains } from 'uniswap/src/features/chains/hooks/useEnabledChains' +import { ElementName, InterfaceEventName, InterfacePageName } from 'uniswap/src/features/telemetry/constants' +import Trace from 'uniswap/src/features/telemetry/Trace' const PADDING_TOP = 60 -const NAV_BORDER_WIDTH = 1 -const OFFSET_TOP = INTERFACE_NAV_HEIGHT + NAV_BORDER_WIDTH const LEFT_CONTENT_MAX_WIDTH = 262 +function useBackgroundGradient() { + const colors = useSporeColors() + const isDarkMode = useIsDarkMode() + return `linear-gradient(to top, ${opacify(isDarkMode ? 70 : 100, colors.surface1.val)} 0%, ${opacify(0, colors.surface1.val)} 100%)` +} + +const BottomFadeOverlay = styled(Flex, { + position: 'absolute', + bottom: 0, + left: 0, + right: 0, + height: 100, + width: 'calc(50vw + 25px)', + pointerEvents: 'none', + variants: { + fullWidth: { + true: { width: '100%' }, + }, + }, +}) + +const ImageWrapper = styled(Flex, { + position: 'absolute', + top: 0, + left: 0, + // on large screens, the image should scale up and always overflow on the right hand side. + minWidth: 'calc(50vw + 25px)', + minHeight: '100%', + borderRadius: '$rounded20', + borderWidth: 1, + borderColor: '$surface3', + overflow: 'hidden', + $xxxl: { + width: '148%', + }, + $xl: { + width: 'auto', + minWidth: 'auto', + maxWidth: '200%', + }, +}) + export default function PortfolioDisconnectedView() { const { t } = useTranslation() const enabledChains = useEnabledChains() const isDarkMode = useIsDarkMode() const accountDrawer = useAccountDrawer() const colors = useSporeColors() + const media = useMedia() + const backgroundGradient = useBackgroundGradient() return ( - + - - - {t('common.getStarted')} - - - {t('portfolio.disconnected.cta.description', { numNetworks: enabledChains.chains.length })} - - - - - - + + + {t('common.getStarted')} + + + {t('portfolio.disconnected.cta.description', { numNetworks: enabledChains.chains.length })} + + + + {/* Wrap in a flex with set height to avoid growing too tall in firefox */} + + + + + + {media.lg ? ( + + + Portfolio overview mobile preview image + + + + ) : ( + + + Portfolio overview preview image + + + + + )} - + ) } diff --git a/apps/web/src/pages/Portfolio/Tokens/Table/TokensContextMenuWrapper.tsx b/apps/web/src/pages/Portfolio/Tokens/Table/TokensContextMenuWrapper.tsx index 3bd16b1bd30..ccd185c5bd2 100644 --- a/apps/web/src/pages/Portfolio/Tokens/Table/TokensContextMenuWrapper.tsx +++ b/apps/web/src/pages/Portfolio/Tokens/Table/TokensContextMenuWrapper.tsx @@ -1,16 +1,28 @@ +import { Currency } from '@uniswap/sdk-core' +import { useModalState } from 'hooks/useModalState' +import { useAtom } from 'jotai' import useIsConnected from 'pages/Portfolio/Header/hooks/useIsConnected' +import { useNavigateToTokenDetails } from 'pages/Portfolio/Tokens/hooks/useNavigateToTokenDetails' import { TokenData } from 'pages/Portfolio/Tokens/hooks/useTransformTokenTableData' -import { PropsWithChildren, useMemo } from 'react' +import { PropsWithChildren, useCallback, useMemo } from 'react' import { ContextMenuTriggerMode } from 'uniswap/src/components/menus/types' import { TokenBalanceItemContextMenu } from 'uniswap/src/components/portfolio/TokenBalanceItemContextMenu' +import { ReportTokenIssueModalPropsAtom } from 'uniswap/src/components/reporting/ReportTokenIssueModal' import { PortfolioBalance } from 'uniswap/src/features/dataApi/types' +import { ModalName } from 'uniswap/src/features/telemetry/constants' +import { setClipboard } from 'uniswap/src/utils/clipboard' +import { useEvent } from 'utilities/src/react/hooks' -export default function TokensContextMenuWrapper({ +export function TokensContextMenuWrapper({ tokenData, triggerMode, children, }: PropsWithChildren<{ tokenData: TokenData; triggerMode?: ContextMenuTriggerMode }>): React.ReactNode { const isConnected = useIsConnected() + + const { openModal } = useModalState(ModalName.ReportTokenIssue) + const [, setModalProps] = useAtom(ReportTokenIssueModalPropsAtom) + const portfolioBalance: PortfolioBalance | undefined = useMemo(() => { if (!tokenData.currencyInfo) { return undefined @@ -19,20 +31,51 @@ export default function TokensContextMenuWrapper({ return { id: tokenData.id, cacheId: tokenData.id, - quantity: parseFloat(tokenData.balance.value), - balanceUSD: tokenData.rawValue, + quantity: tokenData.balance.value, + balanceUSD: tokenData.value, currencyInfo: tokenData.currencyInfo, relativeChange24: tokenData.change1d, - isHidden: false, + isHidden: tokenData.isHidden ?? false, + } + }, [ + tokenData.currencyInfo, + tokenData.id, + tokenData.balance.value, + tokenData.change1d, + tokenData.value, + tokenData.isHidden, + ]) + + const openReportTokenModal = useEvent((currency: Currency) => { + setModalProps({ source: 'portfolio', currency, isMarkedSpam: portfolioBalance?.currencyInfo.isSpam }) + openModal() + }) + + const copyAddressToClipboard = useCallback(async (address: string): Promise => { + await setClipboard(address) + }, []) + + const navigateToTokenDetails = useNavigateToTokenDetails() + + const openReportTokenModalForCurrency = useCallback(() => { + if (portfolioBalance) { + openReportTokenModal(portfolioBalance.currencyInfo.currency) } - }, [tokenData.currencyInfo, tokenData.id, tokenData.balance.value, tokenData.change1d, tokenData.rawValue]) + }, [portfolioBalance, openReportTokenModal]) if (!portfolioBalance || !isConnected) { return children } return ( - + navigateToTokenDetails(tokenData)} + disableNotifications={true} + > {children} ) diff --git a/apps/web/src/pages/Portfolio/Tokens/Table/TokensTable.tsx b/apps/web/src/pages/Portfolio/Tokens/Table/TokensTable.tsx index 39c41307320..f9b52a88fa3 100644 --- a/apps/web/src/pages/Portfolio/Tokens/Table/TokensTable.tsx +++ b/apps/web/src/pages/Portfolio/Tokens/Table/TokensTable.tsx @@ -1,12 +1,15 @@ import { NetworkStatus } from '@apollo/client' +import { SharedEventName } from '@uniswap/analytics-events' +import { PortfolioExpandoRow } from 'pages/Portfolio/components/PortfolioExpandoRow' import { TokenData } from 'pages/Portfolio/Tokens/hooks/useTransformTokenTableData' -import TokensTableInner from 'pages/Portfolio/Tokens/Table/TokensTableInner' -import { useState } from 'react' +import { TokensTableInner } from 'pages/Portfolio/Tokens/Table/TokensTableInner' +import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import { ScrollSync } from 'react-scroll-sync' -import { Flex, HeightAnimator, Text, TouchableArea } from 'ui/src' -import { AnglesDownUp } from 'ui/src/components/icons/AnglesDownUp' -import { SortVertical } from 'ui/src/components/icons/SortVertical' +import { Flex } from 'ui/src' +import { ElementName, SectionName } from 'uniswap/src/features/telemetry/constants' +import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { useTrace } from 'utilities/src/telemetry/trace/TraceContext' interface TokensTableProps { visible: TokenData[] @@ -17,10 +20,21 @@ interface TokensTableProps { error?: Error | undefined } -export default function TokensTable({ visible, hidden, loading, refetching, networkStatus, error }: TokensTableProps) { +export function TokensTable({ visible, hidden, loading, refetching, error }: TokensTableProps) { const { t } = useTranslation() const [isOpen, setIsOpen] = useState(false) const tableLoading = loading && !refetching + const trace = useTrace() + + const handleToggleHiddenTokens = useCallback(() => { + const newIsOpen = !isOpen + setIsOpen(newIsOpen) + sendAnalyticsEvent(SharedEventName.ELEMENT_CLICKED, { + element: ElementName.PortfolioHiddenTokensExpandoRow, + section: SectionName.PortfolioTokensTab, + ...trace, + }) + }, [isOpen, trace]) return ( // Scroll Sync Architecture: @@ -28,26 +42,25 @@ export default function TokensTable({ visible, hidden, loading, refetching, netw // - Each TokensTableInner uses externalScrollSync=true to skip its own ScrollSync wrapper // - Both tables use ScrollSyncPane with scrollGroup="portfolio-tokens" for coordination // - DO NOT remove this outer ScrollSync wrapper without updating the Table components - + {hidden.length > 0 && ( <> - setIsOpen(!isOpen)} row gap="$gap8" p="$spacing16"> - - {t('hidden.tokens.info.text.button', { numHidden: hidden.length })} - - - {isOpen ? ( - - ) : ( - - )} - - - - - + + {isOpen && ( + + )} )} diff --git a/apps/web/src/pages/Portfolio/Tokens/Table/TokensTableInner.tsx b/apps/web/src/pages/Portfolio/Tokens/Table/TokensTableInner.tsx index aeb4600af6a..3f4a3c66d66 100644 --- a/apps/web/src/pages/Portfolio/Tokens/Table/TokensTableInner.tsx +++ b/apps/web/src/pages/Portfolio/Tokens/Table/TokensTableInner.tsx @@ -1,172 +1,84 @@ -import { createColumnHelper } from '@tanstack/react-table' +import { SharedEventName } from '@uniswap/analytics-events' import { Table } from 'components/Table' -import { Cell } from 'components/Table/Cell' -import { HeaderCell } from 'components/Table/styled' -import { ValueWithFadedDecimals } from 'pages/Portfolio/components/ValueWithFadedDecimals/ValueWithFadedDecimals' +import { PORTFOLIO_TABLE_ROW_HEIGHT } from 'pages/Portfolio/constants' +import { useNavigateToTokenDetails } from 'pages/Portfolio/Tokens/hooks/useNavigateToTokenDetails' import { TokenData } from 'pages/Portfolio/Tokens/hooks/useTransformTokenTableData' -import Allocation from 'pages/Portfolio/Tokens/Table/columns/Allocation' -import Balance from 'pages/Portfolio/Tokens/Table/columns/Balance' -import ContextMenuButton from 'pages/Portfolio/Tokens/Table/columns/ContextMenuButton' -import RelativeChange1D from 'pages/Portfolio/Tokens/Table/columns/RelativeChange1D' -import TokenDisplay from 'pages/Portfolio/Tokens/Table/columns/TokenDisplay' -import Value from 'pages/Portfolio/Tokens/Table/columns/Value' -import TokensContextMenuWrapper from 'pages/Portfolio/Tokens/Table/TokensContextMenuWrapper' -import { useMemo } from 'react' +import { useTokenColumns } from 'pages/Portfolio/Tokens/Table/columns/useTokenColumns' +import { useCallback } from 'react' import { useTranslation } from 'react-i18next' -import { Text } from 'ui/src' +import { TouchableArea } from 'ui/src' +import { InformationBanner } from 'uniswap/src/components/banners/InformationBanner' +import { ElementName, SectionName } from 'uniswap/src/features/telemetry/constants' +import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { HiddenTokenInfoModal } from 'uniswap/src/features/transactions/modals/HiddenTokenInfoModal' +import { useBooleanState } from 'utilities/src/react/useBooleanState' +import { useTrace } from 'utilities/src/telemetry/trace/TraceContext' -const hasRow = (obj: unknown): obj is { row: { original: T } } => { - const maybeRow = (obj as { row?: unknown }).row - return typeof maybeRow === 'object' && maybeRow !== null && 'original' in maybeRow && maybeRow.original !== undefined -} - -export default function TokensTableInner({ +export function TokensTableInner({ tokenData, hideHeader, + showHiddenTokensBanner = false, loading = false, error, }: { tokenData: TokenData[] hideHeader?: boolean + showHiddenTokensBanner?: boolean loading?: boolean error?: Error | undefined }) { const { t } = useTranslation() + const { value: isModalVisible, setTrue: openModal, setFalse: closeModal } = useBooleanState(false) const showLoadingSkeleton = loading || !!error + const trace = useTrace() - // Create table columns - const columns = useMemo(() => { - const columnHelper = createColumnHelper() + // Create table columns using the shared hook with default config (all columns shown) + const columns = useTokenColumns({ showLoadingSkeleton }) - return [ - columnHelper.accessor('currencyInfo', { - header: () => ( - - - {t('portfolio.tokens.table.column.token')} - - - ), - cell: (info) => { - return ( - - - - ) - }, - }), - columnHelper.accessor('price', { - header: () => ( - - - {t('portfolio.tokens.table.column.price')} - - - ), - cell: (info) => { - return ( - - - - ) - }, - }), - columnHelper.accessor('change1d', { - header: () => ( - - - {t('portfolio.tokens.table.column.change1d')} - - - ), - cell: (info) => { - return ( - - - - ) - }, - }), - columnHelper.accessor('balance', { - header: () => ( - - - {t('portfolio.tokens.table.column.balance')} - - - ), - cell: (info) => { - return ( - - - - ) - }, - }), - columnHelper.accessor('value', { - header: () => ( - - - {t('portfolio.tokens.table.column.value')} - - - ), - cell: (info) => { - return ( - - - - ) - }, - }), - columnHelper.accessor('allocation', { - header: () => ( - - - {t('portfolio.tokens.table.column.allocation')} - - - ), - cell: (info) => { - return ( - - - - ) - }, - }), - columnHelper.display({ - id: 'actions', - size: 40, - header: () => , - cell: (info) => { - const tokenData = hasRow(info) ? info.row.original : undefined - return ( - - {tokenData && } - - ) - }, - }), - ] - }, [t, showLoadingSkeleton]) + const navigateToTokenDetails = useNavigateToTokenDetails() + + const handleTokenRowClick = useCallback( + (tokenData: TokenData) => { + sendAnalyticsEvent(SharedEventName.ELEMENT_CLICKED, { + element: ElementName.TokenItem, + section: SectionName.PortfolioTokensTab, + ...trace, + }) + navigateToTokenDetails(tokenData) + }, + [navigateToTokenDetails, trace], + ) return ( -
row.id} - rowWrapper={ - loading - ? undefined - : (row, content) => {content} - } - /> + <> + {showHiddenTokensBanner && ( + + )} + +
row.id} + rowWrapper={ + loading + ? undefined + : (row, content) => ( + handleTokenRowClick(row.original)}>{content} + ) + } + rowHeight={PORTFOLIO_TABLE_ROW_HEIGHT} + compactRowHeight={PORTFOLIO_TABLE_ROW_HEIGHT} + defaultPinnedColumns={['currencyInfo']} + maxWidth={1200} + maxHeight={700} + centerArrows + /> + ) } diff --git a/apps/web/src/pages/Portfolio/Tokens/Table/columns/Balance.tsx b/apps/web/src/pages/Portfolio/Tokens/Table/columns/Balance.tsx index 9a9a00ac191..ccc760d7c1a 100644 --- a/apps/web/src/pages/Portfolio/Tokens/Table/columns/Balance.tsx +++ b/apps/web/src/pages/Portfolio/Tokens/Table/columns/Balance.tsx @@ -1,20 +1,26 @@ -import { TableText } from 'components/Table/styled' +import { EllipsisText } from 'components/Table/styled' import { ValueWithFadedDecimals } from 'pages/Portfolio/components/ValueWithFadedDecimals/ValueWithFadedDecimals' +import { EmptyTableCell } from 'pages/Portfolio/EmptyTableCell' import { TokenData } from 'pages/Portfolio/Tokens/hooks/useTransformTokenTableData' -import { memo } from 'react' -import { EM_DASH } from 'ui/src' +import { memo, useMemo } from 'react' +import { useLocalizationContext } from 'uniswap/src/features/language/LocalizationContext' +import { NumberType } from 'utilities/src/format/types' -const Balance = memo(function Balance({ value, symbol }: TokenData['balance']) { - if (!value && value !== '0') { - return {EM_DASH} +export const Balance = memo(function Balance({ balance }: { balance: TokenData['balance'] }) { + const { formatNumberOrString } = useLocalizationContext() + + const formattedBalance = useMemo(() => { + return formatNumberOrString({ value: balance.value, type: NumberType.TokenNonTx }) + }, [balance.value, formatNumberOrString]) + + if (!balance.value && balance.value !== 0) { + return } return ( - - {symbol} - + + {balance.symbol} + ) }) Balance.displayName = 'Balance' - -export default Balance diff --git a/apps/web/src/pages/Portfolio/Tokens/Tokens.tsx b/apps/web/src/pages/Portfolio/Tokens/Tokens.tsx index 4e44c419bcf..b23434294a2 100644 --- a/apps/web/src/pages/Portfolio/Tokens/Tokens.tsx +++ b/apps/web/src/pages/Portfolio/Tokens/Tokens.tsx @@ -1,16 +1,20 @@ +import { FeatureFlags, useFeatureFlag } from '@universe/gating' import { SearchInput } from 'pages/Portfolio/components/SearchInput' -import { usePortfolioParams } from 'pages/Portfolio/Header/hooks/usePortfolioParams' -import { usePortfolioAddress } from 'pages/Portfolio/hooks/usePortfolioAddress' +import { usePortfolioRoutes } from 'pages/Portfolio/Header/hooks/usePortfolioRoutes' +import { usePortfolioAddresses } from 'pages/Portfolio/hooks/usePortfolioAddresses' import { useTransformTokenTableData } from 'pages/Portfolio/Tokens/hooks/useTransformTokenTableData' import { TokensAllocationChart } from 'pages/Portfolio/Tokens/Table/TokensAllocationChart' -import TokensTable from 'pages/Portfolio/Tokens/Table/TokensTable' +import { TokensTable } from 'pages/Portfolio/Tokens/Table/TokensTable' import { filterTokensBySearch } from 'pages/Portfolio/Tokens/utils/filterTokensBySearch' -import { memo, useMemo, useState } from 'react' +import { memo, useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -import { Flex, RemoveScroll, Text } from 'ui/src' +import { useNavigate } from 'react-router' +import { Flex, RemoveScroll, Text, useMedia } from 'ui/src' +import { TokensListEmptyState } from 'uniswap/src/components/tokens/TokensListEmptyState' import { useEnabledChains } from 'uniswap/src/features/chains/hooks/useEnabledChains' +import { getChainLabel } from 'uniswap/src/features/chains/utils' import { PortfolioBalance } from 'uniswap/src/features/portfolio/PortfolioBalance/PortfolioBalance' -import { InterfacePageName } from 'uniswap/src/features/telemetry/constants' +import { ElementName, InterfacePageName, SectionName } from 'uniswap/src/features/telemetry/constants' import Trace from 'uniswap/src/features/telemetry/Trace' import { parseChainFromTokenSearchQuery } from 'uniswap/src/utils/search/parseChainFromTokenSearchQuery' @@ -27,7 +31,7 @@ const TokenCountIndicator = memo(({ count }: { count: number }) => { mx="$spacing8" /> - {t('portfolio.tokens.balance.totalTokens', { numTokens: count })} + {t('portfolio.tokens.balance.totalTokens', { numTokens: count, count })} ) @@ -35,12 +39,15 @@ const TokenCountIndicator = memo(({ count }: { count: number }) => { TokenCountIndicator.displayName = 'TokenCountIndicator' -export default function PortfolioTokens() { - const portfolioAddress = usePortfolioAddress() +export const PortfolioTokens = memo(function PortfolioTokens() { + const portfolioAddresses = usePortfolioAddresses() + const media = useMedia() + const navigate = useNavigate() const { t } = useTranslation() const [search, setSearch] = useState('') const { chains: enabledChains } = useEnabledChains() - const { chainId: urlChainId } = usePortfolioParams() + const { chainId: urlChainId } = usePortfolioRoutes() + const isPortfolioTokensAllocationChartEnabled = useFeatureFlag(FeatureFlags.PortfolioTokensAllocationChart) // Parse search query to extract chain filter and search term const { chainFilter, searchTerm } = useMemo(() => { @@ -72,34 +79,74 @@ export default function PortfolioTokens() { return filterTokensBySearch({ tokens: hiddenTokenData || [], searchTerm }) || [] }, [hiddenTokenData, searchTerm]) + // Handler to clear chain filter and show all networks + const handleShowAllNetworks = useCallback(() => { + navigate('/portfolio/tokens') + }, [navigate]) + + // Custom empty state for chain filtering + const chainFilterEmptyState = useMemo(() => { + if (!urlChainId) { + return undefined + } + const chainName = getChainLabel(urlChainId) + return ( + + ) + }, [handleShowAllNetworks, urlChainId, t]) + return ( - - : undefined} - /> - + + + : undefined} + chainIds={effectiveChainId ? [effectiveChainId] : undefined} + /> + + + + {(tokenData && tokenData.length > 0) || loading ? ( <> - + {isPortfolioTokensAllocationChartEnabled && ( + + + + )} {(filteredTokenData?.length ?? 0) > 0 || loading ? ( - ) -} +}) + +PortfolioTokens.displayName = 'PortfolioTokens' diff --git a/apps/web/src/pages/Portfolio/Tokens/hooks/useTransformTokenTableData.ts b/apps/web/src/pages/Portfolio/Tokens/hooks/useTransformTokenTableData.ts index 2812331fa28..9671e6d6925 100644 --- a/apps/web/src/pages/Portfolio/Tokens/hooks/useTransformTokenTableData.ts +++ b/apps/web/src/pages/Portfolio/Tokens/hooks/useTransformTokenTableData.ts @@ -1,39 +1,37 @@ import { NetworkStatus } from '@apollo/client' -import { usePortfolioAddress } from 'pages/Portfolio/hooks/usePortfolioAddress' +import { usePortfolioAddresses } from 'pages/Portfolio/hooks/usePortfolioAddresses' import { useMemo } from 'react' import { UniverseChainId } from 'uniswap/src/features/chains/types' import { useSortedPortfolioBalances } from 'uniswap/src/features/dataApi/balances/balances' import type { PortfolioBalance } from 'uniswap/src/features/dataApi/types' import { CurrencyInfo } from 'uniswap/src/features/dataApi/types' -import { useLocalizationContext } from 'uniswap/src/features/language/LocalizationContext' -import { NumberType } from 'utilities/src/format/types' export interface TokenData { id: string currencyInfo: CurrencyInfo | null // Full currency info including logoUrl - price: string + price: number change1d: number | undefined balance: { - value: string + value: number symbol: string | undefined } - value: string - rawValue: Maybe + value: number allocation: number + isHidden: boolean | null | undefined } // Custom hook to format portfolio data -export function useTransformTokenTableData({ chainIds }: { chainIds?: UniverseChainId[] }): { +export function useTransformTokenTableData({ chainIds, limit }: { chainIds?: UniverseChainId[]; limit?: number }): { visible: TokenData[] | null hidden: TokenData[] | null + totalCount: number | null loading: boolean refetching: boolean error: Error | undefined refetch: (() => void) | undefined networkStatus: NetworkStatus } { - const portfolioAddress = usePortfolioAddress() - const { convertFiatAmountFormatted, formatNumberOrString } = useLocalizationContext() + const { evmAddress, svmAddress } = usePortfolioAddresses() const { data: sortedBalances, @@ -42,7 +40,8 @@ export function useTransformTokenTableData({ chainIds }: { chainIds?: UniverseCh refetch, networkStatus, } = useSortedPortfolioBalances({ - evmAddress: portfolioAddress, + evmAddress, + svmAddress, chainIds, }) @@ -52,37 +51,41 @@ export function useTransformTokenTableData({ chainIds }: { chainIds?: UniverseCh const isRefetching = loading && !!sortedBalances if (isInitialLoading) { - return { visible: null, hidden: null, loading, refetching: false, error, refetch, networkStatus } + return { + visible: null, + hidden: null, + totalCount: null, + loading, + refetching: false, + error, + refetch, + networkStatus, + } } if (!sortedBalances) { - return { visible: [], hidden: [], loading, refetching: false, error, refetch, networkStatus } + return { visible: [], hidden: [], totalCount: 0, loading, refetching: false, error, refetch, networkStatus } } // Compute total USD across visible balances to determine allocation per token const totalUSDVisible = sortedBalances.balances.reduce((sum, b) => sum + (b.balanceUSD ?? 0), 0) const mapBalanceToTokenData = (balance: PortfolioBalance, allocationFromTotal?: number): TokenData => { - const price = - balance.balanceUSD && balance.quantity > 0 - ? convertFiatAmountFormatted(balance.balanceUSD / balance.quantity, NumberType.FiatTokenPrice) - : '$0.00' - - const formattedBalance = formatNumberOrString({ value: balance.quantity, type: NumberType.TokenNonTx }) - const value = convertFiatAmountFormatted(balance.balanceUSD, NumberType.PortfolioBalance) + const balanceUSD = balance.balanceUSD ?? 0 + const priceRaw = balanceUSD > 0 && balance.quantity > 0 ? balanceUSD / balance.quantity : 0 return { id: balance.id, currencyInfo: balance.currencyInfo, - price, + price: priceRaw, change1d: balance.relativeChange24 || undefined, balance: { - value: formattedBalance, + value: balance.quantity, symbol: balance.currencyInfo.currency.symbol, }, - value, - rawValue: balance.balanceUSD, + value: balanceUSD, allocation: allocationFromTotal ?? 0, + isHidden: balance.isHidden, } } @@ -94,6 +97,19 @@ export function useTransformTokenTableData({ chainIds }: { chainIds?: UniverseCh const hidden = sortedBalances.hiddenBalances.map((b) => mapBalanceToTokenData(b, 0)) - return { visible, hidden, loading, refetching: isRefetching, refetch, networkStatus, error } - }, [loading, sortedBalances, convertFiatAmountFormatted, formatNumberOrString, error, refetch, networkStatus]) + // Apply limit to visible tokens if specified + const limitedVisible = limit ? visible.slice(0, limit) : visible + const totalCount = visible.length + + return { + visible: limitedVisible, + hidden, + totalCount, + loading, + refetching: isRefetching, + refetch, + networkStatus, + error, + } + }, [loading, sortedBalances, error, refetch, networkStatus, limit]) } diff --git a/apps/web/src/pages/Portfolio/hooks/usePortfolioAddress.ts b/apps/web/src/pages/Portfolio/hooks/usePortfolioAddress.ts deleted file mode 100644 index b3909dc2a0d..00000000000 --- a/apps/web/src/pages/Portfolio/hooks/usePortfolioAddress.ts +++ /dev/null @@ -1,13 +0,0 @@ -/* eslint-disable-next-line no-restricted-imports, no-restricted-syntax */ -import { useAccount } from 'hooks/useAccount' - -// This is the address used for the disconnected demo view. It is only used in the disconnected state for the portfolio page. -const DEMO_WALLET_ADDRESS = '0x8796207d877194d97a2c360c041f13887896FC79' - -export function usePortfolioAddress() { - const account = useAccount() - if (!account.address) { - return DEMO_WALLET_ADDRESS - } - return account.address -} diff --git a/apps/web/src/pages/Positions/index.tsx b/apps/web/src/pages/Positions/index.tsx index 43c75054aeb..795d43a7e94 100644 --- a/apps/web/src/pages/Positions/index.tsx +++ b/apps/web/src/pages/Positions/index.tsx @@ -1,3 +1,4 @@ +/* eslint-disable max-lines */ import { PositionStatus, ProtocolVersion } from '@uniswap/client-data-api/dist/data/v1/poolTypes_pb' import { FeatureFlags, useFeatureFlag } from '@universe/gating' import PROVIDE_LIQUIDITY from 'assets/images/provideLiquidity.png' @@ -15,7 +16,6 @@ import { PositionInfo } from 'components/Liquidity/types' import { getPositionUrl } from 'components/Liquidity/utils/getPositionUrl' import { parseRestPosition } from 'components/Liquidity/utils/parseFromRest' import { useAccount } from 'hooks/useAccount' -import { useInfiniteScroll } from 'hooks/useInfiniteScroll' import { useLpIncentives } from 'hooks/useLpIncentives' import { atom, useAtom } from 'jotai' import { TopPools } from 'pages/Positions/TopPools' @@ -41,6 +41,7 @@ import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' import Trace from 'uniswap/src/features/telemetry/Trace' import { useIsMissingPlatformWallet } from 'uniswap/src/features/transactions/swap/components/SwapFormButton/hooks/useIsMissingPlatformWallet' import { usePositionVisibilityCheck } from 'uniswap/src/features/visibility/hooks/usePositionVisibilityCheck' +import { useInfiniteScroll } from 'utilities/src/react/useInfiniteScroll' // The BE limits the number of positions by chain and protocol version. // PAGE_SIZE=25 means the limit is at most 25 positions * x chains * y protocol versions. @@ -82,7 +83,7 @@ function DisconnectedWalletView() { {connectedWithoutEVM ? t('pool.connectEthereumToView') : t('positions.welcome.connect.description')} - + {!connectedWithoutEVM && ( @@ -151,7 +146,7 @@ function EmptyPositionsView() { {t('positions.noPositions.description')} - +